argsbarg 3.1.0 → 3.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -1
- package/README.md +4 -6
- package/docs/ai-skills.md +1 -1
- package/docs/bundled-docs.md +18 -4
- package/docs/install.md +51 -4
- package/docs/mcp.md +4 -6
- package/examples/minimal.ts +6 -0
- package/examples/nested.ts +3 -0
- package/index.d.ts +93 -1
- package/package.json +1 -1
- package/plan.md +10 -185
- package/src/builtins/completion-bash.ts +1 -8
- package/src/builtins/completion-fish.ts +0 -5
- package/src/builtins/completion-zsh.ts +1 -9
- package/src/builtins/dispatch.ts +27 -0
- package/src/builtins/export.ts +4 -0
- package/src/builtins/install.ts +9 -3
- package/src/builtins/presentation.ts +4 -0
- package/src/builtins/shell-helpers.ts +0 -2
- package/src/builtins/update.ts +14 -0
- package/src/capabilities.ts +7 -1
- package/src/docs/api-guide.test.ts +55 -0
- package/src/docs/api-guide.ts +129 -0
- package/src/docs/builtin.ts +3 -0
- package/src/docs/docs.test.ts +47 -1
- package/src/docs/mcp-guide.ts +3 -3
- package/src/docs/resolve.ts +32 -1
- package/src/headless.test.ts +86 -0
- package/src/headless.ts +86 -0
- package/src/help.ts +3 -7
- package/src/index.test.ts +36 -65
- package/src/index.ts +20 -0
- package/src/install/binary.ts +8 -3
- package/src/install/gh-release-update.test.ts +22 -0
- package/src/install/gh-release-update.ts +229 -0
- package/src/install/index.ts +55 -30
- package/src/install/plan.ts +5 -3
- package/src/install/update.test.ts +106 -0
- package/src/install/update.ts +55 -0
- package/src/invoke.ts +1 -11
- package/src/mcp/tools.ts +1 -1
- package/src/parse.ts +0 -27
- package/src/runtime.ts +7 -6
- package/src/schema.ts +7 -2
- package/src/skill/generate.ts +2 -2
- package/src/types.ts +17 -0
- package/src/validate.ts +11 -6
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
formatDryRunMessage,
|
|
4
|
+
requireYesInNonTty,
|
|
5
|
+
shouldRunHeadless,
|
|
6
|
+
shouldRunHeadlessWithPositionals,
|
|
7
|
+
shouldRunHeadlessWithYes,
|
|
8
|
+
wantsDryRun,
|
|
9
|
+
wantsExplicitJson,
|
|
10
|
+
} from "./headless.ts";
|
|
11
|
+
|
|
12
|
+
test("wantsDryRun detects flag", () => {
|
|
13
|
+
expect(wantsDryRun(true)).toBe(true);
|
|
14
|
+
expect(wantsDryRun(false)).toBe(false);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("wantsExplicitJson includes MCP invocation", () => {
|
|
18
|
+
expect(wantsExplicitJson({ invocation: "cli" }, false)).toBe(false);
|
|
19
|
+
expect(wantsExplicitJson({ invocation: "mcp" }, false)).toBe(true);
|
|
20
|
+
expect(wantsExplicitJson({ invocation: "cli" }, true)).toBe(true);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("shouldRunHeadless is true for MCP and json", () => {
|
|
24
|
+
expect(shouldRunHeadless({ invocation: "mcp" }, false)).toBe(true);
|
|
25
|
+
expect(shouldRunHeadless({ invocation: "cli" }, true)).toBe(true);
|
|
26
|
+
expect(shouldRunHeadless({ invocation: "cli" }, false, true)).toBe(true);
|
|
27
|
+
expect(shouldRunHeadless({ invocation: "cli" }, false, false, false)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("shouldRunHeadlessWithPositionals requires positionals in non-tty", () => {
|
|
31
|
+
expect(
|
|
32
|
+
shouldRunHeadlessWithPositionals({ invocation: "cli" }, false, [], false, false),
|
|
33
|
+
).toBe(false);
|
|
34
|
+
expect(
|
|
35
|
+
shouldRunHeadlessWithPositionals({ invocation: "cli" }, false, ["a"], false, false),
|
|
36
|
+
).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("shouldRunHeadlessWithYes requires yes in non-tty", () => {
|
|
40
|
+
expect(
|
|
41
|
+
shouldRunHeadlessWithYes(
|
|
42
|
+
{ invocation: "cli" },
|
|
43
|
+
{ yes: true, hasRequiredArgs: true },
|
|
44
|
+
false,
|
|
45
|
+
),
|
|
46
|
+
).toBe(true);
|
|
47
|
+
expect(
|
|
48
|
+
shouldRunHeadlessWithYes(
|
|
49
|
+
{ invocation: "cli" },
|
|
50
|
+
{ yes: false, hasRequiredArgs: true },
|
|
51
|
+
false,
|
|
52
|
+
),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
expect(
|
|
55
|
+
shouldRunHeadlessWithYes(
|
|
56
|
+
{ invocation: "cli" },
|
|
57
|
+
{ yes: false, hasRequiredArgs: true, dryRun: true },
|
|
58
|
+
false,
|
|
59
|
+
),
|
|
60
|
+
).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("formatDryRunMessage prefixes dry-run output", () => {
|
|
64
|
+
expect(formatDryRunMessage("hello", false)).toBe("hello");
|
|
65
|
+
expect(formatDryRunMessage("hello", true)).toBe("[DRY RUN] hello");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("requireYesInNonTty exits without yes in non-tty", () => {
|
|
69
|
+
const originalExit = process.exit;
|
|
70
|
+
let code: number | undefined;
|
|
71
|
+
process.exit = ((c?: number) => {
|
|
72
|
+
code = c ?? 0;
|
|
73
|
+
throw new Error("exit");
|
|
74
|
+
}) as typeof process.exit;
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
expect(() => {
|
|
78
|
+
requireYesInNonTty(false, "hint", false, false);
|
|
79
|
+
}).toThrow("exit");
|
|
80
|
+
expect(code).toBe(1);
|
|
81
|
+
requireYesInNonTty(false, "hint", true, false);
|
|
82
|
+
requireYesInNonTty(true, "hint", false, false);
|
|
83
|
+
} finally {
|
|
84
|
+
process.exit = originalExit;
|
|
85
|
+
}
|
|
86
|
+
});
|
package/src/headless.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { CliContext } from "./context.ts";
|
|
2
|
+
import { isInteractiveTty } from "./utils.ts";
|
|
3
|
+
|
|
4
|
+
/** Minimal context for headless routing helpers. */
|
|
5
|
+
export type HeadlessContext = Pick<CliContext, "invocation">;
|
|
6
|
+
|
|
7
|
+
/** True when `--dry-run` was passed. */
|
|
8
|
+
export function wantsDryRun(hasDryRunFlag: boolean): boolean {
|
|
9
|
+
return hasDryRunFlag;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** True when `--json` was passed or the handler was invoked via MCP. */
|
|
13
|
+
export function wantsExplicitJson(ctx: HeadlessContext, hasJsonFlag: boolean): boolean {
|
|
14
|
+
return hasJsonFlag || ctx.invocation === "mcp";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Headless when MCP, `--json`, `--dry-run`, or stdin is not a TTY.
|
|
19
|
+
* Use for commands that should auto-emit JSON in pipelines.
|
|
20
|
+
*/
|
|
21
|
+
export function shouldRunHeadless(
|
|
22
|
+
ctx: HeadlessContext,
|
|
23
|
+
hasJsonFlag: boolean,
|
|
24
|
+
hasDryRunFlag = false,
|
|
25
|
+
interactive: boolean = isInteractiveTty,
|
|
26
|
+
): boolean {
|
|
27
|
+
if (ctx.invocation === "mcp") return true;
|
|
28
|
+
if (hasJsonFlag || hasDryRunFlag) return true;
|
|
29
|
+
return !interactive;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Like {@link shouldRunHeadless}, but only auto-headless in non-TTY when positionals are present.
|
|
34
|
+
* Avoids turning empty invocations into JSON errors.
|
|
35
|
+
*/
|
|
36
|
+
export function shouldRunHeadlessWithPositionals(
|
|
37
|
+
ctx: HeadlessContext,
|
|
38
|
+
hasJsonFlag: boolean,
|
|
39
|
+
positionals: string[],
|
|
40
|
+
hasDryRunFlag = false,
|
|
41
|
+
interactive: boolean = isInteractiveTty,
|
|
42
|
+
): boolean {
|
|
43
|
+
if (ctx.invocation === "mcp") return true;
|
|
44
|
+
if (hasJsonFlag || hasDryRunFlag) return true;
|
|
45
|
+
return !interactive && positionals.length > 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Headless when MCP, `--dry-run` with required args, or non-TTY with `--yes` and required args.
|
|
50
|
+
* Use for mutating commands that require explicit `--yes` in scripts.
|
|
51
|
+
*/
|
|
52
|
+
export function shouldRunHeadlessWithYes(
|
|
53
|
+
ctx: HeadlessContext,
|
|
54
|
+
opts: { yes: boolean; hasRequiredArgs: boolean; dryRun?: boolean },
|
|
55
|
+
interactive: boolean = isInteractiveTty,
|
|
56
|
+
): boolean {
|
|
57
|
+
if (ctx.invocation === "mcp") {
|
|
58
|
+
return opts.hasRequiredArgs && (opts.yes || Boolean(opts.dryRun));
|
|
59
|
+
}
|
|
60
|
+
if (opts.dryRun && opts.hasRequiredArgs) return true;
|
|
61
|
+
if (!interactive) return opts.yes && opts.hasRequiredArgs;
|
|
62
|
+
return opts.yes && opts.hasRequiredArgs;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Exits when non-interactive mode is used without `--yes`.
|
|
67
|
+
* @param hint - Command-specific guidance appended to the error
|
|
68
|
+
*/
|
|
69
|
+
export function requireYesInNonTty(
|
|
70
|
+
yes: boolean,
|
|
71
|
+
hint: string,
|
|
72
|
+
dryRun = false,
|
|
73
|
+
interactive: boolean = isInteractiveTty,
|
|
74
|
+
): void {
|
|
75
|
+
if (dryRun) return;
|
|
76
|
+
if (!interactive && !yes) {
|
|
77
|
+
process.stderr.write(`Error: non-interactive mode requires --yes. ${hint}\n`);
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Prefixes a success message when running in dry-run mode. */
|
|
83
|
+
export function formatDryRunMessage(message: string, dryRun: boolean): string {
|
|
84
|
+
if (!dryRun) return message;
|
|
85
|
+
return `[DRY RUN] ${message}`;
|
|
86
|
+
}
|
package/src/help.ts
CHANGED
|
@@ -346,16 +346,12 @@ function usageLines(
|
|
|
346
346
|
}
|
|
347
347
|
|
|
348
348
|
/** Table rows for named options, including synthetic built-in rows. */
|
|
349
|
-
function rowsForOptions(defs: CliOption[], color: boolean
|
|
349
|
+
function rowsForOptions(defs: CliOption[], color: boolean): HelpRow[] {
|
|
350
350
|
const rows: HelpRow[] = [];
|
|
351
351
|
const helpLabel = color
|
|
352
352
|
? style.aquaBold("--help, ") + style.greenBright("-h")
|
|
353
353
|
: "--help, -h";
|
|
354
354
|
rows.push({ label: helpLabel, description: "Show help for this command." });
|
|
355
|
-
if (isRoot) {
|
|
356
|
-
const schemaLabel = color ? style.aquaBold("--schema") : "--schema";
|
|
357
|
-
rows.push({ label: schemaLabel, description: "Print the full command tree as JSON." });
|
|
358
|
-
}
|
|
359
355
|
for (const o of defs) {
|
|
360
356
|
const desc = o.required ? "(required) " + o.description : o.description;
|
|
361
357
|
rows.push({ label: cliOptionLabel(o, color), description: desc });
|
|
@@ -401,7 +397,7 @@ export function cliHelpRender(schema: CliRouter, helpPath: string[], useStderr:
|
|
|
401
397
|
).join("\n"),
|
|
402
398
|
);
|
|
403
399
|
|
|
404
|
-
const optBox = renderTableBox("Options", rowsForOptions(schema.options ?? [], color
|
|
400
|
+
const optBox = renderTableBox("Options", rowsForOptions(schema.options ?? [], color), hw, color);
|
|
405
401
|
if (optBox.length > 0) {
|
|
406
402
|
lines.push("");
|
|
407
403
|
lines.push(optBox.join("\n"));
|
|
@@ -450,7 +446,7 @@ export function cliHelpRender(schema: CliRouter, helpPath: string[], useStderr:
|
|
|
450
446
|
).join("\n"),
|
|
451
447
|
);
|
|
452
448
|
|
|
453
|
-
const optBox = renderTableBox("Options", rowsForOptions(node.options ?? [], color
|
|
449
|
+
const optBox = renderTableBox("Options", rowsForOptions(node.options ?? [], color), hw, color);
|
|
454
450
|
if (optBox.length > 0) {
|
|
455
451
|
lines.push("");
|
|
456
452
|
lines.push(optBox.join("\n"));
|
package/src/index.test.ts
CHANGED
|
@@ -500,8 +500,8 @@ test("leaf completion help prints correctly", async () => {
|
|
|
500
500
|
expect(stderr.toString()).toBe("");
|
|
501
501
|
});
|
|
502
502
|
|
|
503
|
-
test("
|
|
504
|
-
const { stdout, stderr, exitCode } = await $`bun run examples/nested.ts
|
|
503
|
+
test("docs schema exports JSON for nested CLIs", async () => {
|
|
504
|
+
const { stdout, stderr, exitCode } = await $`bun run examples/nested.ts docs schema`.nothrow().quiet();
|
|
505
505
|
expect(exitCode).toBe(0);
|
|
506
506
|
expect(stderr.toString()).toBe("");
|
|
507
507
|
|
|
@@ -516,15 +516,20 @@ test("--schema exports JSON for nested CLIs", async () => {
|
|
|
516
516
|
expect(lookup.positionals[0].name).toBe("path");
|
|
517
517
|
});
|
|
518
518
|
|
|
519
|
-
test("
|
|
520
|
-
const { stdout, exitCode } = await $`bun run examples/minimal.ts
|
|
519
|
+
test("docs schema exports JSON for leaf roots", async () => {
|
|
520
|
+
const { stdout, exitCode } = await $`bun run examples/minimal.ts docs schema`.nothrow().quiet();
|
|
521
521
|
expect(exitCode).toBe(0);
|
|
522
522
|
|
|
523
523
|
const schema = JSON.parse(stdout.toString());
|
|
524
524
|
expect(schema.key).toBe("minimal.ts");
|
|
525
525
|
expect(schema.positionals[0].name).toBe("name");
|
|
526
526
|
expect(schema.options[0].name).toBe("verbose");
|
|
527
|
-
expect(schema.commands.map((c: { key: string }) => c.key)).toEqual([
|
|
527
|
+
expect(schema.commands.map((c: { key: string }) => c.key)).toEqual([
|
|
528
|
+
"completion",
|
|
529
|
+
"version",
|
|
530
|
+
"install",
|
|
531
|
+
"docs",
|
|
532
|
+
]);
|
|
528
533
|
});
|
|
529
534
|
|
|
530
535
|
test("version builtin prints program version", async () => {
|
|
@@ -540,10 +545,15 @@ test("leaf root help lists completion built-in", async () => {
|
|
|
540
545
|
expect(stdout.toString()).toContain("Generate the autocompletion script for shells.");
|
|
541
546
|
});
|
|
542
547
|
|
|
543
|
-
test("
|
|
544
|
-
const root= testProgram({
|
|
548
|
+
test("root --schema is no longer a flag", () => {
|
|
549
|
+
const root = testProgram({
|
|
545
550
|
key: "app",
|
|
551
|
+
version: "1.0.0",
|
|
546
552
|
description: "demo",
|
|
553
|
+
docs: {
|
|
554
|
+
enabled: true,
|
|
555
|
+
topics: { readme: { text: "# readme\n" } },
|
|
556
|
+
},
|
|
547
557
|
commands: [
|
|
548
558
|
{
|
|
549
559
|
key: "x",
|
|
@@ -553,8 +563,8 @@ test("parse recognizes --schema at the program root", () => {
|
|
|
553
563
|
],
|
|
554
564
|
});
|
|
555
565
|
cliValidateProgram(root);
|
|
556
|
-
const pr = parse(root, ["--schema"]);
|
|
557
|
-
expect(pr.kind).toBe(ParseKind.
|
|
566
|
+
const pr = parse(cliPresentationRoot(root), ["--schema"]);
|
|
567
|
+
expect(pr.kind).not.toBe(ParseKind.Ok);
|
|
558
568
|
});
|
|
559
569
|
|
|
560
570
|
test("cliSchemaJson omits handlers and completion built-ins", () => {
|
|
@@ -587,32 +597,15 @@ test("cliSchemaJson omits handlers and completion built-ins", () => {
|
|
|
587
597
|
expect(schema).not.toHaveProperty("handler");
|
|
588
598
|
});
|
|
589
599
|
|
|
590
|
-
test("
|
|
591
|
-
const root= testProgram({
|
|
592
|
-
key: "app",
|
|
593
|
-
description: "",
|
|
594
|
-
commands: [
|
|
595
|
-
{
|
|
596
|
-
key: "x",
|
|
597
|
-
description: "cmd",
|
|
598
|
-
options: [
|
|
599
|
-
{
|
|
600
|
-
name: "schema",
|
|
601
|
-
description: "",
|
|
602
|
-
kind: CliOptionKind.String,
|
|
603
|
-
},
|
|
604
|
-
],
|
|
605
|
-
handler: () => {},
|
|
606
|
-
},
|
|
607
|
-
],
|
|
608
|
-
});
|
|
609
|
-
expect(() => cliValidateProgram(root)).toThrow(/reserved for --schema/);
|
|
610
|
-
});
|
|
611
|
-
|
|
612
|
-
test("root help lists --schema built-in", () => {
|
|
613
|
-
const root= testProgram({
|
|
600
|
+
test("docs help lists schema, api, and skill subcommands", () => {
|
|
601
|
+
const root = testProgram({
|
|
614
602
|
key: "app",
|
|
603
|
+
version: "1.0.0",
|
|
615
604
|
description: "demo",
|
|
605
|
+
docs: {
|
|
606
|
+
enabled: true,
|
|
607
|
+
topics: { readme: { text: "# readme\n" } },
|
|
608
|
+
},
|
|
616
609
|
commands: [
|
|
617
610
|
{
|
|
618
611
|
key: "x",
|
|
@@ -621,14 +614,19 @@ test("root help lists --schema built-in", () => {
|
|
|
621
614
|
},
|
|
622
615
|
],
|
|
623
616
|
});
|
|
624
|
-
const help = cliHelpRender(cliPresentationRoot(root), [], false);
|
|
625
|
-
expect(help).toContain("
|
|
617
|
+
const help = cliHelpRender(cliPresentationRoot(root), ["docs"], false);
|
|
618
|
+
expect(help).toContain("schema");
|
|
626
619
|
expect(help).toContain("Print the full command tree as JSON.");
|
|
620
|
+
expect(help).toContain("api");
|
|
621
|
+
expect(help).toContain("markdown");
|
|
622
|
+
expect(help).toContain("skill");
|
|
623
|
+
expect(help).toContain("SKILL.md");
|
|
627
624
|
});
|
|
628
625
|
|
|
629
|
-
test("
|
|
630
|
-
const root= testProgram({
|
|
626
|
+
test("root help omits legacy --schema flag", () => {
|
|
627
|
+
const root = testProgram({
|
|
631
628
|
key: "app",
|
|
629
|
+
version: "1.0.0",
|
|
632
630
|
description: "demo",
|
|
633
631
|
commands: [
|
|
634
632
|
{
|
|
@@ -638,37 +636,10 @@ test("nested help omits --schema built-in", () => {
|
|
|
638
636
|
},
|
|
639
637
|
],
|
|
640
638
|
});
|
|
641
|
-
const help = cliHelpRender(cliPresentationRoot(root), [
|
|
639
|
+
const help = cliHelpRender(cliPresentationRoot(root), [], false);
|
|
642
640
|
expect(help).not.toContain("--schema");
|
|
643
641
|
});
|
|
644
642
|
|
|
645
|
-
test("completion scripts offer --schema at the program root only", () => {
|
|
646
|
-
const root= testProgram({
|
|
647
|
-
key: "myapp",
|
|
648
|
-
description: "",
|
|
649
|
-
commands: [
|
|
650
|
-
{
|
|
651
|
-
key: "stat",
|
|
652
|
-
description: "stats",
|
|
653
|
-
commands: [
|
|
654
|
-
{
|
|
655
|
-
key: "show",
|
|
656
|
-
description: "show",
|
|
657
|
-
handler: () => {},
|
|
658
|
-
},
|
|
659
|
-
],
|
|
660
|
-
},
|
|
661
|
-
],
|
|
662
|
-
});
|
|
663
|
-
|
|
664
|
-
const bash = completionBashScript(cliPresentationRoot(root));
|
|
665
|
-
expect(bash).toContain("A_myapp_0_opts+=('--schema')");
|
|
666
|
-
expect(bash).not.toContain("A_myapp_1_opts+=('--schema')");
|
|
667
|
-
|
|
668
|
-
const zsh = completionZshScript(cliPresentationRoot(root));
|
|
669
|
-
expect(zsh).toContain("'--schema:Print the full command tree as JSON.'");
|
|
670
|
-
});
|
|
671
|
-
|
|
672
643
|
const nestedMcpFixture = testProgram({
|
|
673
644
|
key: "nested.ts",
|
|
674
645
|
description: "Nested groups demo.",
|
package/src/index.ts
CHANGED
|
@@ -20,9 +20,29 @@ export type {
|
|
|
20
20
|
CliMcpServerConfig,
|
|
21
21
|
CliMcpToolConfig,
|
|
22
22
|
CliInstallConfig,
|
|
23
|
+
CliUpdateArtifact,
|
|
24
|
+
CliUpdateGetLatest,
|
|
23
25
|
CliDocsConfig,
|
|
24
26
|
CliDocsTopic,
|
|
25
27
|
CliOption,
|
|
26
28
|
CliPositional,
|
|
27
29
|
} from "./types.ts";
|
|
28
30
|
export { isInteractiveTty } from "./utils.ts";
|
|
31
|
+
export {
|
|
32
|
+
formatDryRunMessage,
|
|
33
|
+
requireYesInNonTty,
|
|
34
|
+
shouldRunHeadless,
|
|
35
|
+
shouldRunHeadlessWithPositionals,
|
|
36
|
+
shouldRunHeadlessWithYes,
|
|
37
|
+
wantsDryRun,
|
|
38
|
+
wantsExplicitJson,
|
|
39
|
+
} from "./headless.ts";
|
|
40
|
+
export type { HeadlessContext } from "./headless.ts";
|
|
41
|
+
export {
|
|
42
|
+
createGhFetchLatest,
|
|
43
|
+
createGhVersionCheck,
|
|
44
|
+
ghReleaseUpdateGetLatest,
|
|
45
|
+
isAlreadyCurrent,
|
|
46
|
+
parseReleaseTag,
|
|
47
|
+
} from "./install/gh-release-update.ts";
|
|
48
|
+
export type { GhReleaseUpdateConfig, GhVersionCheckConfig } from "./install/gh-release-update.ts";
|
package/src/install/binary.ts
CHANGED
|
@@ -16,8 +16,13 @@ export interface BinaryInstallResult {
|
|
|
16
16
|
patchedZshRc: boolean;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
/** Copies
|
|
20
|
-
export function installBinary(
|
|
19
|
+
/** Copies a binary to the install path and patches rc files when shells are detected. */
|
|
20
|
+
export function installBinary(
|
|
21
|
+
root: CliProgram,
|
|
22
|
+
paths: InstallPaths,
|
|
23
|
+
dry: boolean,
|
|
24
|
+
sourcePath: string = process.execPath,
|
|
25
|
+
): BinaryInstallResult {
|
|
21
26
|
const changed: string[] = [];
|
|
22
27
|
const shells = detectShells();
|
|
23
28
|
let patchedBashRc = false;
|
|
@@ -25,7 +30,7 @@ export function installBinary(root: CliProgram, paths: InstallPaths, dry: boolea
|
|
|
25
30
|
|
|
26
31
|
if (!dry) {
|
|
27
32
|
mkdirSync(paths.bindir, { recursive: true });
|
|
28
|
-
copyFileSync(
|
|
33
|
+
copyFileSync(sourcePath, paths.binaryPath);
|
|
29
34
|
}
|
|
30
35
|
changed.push(paths.binaryPath);
|
|
31
36
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import { isAlreadyCurrent, parseReleaseTag } from "./gh-release-update.ts";
|
|
3
|
+
|
|
4
|
+
test("parseReleaseTag strips leading v", () => {
|
|
5
|
+
expect(parseReleaseTag("v1.4.3")).toBe("1.4.3");
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
test("parseReleaseTag leaves bare semver unchanged", () => {
|
|
9
|
+
expect(parseReleaseTag("1.4.3")).toBe("1.4.3");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test("parseReleaseTag throws on empty tag", () => {
|
|
13
|
+
expect(() => parseReleaseTag("")).toThrow("Release tag is empty");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("isAlreadyCurrent returns true when versions match", () => {
|
|
17
|
+
expect(isAlreadyCurrent("1.4.3", "1.4.3")).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("isAlreadyCurrent returns false when versions differ", () => {
|
|
21
|
+
expect(isAlreadyCurrent("1.4.2", "1.4.3")).toBe(false);
|
|
22
|
+
});
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import type { CliUpdateGetLatest } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
/** Config for {@link ghReleaseUpdateGetLatest}. */
|
|
7
|
+
export interface GhReleaseUpdateConfig {
|
|
8
|
+
/** GitHub `owner/repo` slug. */
|
|
9
|
+
repo: string;
|
|
10
|
+
/** Release asset filename (e.g. `myapp`). */
|
|
11
|
+
asset: string;
|
|
12
|
+
/** Temp directory name prefix for downloads. */
|
|
13
|
+
tempPrefix: string;
|
|
14
|
+
/** Path to the on-disk version-check cache JSON file. */
|
|
15
|
+
cachePath: string;
|
|
16
|
+
/** Optional hint when `gh auth` fails or no releases exist. */
|
|
17
|
+
repoEnvHint?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Config for {@link createGhVersionCheck}. */
|
|
21
|
+
export interface GhVersionCheckConfig {
|
|
22
|
+
/** Installed semver string. */
|
|
23
|
+
currentVersion: string;
|
|
24
|
+
/** CLI command name for update notices (e.g. `qa`). */
|
|
25
|
+
commandName: string;
|
|
26
|
+
/** Path to the on-disk version-check cache JSON file. */
|
|
27
|
+
cachePath: string;
|
|
28
|
+
/** Cache TTL in milliseconds (default 24h). */
|
|
29
|
+
ttlMs?: number;
|
|
30
|
+
/** When true, skip background refresh (e.g. test subprocess). */
|
|
31
|
+
skipRefresh?: () => boolean;
|
|
32
|
+
/** When true, skip refresh because `gh` is unavailable. */
|
|
33
|
+
ghAvailable?: () => boolean;
|
|
34
|
+
/** Fetches latest release version via `gh`. */
|
|
35
|
+
fetchLatest: () => Promise<string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type VersionCheckCache = {
|
|
39
|
+
fetchedAt: number;
|
|
40
|
+
latest: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
44
|
+
|
|
45
|
+
/** Returns whether the installed version matches the latest release. */
|
|
46
|
+
export function isAlreadyCurrent(current: string, latest: string): boolean {
|
|
47
|
+
return current === latest;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Strips a leading `v` from a release tag. */
|
|
51
|
+
export function parseReleaseTag(tag: string): string {
|
|
52
|
+
const trimmed = tag.trim();
|
|
53
|
+
if (!trimmed) {
|
|
54
|
+
throw new Error("Release tag is empty");
|
|
55
|
+
}
|
|
56
|
+
return trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Builds a `CliUpdateGetLatest` hook that downloads a release via `gh`. */
|
|
60
|
+
export function ghReleaseUpdateGetLatest(config: GhReleaseUpdateConfig): CliUpdateGetLatest {
|
|
61
|
+
const fetchLatest = createGhFetchLatest(config);
|
|
62
|
+
|
|
63
|
+
return async ({ version }) => {
|
|
64
|
+
ensureGhAvailable();
|
|
65
|
+
|
|
66
|
+
const latestVersion = await fetchLatest();
|
|
67
|
+
if (isAlreadyCurrent(version, latestVersion)) {
|
|
68
|
+
return { path: process.execPath, version: latestVersion };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const tempDir = mkdtempSync(join(tmpdir(), config.tempPrefix));
|
|
72
|
+
try {
|
|
73
|
+
const downloadedPath = await downloadReleaseAsset(config, tempDir);
|
|
74
|
+
chmodSync(downloadedPath, 0o755);
|
|
75
|
+
writeVersionCheckCache(config.cachePath, { fetchedAt: Date.now(), latest: latestVersion });
|
|
76
|
+
return {
|
|
77
|
+
path: downloadedPath,
|
|
78
|
+
version: latestVersion,
|
|
79
|
+
cleanup: () => {
|
|
80
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
} catch (err) {
|
|
84
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
85
|
+
throw err;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Version-check cache helpers for summary notices and background refresh. */
|
|
91
|
+
export function createGhVersionCheck(config: GhVersionCheckConfig): {
|
|
92
|
+
getUpdateNotice: () => string | null;
|
|
93
|
+
refreshIfStale: () => void;
|
|
94
|
+
} {
|
|
95
|
+
const ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
|
|
96
|
+
const ghAvailable = config.ghAvailable ?? (() => Bun.which("gh") !== null);
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
getUpdateNotice(): string | null {
|
|
100
|
+
const cached = readVersionCheckCache(config.cachePath);
|
|
101
|
+
if (cached === null || isAlreadyCurrent(config.currentVersion, cached.latest)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
return `Update available: v${cached.latest} (you have v${config.currentVersion}). Run \`${config.commandName} update\``;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
refreshIfStale(): void {
|
|
108
|
+
if (config.skipRefresh?.()) return;
|
|
109
|
+
if (!ghAvailable()) return;
|
|
110
|
+
|
|
111
|
+
const cached = readVersionCheckCache(config.cachePath);
|
|
112
|
+
if (cached !== null && Date.now() - cached.fetchedAt < ttlMs) {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
void config.fetchLatest()
|
|
117
|
+
.then((latest) => {
|
|
118
|
+
writeVersionCheckCache(config.cachePath, { fetchedAt: Date.now(), latest });
|
|
119
|
+
})
|
|
120
|
+
.catch(() => {
|
|
121
|
+
// Best-effort; summary must never fail because of a version check.
|
|
122
|
+
});
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Shared `gh release view` fetcher for hooks and version-check refresh. */
|
|
128
|
+
export function createGhFetchLatest(config: Pick<GhReleaseUpdateConfig, "repo" | "repoEnvHint">): () => Promise<string> {
|
|
129
|
+
return async () => {
|
|
130
|
+
const result = await runGh([
|
|
131
|
+
"release",
|
|
132
|
+
"view",
|
|
133
|
+
"--repo",
|
|
134
|
+
config.repo,
|
|
135
|
+
"--json",
|
|
136
|
+
"tagName",
|
|
137
|
+
]);
|
|
138
|
+
|
|
139
|
+
if (result.exitCode !== 0) {
|
|
140
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
141
|
+
const hint = detail.includes("auth") || detail.includes("401")
|
|
142
|
+
? " Run `gh auth login` and try again."
|
|
143
|
+
: detail.includes("release not found") || detail.includes("Not Found")
|
|
144
|
+
? config.repoEnvHint
|
|
145
|
+
? ` ${config.repoEnvHint}`
|
|
146
|
+
: ` No releases found for ${config.repo}.`
|
|
147
|
+
: "";
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Failed to fetch latest release from ${config.repo}: ${detail || "unknown error"}.${hint}`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let parsed: { tagName?: string };
|
|
154
|
+
try {
|
|
155
|
+
parsed = JSON.parse(result.stdout) as { tagName?: string };
|
|
156
|
+
} catch {
|
|
157
|
+
throw new Error("Failed to parse release metadata from gh");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!parsed.tagName) {
|
|
161
|
+
throw new Error("No release tag found for this repository");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return parseReleaseTag(parsed.tagName);
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function ensureGhAvailable(): void {
|
|
169
|
+
if (Bun.which("gh") === null) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
"GitHub CLI (gh) is required. Install from https://cli.github.com/ and run `gh auth login`.",
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function downloadReleaseAsset(config: GhReleaseUpdateConfig, tempDir: string): Promise<string> {
|
|
177
|
+
const result = await runGh([
|
|
178
|
+
"release",
|
|
179
|
+
"download",
|
|
180
|
+
"--repo",
|
|
181
|
+
config.repo,
|
|
182
|
+
"--pattern",
|
|
183
|
+
config.asset,
|
|
184
|
+
"--dir",
|
|
185
|
+
tempDir,
|
|
186
|
+
]);
|
|
187
|
+
|
|
188
|
+
if (result.exitCode !== 0) {
|
|
189
|
+
const detail = result.stderr.trim() || result.stdout.trim();
|
|
190
|
+
throw new Error(
|
|
191
|
+
`Failed to download release from ${config.repo}: ${detail || "unknown error"}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const downloadedPath = join(tempDir, config.asset);
|
|
196
|
+
if (!existsSync(downloadedPath)) {
|
|
197
|
+
throw new Error(`Release asset "${config.asset}" was not found in the download`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return downloadedPath;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function runGh(args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
|
|
204
|
+
const proc = Bun.spawn(["gh", ...args], { stdout: "pipe", stderr: "pipe" });
|
|
205
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
206
|
+
proc.exited,
|
|
207
|
+
new Response(proc.stdout).text(),
|
|
208
|
+
new Response(proc.stderr).text(),
|
|
209
|
+
]);
|
|
210
|
+
return { exitCode, stdout, stderr };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function readVersionCheckCache(cachePath: string): VersionCheckCache | null {
|
|
214
|
+
try {
|
|
215
|
+
const raw = readFileSync(cachePath, "utf8");
|
|
216
|
+
const parsed = JSON.parse(raw) as VersionCheckCache;
|
|
217
|
+
if (typeof parsed.fetchedAt !== "number" || typeof parsed.latest !== "string") {
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
return parsed;
|
|
221
|
+
} catch {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function writeVersionCheckCache(cachePath: string, cache: VersionCheckCache): void {
|
|
227
|
+
mkdirSync(dirname(cachePath), { recursive: true });
|
|
228
|
+
writeFileSync(cachePath, JSON.stringify(cache));
|
|
229
|
+
}
|