Skip to content

Commit 354ce53

Browse files
committed
Fix third-party package URL confirmation
1 parent ff4f7ba commit 354ce53

6 files changed

Lines changed: 306 additions & 8 deletions

File tree

src/commands/plugin/__test__/plugin-entry.spec.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,15 @@ test("tryRunPluginCommand dispatches single-plugin upgrade commands", async () =
7373

7474
await expect(
7575
tryRunPluginCommand(
76-
["plugin", "upgrade", "demo-plugin", "--url", "https://example.com/plugin.jar", "--json"],
76+
[
77+
"plugin",
78+
"upgrade",
79+
"demo-plugin",
80+
"--url",
81+
"https://example.com/plugin.jar",
82+
"--yes",
83+
"--json",
84+
],
7785
runtimeMock as never,
7886
),
7987
).resolves.toBe(true);
@@ -285,7 +293,7 @@ test("tryRunPluginCommand dispatches install subcommands from urls", async () =>
285293

286294
await expect(
287295
tryRunPluginCommand(
288-
["plugin", "install", "--url", "https://example.com/plugin.jar", "--json"],
296+
["plugin", "install", "--url", "https://example.com/plugin.jar", "--yes", "--json"],
289297
runtimeMock as never,
290298
),
291299
).resolves.toBe(true);
@@ -297,6 +305,29 @@ test("tryRunPluginCommand dispatches install subcommands from urls", async () =>
297305
});
298306
});
299307

308+
test("tryRunPluginCommand rejects third-party install urls without --yes outside interactive terminals", async () => {
309+
silenceStdout();
310+
311+
const runtimeMock = createPluginRuntimeMock({
312+
console: {
313+
plugin: {
314+
plugin: {
315+
installPluginFromUri: vi.fn(),
316+
},
317+
},
318+
},
319+
});
320+
321+
await expect(
322+
tryRunPluginCommand(
323+
["plugin", "install", "--url", "https://example.com/plugin.jar", "--json"],
324+
runtimeMock as never,
325+
),
326+
).rejects.toThrow(/requires confirmation in interactive mode.*or use --yes/i);
327+
328+
expect(runtimeMock.getClientsForOptions).not.toHaveBeenCalled();
329+
});
330+
300331
test("tryRunPluginCommand dispatches install subcommands from files", async () => {
301332
silenceStdout();
302333

src/commands/plugin/index.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { CliError } from "../../utils/errors.js";
2121
import { parseBooleanOption } from "../../utils/options.js";
2222
import { printJson } from "../../utils/output.js";
2323
import { loadFileAsJar } from "../../utils/package-file.js";
24+
import { confirmThirdPartyPackageSource } from "../../utils/remote-source.js";
2425
import { RuntimeContext } from "../../utils/runtime.js";
2526
import { printPlugin, printPluginList } from "./format.js";
2627

@@ -670,13 +671,28 @@ function buildPluginCli(runtime: RuntimeContext): CAC {
670671
.option("--url <url>", "Remote JAR URL")
671672
.option("--uri <uri>", "Remote JAR URI")
672673
.option("--file <path>", "Local JAR file path")
674+
.option("-y, --yes", "Skip third-party URL confirmation")
673675
.action(async (options: PluginCommandOptions) => {
674676
if (options.online) {
675677
throw new CliError("`halo plugin install` does not support --online. Use --url or --file.");
676678
}
677679

678-
const { clients } = await runtime.getClientsForOptions(options);
679680
const source = resolvePluginInstallSource(options);
681+
if (
682+
source.url &&
683+
!(await confirmThirdPartyPackageSource(
684+
source.url,
685+
{
686+
commandPath: "halo plugin install",
687+
actionLabel: "installing plugin",
688+
},
689+
options,
690+
))
691+
) {
692+
return;
693+
}
694+
695+
const { clients } = await runtime.getClientsForOptions(options);
680696
const response = source.url
681697
? await clients.console.plugin.plugin.installPluginFromUri({
682698
installFromUriRequest: { uri: source.url },
@@ -721,8 +737,22 @@ function buildPluginCli(runtime: RuntimeContext): CAC {
721737
return;
722738
}
723739

724-
const { clients } = await runtime.getClientsForOptions(options);
725740
const source = resolvePluginUpgradeSource(options);
741+
if (
742+
source.kind === "url" &&
743+
!(await confirmThirdPartyPackageSource(
744+
source.url,
745+
{
746+
commandPath: "halo plugin upgrade",
747+
actionLabel: `upgrading plugin ${target.name}`,
748+
},
749+
options,
750+
))
751+
) {
752+
return;
753+
}
754+
755+
const { clients } = await runtime.getClientsForOptions(options);
726756

727757
let response;
728758

src/commands/theme/__test__/theme-entry.spec.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ test("tryRunThemeCommand dispatches install subcommands from urls", async () =>
241241

242242
await expect(
243243
tryRunThemeCommand(
244-
["theme", "install", "--url", "https://example.com/theme.zip", "--json"],
244+
["theme", "install", "--url", "https://example.com/theme.zip", "--yes", "--json"],
245245
runtimeMock as never,
246246
),
247247
).resolves.toBe(true);
@@ -253,6 +253,29 @@ test("tryRunThemeCommand dispatches install subcommands from urls", async () =>
253253
});
254254
});
255255

256+
test("tryRunThemeCommand rejects third-party install urls without --yes outside interactive terminals", async () => {
257+
silenceStdout();
258+
259+
const runtimeMock = createThemeRuntimeMock({
260+
console: {
261+
theme: {
262+
theme: {
263+
installThemeFromUri: vi.fn(),
264+
},
265+
},
266+
},
267+
});
268+
269+
await expect(
270+
tryRunThemeCommand(
271+
["theme", "install", "--url", "https://example.com/theme.zip", "--json"],
272+
runtimeMock as never,
273+
),
274+
).rejects.toThrow(/requires confirmation in interactive mode.*or use --yes/i);
275+
276+
expect(runtimeMock.getClientsForOptions).not.toHaveBeenCalled();
277+
});
278+
256279
test("tryRunThemeCommand rejects unknown online install flags during parsing", async () => {
257280
silenceStdout();
258281

@@ -335,7 +358,15 @@ test("tryRunThemeCommand dispatches upgrade subcommands from urls", async () =>
335358

336359
await expect(
337360
tryRunThemeCommand(
338-
["theme", "upgrade", "demo-theme", "--url", "https://example.com/theme.zip", "--json"],
361+
[
362+
"theme",
363+
"upgrade",
364+
"demo-theme",
365+
"--url",
366+
"https://example.com/theme.zip",
367+
"--yes",
368+
"--json",
369+
],
339370
runtimeMock as never,
340371
),
341372
).resolves.toBe(true);

src/commands/theme/index.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { confirmDangerousAction } from "../../utils/confirmation.js";
1616
import { CliError } from "../../utils/errors.js";
1717
import { printJson } from "../../utils/output.js";
1818
import { loadFileAsZip } from "../../utils/package-file.js";
19+
import { confirmThirdPartyPackageSource } from "../../utils/remote-source.js";
1920
import { RuntimeContext } from "../../utils/runtime.js";
2021
import { printTheme, printThemeList } from "./format.js";
2122

@@ -592,13 +593,28 @@ function buildThemeCli(runtime: RuntimeContext): CAC {
592593
.option("--url <url>", "Remote ZIP URL")
593594
.option("--uri <uri>", "Remote ZIP URI")
594595
.option("--file <path>", "Local ZIP file path")
596+
.option("-y, --yes", "Skip third-party URL confirmation")
595597
.action(async (options: ThemeCommandOptions) => {
596598
if (options.online) {
597599
throw new CliError("`halo theme install` does not support --online. Use --url or --file.");
598600
}
599601

600-
const { clients } = await runtime.getClientsForOptions(options);
601602
const source = resolveThemeInstallSource(options);
603+
if (
604+
source.url &&
605+
!(await confirmThirdPartyPackageSource(
606+
source.url,
607+
{
608+
commandPath: "halo theme install",
609+
actionLabel: "installing theme",
610+
},
611+
options,
612+
))
613+
) {
614+
return;
615+
}
616+
617+
const { clients } = await runtime.getClientsForOptions(options);
602618
const formData = new FormData();
603619
if (source.file) {
604620
formData.append("file", await loadFileAsZip(source.file));
@@ -648,8 +664,22 @@ function buildThemeCli(runtime: RuntimeContext): CAC {
648664
return;
649665
}
650666

651-
const { clients } = await runtime.getClientsForOptions(options);
652667
const source = resolvePluginUpgradeSource(options);
668+
if (
669+
source.kind === "url" &&
670+
!(await confirmThirdPartyPackageSource(
671+
source.url,
672+
{
673+
commandPath: "halo theme upgrade",
674+
actionLabel: `upgrading theme ${target.name}`,
675+
},
676+
options,
677+
))
678+
) {
679+
return;
680+
}
681+
682+
const { clients } = await runtime.getClientsForOptions(options);
653683
let response;
654684

655685
try {
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { afterEach, expect, test, vi } from "vitest";
2+
3+
vi.mock("@inquirer/prompts", () => ({
4+
confirm: vi.fn(),
5+
}));
6+
7+
import { confirm } from "@inquirer/prompts";
8+
9+
import {
10+
confirmThirdPartyPackageSource,
11+
requiresThirdPartyPackageSourceConfirmation,
12+
} from "../remote-source.js";
13+
14+
afterEach(() => {
15+
vi.restoreAllMocks();
16+
});
17+
18+
test("requiresThirdPartyPackageSourceConfirmation skips halo.run URLs", () => {
19+
expect(
20+
requiresThirdPartyPackageSourceConfirmation("https://www.halo.run/plugins/demo-plugin.jar"),
21+
).toBe(false);
22+
});
23+
24+
test("requiresThirdPartyPackageSourceConfirmation skips file URIs", () => {
25+
expect(requiresThirdPartyPackageSourceConfirmation("file:///tmp/demo-plugin.jar")).toBe(false);
26+
});
27+
28+
test("requiresThirdPartyPackageSourceConfirmation flags third-party URLs", () => {
29+
expect(
30+
requiresThirdPartyPackageSourceConfirmation("https://downloads.example.com/demo-plugin.jar"),
31+
).toBe(true);
32+
});
33+
34+
test("confirmThirdPartyPackageSource skips prompting with --yes", async () => {
35+
await expect(
36+
confirmThirdPartyPackageSource(
37+
"https://downloads.example.com/demo-plugin.jar",
38+
{
39+
commandPath: "halo plugin install",
40+
actionLabel: "installing plugin",
41+
},
42+
{ yes: true },
43+
),
44+
).resolves.toBe(true);
45+
46+
expect(confirm).not.toHaveBeenCalled();
47+
});
48+
49+
test("confirmThirdPartyPackageSource requires --yes outside interactive terminals", async () => {
50+
Object.defineProperty(process.stdin, "isTTY", {
51+
value: false,
52+
configurable: true,
53+
});
54+
Object.defineProperty(process.stdout, "isTTY", {
55+
value: false,
56+
configurable: true,
57+
});
58+
59+
await expect(
60+
confirmThirdPartyPackageSource(
61+
"https://downloads.example.com/demo-plugin.jar",
62+
{
63+
commandPath: "halo plugin install",
64+
actionLabel: "installing plugin",
65+
},
66+
{},
67+
),
68+
).rejects.toThrow(/requires confirmation in interactive mode.*or use --yes/i);
69+
});
70+
71+
test("confirmThirdPartyPackageSource returns false when user cancels", async () => {
72+
Object.defineProperty(process.stdin, "isTTY", {
73+
value: true,
74+
configurable: true,
75+
});
76+
Object.defineProperty(process.stdout, "isTTY", {
77+
value: true,
78+
configurable: true,
79+
});
80+
81+
vi.mocked(confirm).mockResolvedValue(false);
82+
const stdoutSpy = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
83+
84+
await expect(
85+
confirmThirdPartyPackageSource(
86+
"https://downloads.example.com/demo-plugin.jar",
87+
{
88+
commandPath: "halo plugin install",
89+
actionLabel: "installing plugin",
90+
},
91+
{},
92+
),
93+
).resolves.toBe(false);
94+
95+
expect(stdoutSpy).toHaveBeenCalledWith(
96+
"Warning: remote package URL is not hosted on www.halo.run: https://downloads.example.com/demo-plugin.jar\n",
97+
);
98+
expect(stdoutSpy).toHaveBeenCalledWith("Cancelled installing plugin.\n");
99+
});

0 commit comments

Comments
 (0)