Skip to content

Commit ff4f7ba

Browse files
committed
Fix app store upgrade release note confirmation
1 parent 26baa51 commit ff4f7ba

6 files changed

Lines changed: 940 additions & 24 deletions

File tree

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

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
11
import { afterEach, expect, test, vi } from "vitest";
22

3+
vi.mock("@inquirer/prompts", () => ({
4+
checkbox: vi.fn(),
5+
confirm: vi.fn(),
6+
input: vi.fn(),
7+
}));
8+
9+
import { confirm, input } from "@inquirer/prompts";
10+
311
import { tryRunPluginCommand } from "../index.js";
412

513
afterEach(() => {
@@ -10,6 +18,17 @@ function silenceStdout() {
1018
return vi.spyOn(process.stdout, "write").mockImplementation(() => true);
1119
}
1220

21+
function setInteractiveTerminal() {
22+
Object.defineProperty(process.stdin, "isTTY", {
23+
value: true,
24+
configurable: true,
25+
});
26+
Object.defineProperty(process.stdout, "isTTY", {
27+
value: true,
28+
configurable: true,
29+
});
30+
}
31+
1332
function createPluginRuntimeMock(clients: Record<string, unknown>) {
1433
return {
1534
getClientsForOptions: vi.fn().mockResolvedValue({
@@ -318,7 +337,10 @@ test("tryRunPluginCommand dispatches install subcommands from files", async () =
318337
});
319338

320339
test("tryRunPluginCommand dispatches online upgrade commands", async () => {
321-
silenceStdout();
340+
const stdoutSpy = silenceStdout();
341+
setInteractiveTerminal();
342+
vi.mocked(confirm).mockResolvedValue(true);
343+
vi.mocked(input).mockResolvedValue("y");
322344

323345
const getPlugin = vi.fn().mockResolvedValue({
324346
data: {
@@ -424,6 +446,9 @@ test("tryRunPluginCommand dispatches online upgrade commands", async () => {
424446

425447
expect(axiosCreateSpy).toHaveBeenCalledOnce();
426448
expect(getPlugin).toHaveBeenCalledWith({ name: "demo-plugin" });
449+
expect(stdoutSpy).toHaveBeenCalledWith(
450+
"- demo-plugin: https://www.halo.run/store/apps/demo-plugin-app/releases/release-1\n",
451+
);
427452
expect(upgradePluginFromUri).toHaveBeenCalledWith({
428453
name: "demo-plugin",
429454
upgradeFromUriRequest: {
@@ -432,6 +457,117 @@ test("tryRunPluginCommand dispatches online upgrade commands", async () => {
432457
});
433458
});
434459

460+
test("tryRunPluginCommand skips release note confirmation with --yes", async () => {
461+
vi.clearAllMocks();
462+
silenceStdout();
463+
464+
const getPlugin = vi.fn().mockResolvedValue({
465+
data: {
466+
metadata: {
467+
name: "demo-plugin",
468+
annotations: {
469+
"store.halo.run/app-id": "demo-plugin-app",
470+
},
471+
},
472+
},
473+
});
474+
const upgradePluginFromUri = vi.fn().mockResolvedValue({
475+
data: {
476+
metadata: {
477+
name: "demo-plugin",
478+
},
479+
},
480+
});
481+
const runtimeMock = createPluginRuntimeMock({
482+
core: {
483+
plugin: {
484+
plugin: {
485+
getPlugin,
486+
},
487+
},
488+
secret: {
489+
getSecret: vi.fn().mockResolvedValue({
490+
data: {
491+
stringData: {},
492+
},
493+
}),
494+
},
495+
},
496+
console: {
497+
plugin: {
498+
plugin: {
499+
upgradePluginFromUri,
500+
},
501+
},
502+
},
503+
axios: {
504+
get: vi.fn().mockImplementation((url: string) => {
505+
if (url === "/actuator/info") {
506+
return Promise.resolve({
507+
data: {
508+
build: {
509+
name: "halo",
510+
version: "2.20.0",
511+
},
512+
},
513+
});
514+
}
515+
516+
return Promise.reject(new Error(`Unexpected axios.get call: ${url}`));
517+
}),
518+
},
519+
});
520+
521+
vi.spyOn((await import("axios")).default, "create").mockReturnValue({
522+
get: vi.fn().mockImplementation((url: string) => {
523+
if (url === "/apis/api.store.halo.run/v1alpha1/applications/demo-plugin-app") {
524+
return Promise.resolve({
525+
data: {
526+
latestRelease: {
527+
release: {
528+
metadata: {
529+
name: "release-1",
530+
},
531+
},
532+
assets: [
533+
{
534+
metadata: {
535+
name: "plugin.jar",
536+
},
537+
},
538+
],
539+
},
540+
},
541+
});
542+
}
543+
544+
if (
545+
url ===
546+
"/apis/api.store.halo.run/v1alpha1/applications/demo-plugin-app/releases/release-1/download/plugin.jar"
547+
) {
548+
return Promise.resolve({
549+
data: {
550+
url: "https://downloads.example.com/demo-plugin.jar",
551+
},
552+
});
553+
}
554+
555+
return Promise.reject(new Error(`Unexpected app store get call: ${url}`));
556+
}),
557+
} as never);
558+
559+
await expect(
560+
tryRunPluginCommand(
561+
["plugin", "upgrade", "demo-plugin", "--online", "--yes", "--json"],
562+
runtimeMock as never,
563+
),
564+
).resolves.toBe(true);
565+
566+
expect(confirm).not.toHaveBeenCalled();
567+
expect(input).not.toHaveBeenCalled();
568+
expect(upgradePluginFromUri).toHaveBeenCalledOnce();
569+
});
570+
435571
test("tryRunPluginCommand rejects unknown install flags during parsing", async () => {
436572
silenceStdout();
437573

@@ -496,7 +632,9 @@ test("tryRunPluginCommand prints empty batch upgrade results in json mode", asyn
496632
});
497633

498634
test("tryRunPluginCommand upgrades batch plugin candidates in json mode", async () => {
499-
silenceStdout();
635+
const stdoutSpy = silenceStdout();
636+
setInteractiveTerminal();
637+
vi.mocked(confirm).mockResolvedValue(true);
500638

501639
const listPlugins = vi.fn().mockResolvedValue({
502640
data: {
@@ -644,4 +782,7 @@ test("tryRunPluginCommand upgrades batch plugin candidates in json mode", async
644782
uri: "https://downloads.example.com/demo-plugin.jar",
645783
},
646784
});
785+
expect(stdoutSpy).toHaveBeenCalledWith(
786+
"- Demo Plugin: https://www.halo.run/store/apps/demo-plugin-app/releases/release-1\n",
787+
);
647788
});

src/commands/plugin/index.ts

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import cac, { type CAC } from "cac";
88
import ora, { type Ora } from "ora";
99

1010
import {
11+
confirmAppStoreReleaseReview,
1112
createAppStoreClient,
12-
resolveLatestAppStoreDownloadUrl,
13+
resolveLatestAppStoreRelease,
1314
resolvePluginAppStoreAppId,
1415
resolvePluginUpdates,
1516
resolvePluginUpgradeSource,
@@ -41,6 +42,7 @@ interface PluginMutationOptions extends PluginCommandOptions {
4142
}
4243

4344
interface BatchUpgradeResult {
45+
cancelled?: boolean;
4446
upgraded: Array<{ name: string; fromVersion?: string; toVersion: string }>;
4547
skipped: Array<{
4648
name: string;
@@ -51,6 +53,13 @@ interface BatchUpgradeResult {
5153
failed: Array<{ name: string; error: string }>;
5254
}
5355

56+
interface PreparedPluginUpgradeCandidate {
57+
plugin: Plugin;
58+
update: { latestVersion: string; compatible: boolean };
59+
releaseUrl: string;
60+
downloadUrl: string;
61+
}
62+
5463
interface BatchUpgradeProgressEvent {
5564
type:
5665
| "checking"
@@ -350,9 +359,56 @@ async function upgradeAllPlugins(
350359
});
351360
}
352361

353-
onProgress?.({ type: "queued", count: selectedCandidates.length });
354-
362+
const preparedCandidates: PreparedPluginUpgradeCandidate[] = [];
355363
for (const item of selectedCandidates) {
364+
try {
365+
const appId = resolvePluginAppStoreAppId(item.plugin);
366+
const release = await resolveLatestAppStoreRelease(appStoreClient, appId);
367+
preparedCandidates.push({
368+
...item,
369+
releaseUrl: release.releaseUrl,
370+
downloadUrl: release.downloadUrl,
371+
});
372+
} catch (error) {
373+
result.failed.push({
374+
name: item.plugin.metadata.name,
375+
error: error instanceof Error ? error.message : "Unknown upgrade error.",
376+
});
377+
378+
onProgress?.({
379+
type: "failed",
380+
name: item.plugin.metadata.name,
381+
fromVersion: item.plugin.spec.version,
382+
toVersion: item.update.latestVersion,
383+
error: error instanceof Error ? error.message : "Unknown upgrade error.",
384+
});
385+
}
386+
}
387+
388+
if (preparedCandidates.length === 0) {
389+
return result;
390+
}
391+
392+
const confirmed = await confirmAppStoreReleaseReview(
393+
{
394+
commandPath: "halo plugin upgrade --all",
395+
actionLabel: "upgrading App Store plugins",
396+
items: preparedCandidates.map((item) => ({
397+
name: item.plugin.spec.displayName ?? item.plugin.metadata.name,
398+
releaseUrl: item.releaseUrl,
399+
})),
400+
},
401+
options,
402+
);
403+
404+
if (!confirmed) {
405+
result.cancelled = true;
406+
return result;
407+
}
408+
409+
onProgress?.({ type: "queued", count: preparedCandidates.length });
410+
411+
for (const item of preparedCandidates) {
356412
const { plugin, update } = item;
357413

358414
if (!update.compatible) {
@@ -380,11 +436,9 @@ async function upgradeAllPlugins(
380436
toVersion: update.latestVersion,
381437
});
382438

383-
const appId = resolvePluginAppStoreAppId(plugin);
384-
const downloadUrl = await resolveLatestAppStoreDownloadUrl(appStoreClient, appId);
385439
await clients.console.plugin.plugin.upgradePluginFromUri({
386440
name: plugin.metadata.name,
387-
upgradeFromUriRequest: { uri: downloadUrl },
441+
upgradeFromUriRequest: { uri: item.downloadUrl },
388442
});
389443

390444
onProgress?.({
@@ -475,6 +529,13 @@ function reportBatchUpgradeProgress(
475529
}
476530

477531
function printBatchUpgradeResult(result: BatchUpgradeResult, json = false): void {
532+
if (result.cancelled) {
533+
if (!json) {
534+
process.stdout.write("Cancelled upgrading App Store plugins.\n");
535+
}
536+
return;
537+
}
538+
478539
if (json) {
479540
printJson(result);
480541
return;
@@ -644,7 +705,6 @@ function buildPluginCli(runtime: RuntimeContext): CAC {
644705
.option("-y, --yes", "Skip selection and upgrade all compatible plugins")
645706
.action(async (name: string | undefined, options: PluginCommandOptions) => {
646707
const spinner = createSpinnerReporter(options.json);
647-
const { clients } = await runtime.getClientsForOptions(options);
648708
const target = resolvePluginUpgradeTarget(name, options);
649709

650710
if (target.mode === "all") {
@@ -661,6 +721,7 @@ function buildPluginCli(runtime: RuntimeContext): CAC {
661721
return;
662722
}
663723

724+
const { clients } = await runtime.getClientsForOptions(options);
664725
const source = resolvePluginUpgradeSource(options);
665726

666727
let response;
@@ -685,12 +746,32 @@ function buildPluginCli(runtime: RuntimeContext): CAC {
685746
const pluginResponse = await clients.core.plugin.plugin.getPlugin({ name: target.name });
686747
const appId = resolvePluginAppStoreAppId(pluginResponse.data);
687748
const appStoreClient = await createAppStoreClient(clients);
688-
const downloadUrl = await resolveLatestAppStoreDownloadUrl(appStoreClient, appId);
749+
const release = await resolveLatestAppStoreRelease(appStoreClient, appId);
689750

690-
spinner.update(`Upgrading plugin ${target.name} from Halo App Store...`);
751+
spinner.stop();
752+
const confirmed = await confirmAppStoreReleaseReview(
753+
{
754+
commandPath: "halo plugin upgrade",
755+
actionLabel: `upgrading plugin ${target.name}`,
756+
items: [
757+
{
758+
name: pluginResponse.data.spec?.displayName ?? target.name,
759+
releaseUrl: release.releaseUrl,
760+
},
761+
],
762+
requireTypedYes: true,
763+
},
764+
options,
765+
);
766+
767+
if (!confirmed) {
768+
return;
769+
}
770+
771+
spinner.start(`Upgrading plugin ${target.name} from Halo App Store...`);
691772
response = await clients.console.plugin.plugin.upgradePluginFromUri({
692773
name: target.name,
693-
upgradeFromUriRequest: { uri: downloadUrl },
774+
upgradeFromUriRequest: { uri: release.downloadUrl },
694775
});
695776
spinner.succeed(`Upgraded plugin ${target.name}.`);
696777
}

0 commit comments

Comments
 (0)