@duyquangnvx/agent-cli 0.17.0 → 0.18.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 +65 -0
- package/README.md +2 -2
- package/agents-section.md +5 -0
- package/dist/agent-cli.d.ts +69 -11
- package/dist/agent-cli.js +69 -10
- package/dist/agent-cli.js.map +1 -1
- package/dist/built-ins/agents-md.d.ts +9 -9
- package/dist/built-ins/agents-md.js +8 -8
- package/dist/built-ins/agents-md.js.map +1 -1
- package/dist/built-ins/doctor-rows.js +5 -3
- package/dist/built-ins/doctor-rows.js.map +1 -1
- package/dist/built-ins/doctor.d.ts +1 -1
- package/dist/built-ins/doctor.js +16 -15
- package/dist/built-ins/doctor.js.map +1 -1
- package/dist/built-ins/install/dependency.js +1 -1
- package/dist/built-ins/install/dependency.js.map +1 -1
- package/dist/built-ins/install/global-npm.js +6 -4
- package/dist/built-ins/install/global-npm.js.map +1 -1
- package/dist/built-ins/npm.d.ts +3 -2
- package/dist/built-ins/npm.js +16 -9
- package/dist/built-ins/npm.js.map +1 -1
- package/dist/{project-root.js → built-ins/project-root.js} +1 -1
- package/dist/built-ins/project-root.js.map +1 -0
- package/dist/built-ins/skill/hub.d.ts +3 -2
- package/dist/built-ins/skill/hub.js +43 -16
- package/dist/built-ins/skill/hub.js.map +1 -1
- package/dist/built-ins/skill/install-command.d.ts +3 -4
- package/dist/built-ins/skill/install-command.js +20 -21
- package/dist/built-ins/skill/install-command.js.map +1 -1
- package/dist/built-ins/skill/packaged.js +5 -2
- package/dist/built-ins/skill/packaged.js.map +1 -1
- package/dist/built-ins/skill/skill.d.ts +5 -2
- package/dist/built-ins/skill/skill.js +1 -1
- package/dist/built-ins/skill/skill.js.map +1 -1
- package/dist/built-ins/update.d.ts +1 -1
- package/dist/built-ins/update.js +4 -9
- package/dist/built-ins/update.js.map +1 -1
- package/dist/check-examples.d.ts +6 -0
- package/dist/check-examples.js +53 -0
- package/dist/check-examples.js.map +1 -0
- package/dist/check-next-steps.d.ts +10 -0
- package/dist/check-next-steps.js +47 -0
- package/dist/check-next-steps.js.map +1 -0
- package/dist/command-errors.d.ts +8 -0
- package/dist/command-errors.js +25 -0
- package/dist/command-errors.js.map +1 -0
- package/dist/{flags.d.ts → command-line/flags.d.ts} +4 -4
- package/dist/{flags.js → command-line/flags.js} +7 -10
- package/dist/command-line/flags.js.map +1 -0
- package/dist/{input.d.ts → command-line/input.d.ts} +1 -1
- package/dist/{input.js → command-line/input.js} +1 -1
- package/dist/command-line/input.js.map +1 -0
- package/dist/{invocation.d.ts → command-line/invocation.d.ts} +6 -3
- package/dist/{invocation.js → command-line/invocation.js} +9 -5
- package/dist/command-line/invocation.js.map +1 -0
- package/dist/{parse-input.d.ts → command-line/parse-input.d.ts} +2 -2
- package/dist/{parse-input.js → command-line/parse-input.js} +3 -3
- package/dist/command-line/parse-input.js.map +1 -0
- package/dist/command-line/route-line.d.ts +15 -0
- package/dist/command-line/route-line.js +18 -0
- package/dist/command-line/route-line.js.map +1 -0
- package/dist/{route.d.ts → command-line/route.d.ts} +2 -2
- package/dist/{route.js → command-line/route.js} +1 -1
- package/dist/command-line/route.js.map +1 -0
- package/dist/command-line/shell-words.d.ts +12 -0
- package/dist/command-line/shell-words.js +83 -0
- package/dist/command-line/shell-words.js.map +1 -0
- package/dist/command-line/suggest.js.map +1 -0
- package/dist/command-line/tokenize.js.map +1 -0
- package/dist/{usage.d.ts → command-line/usage.d.ts} +1 -1
- package/dist/{usage.js → command-line/usage.js} +2 -2
- package/dist/command-line/usage.js.map +1 -0
- package/dist/command-spec.d.ts +13 -4
- package/dist/command-spec.js +22 -11
- package/dist/command-spec.js.map +1 -1
- package/dist/{agent.d.ts → context/agent.d.ts} +1 -1
- package/dist/context/agent.js.map +1 -0
- package/dist/{confirm.d.ts → context/confirm.d.ts} +3 -3
- package/dist/{confirm.js → context/confirm.js} +9 -11
- package/dist/context/confirm.js.map +1 -0
- package/dist/{cost.d.ts → context/cost.d.ts} +3 -3
- package/dist/context/cost.js +16 -0
- package/dist/context/cost.js.map +1 -0
- package/dist/context/declared.d.ts +8 -0
- package/dist/context/declared.js +10 -0
- package/dist/context/declared.js.map +1 -0
- package/dist/{errno.d.ts → context/errno.d.ts} +4 -3
- package/dist/{errno.js → context/errno.js} +14 -7
- package/dist/context/errno.js.map +1 -0
- package/dist/{files.js → context/files.js} +1 -1
- package/dist/context/files.js.map +1 -0
- package/dist/{interrupts.d.ts → context/interrupts.d.ts} +14 -10
- package/dist/{interrupts.js → context/interrupts.js} +30 -27
- package/dist/context/interrupts.js.map +1 -0
- package/dist/{operator.d.ts → context/operator.d.ts} +4 -3
- package/dist/{operator.js → context/operator.js} +4 -3
- package/dist/context/operator.js.map +1 -0
- package/dist/{outputs.d.ts → context/outputs.d.ts} +6 -5
- package/dist/{outputs.js → context/outputs.js} +10 -9
- package/dist/context/outputs.js.map +1 -0
- package/dist/{project.d.ts → context/project.d.ts} +4 -3
- package/dist/{project.js → context/project.js} +8 -7
- package/dist/context/project.js.map +1 -0
- package/dist/{secrets.d.ts → context/secrets.d.ts} +8 -1
- package/dist/context/secrets.js +85 -0
- package/dist/context/secrets.js.map +1 -0
- package/dist/{side-task.d.ts → context/side-task.d.ts} +2 -2
- package/dist/{side-task.js → context/side-task.js} +1 -1
- package/dist/context/side-task.js.map +1 -0
- package/dist/{spawn.d.ts → context/spawn.d.ts} +4 -3
- package/dist/{spawn.js → context/spawn.js} +7 -6
- package/dist/context/spawn.js.map +1 -0
- package/dist/{spill.d.ts → context/spill.d.ts} +4 -3
- package/dist/{spill.js → context/spill.js} +4 -4
- package/dist/context/spill.js.map +1 -0
- package/dist/{stdin.d.ts → context/stdin.d.ts} +2 -2
- package/dist/{stdin.js → context/stdin.js} +2 -2
- package/dist/context/stdin.js.map +1 -0
- package/dist/{tools.d.ts → context/tools.d.ts} +4 -3
- package/dist/{tools.js → context/tools.js} +8 -10
- package/dist/context/tools.js.map +1 -0
- package/dist/context.d.ts +51 -27
- package/dist/define-command.d.ts +47 -0
- package/dist/define-command.js +25 -0
- package/dist/define-command.js.map +1 -0
- package/dist/define.d.ts +72 -63
- package/dist/define.js +0 -14
- package/dist/define.js.map +1 -1
- package/dist/error-table.d.ts +9 -6
- package/dist/error-table.js +6 -6
- package/dist/error-table.js.map +1 -1
- package/dist/errors.d.ts +15 -1
- package/dist/errors.js +24 -6
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +8 -7
- package/dist/execute.js +51 -22
- package/dist/execute.js.map +1 -1
- package/dist/exit-classes.d.ts +1 -2
- package/dist/exit-classes.js +41 -48
- package/dist/exit-classes.js.map +1 -1
- package/dist/help-json.d.ts +4 -3
- package/dist/help-json.js +33 -11
- package/dist/help-json.js.map +1 -1
- package/dist/help.js.map +1 -1
- package/dist/index.d.ts +19 -26
- package/dist/index.js +6 -18
- package/dist/index.js.map +1 -1
- package/dist/list.d.ts +6 -5
- package/dist/list.js +17 -11
- package/dist/list.js.map +1 -1
- package/dist/next-step.d.ts +23 -0
- package/dist/next-step.js +20 -0
- package/dist/next-step.js.map +1 -0
- package/dist/output.d.ts +11 -5
- package/dist/output.js +25 -11
- package/dist/output.js.map +1 -1
- package/dist/root-spec.d.ts +5 -0
- package/dist/root-spec.js +15 -0
- package/dist/root-spec.js.map +1 -0
- package/dist/run-cli.d.ts +2 -2
- package/dist/run-cli.js +12 -11
- package/dist/run-cli.js.map +1 -1
- package/dist/run-command.d.ts +4 -4
- package/dist/run-command.js +66 -37
- package/dist/run-command.js.map +1 -1
- package/dist/run-options.d.ts +11 -4
- package/dist/run-process.js +22 -7
- package/dist/run-process.js.map +1 -1
- package/dist/runtime-codes.d.ts +33 -0
- package/dist/runtime-codes.js +50 -0
- package/dist/runtime-codes.js.map +1 -0
- package/dist/style.js +1 -1
- package/dist/style.js.map +1 -1
- package/dist/testing.d.ts +27 -0
- package/dist/testing.js +66 -0
- package/dist/testing.js.map +1 -0
- package/dist/version.d.ts +4 -1
- package/dist/version.js +6 -1
- package/dist/version.js.map +1 -1
- package/docs/README.md +51 -0
- package/docs/example/archive.test.ts +280 -0
- package/docs/example/archive.ts +160 -0
- package/docs/example/bin.test.ts +118 -0
- package/docs/example/bin.ts +5 -0
- package/docs/example/bundle.test.ts +57 -0
- package/docs/example/bundle.ts +57 -0
- package/docs/example/cli.test.ts +428 -0
- package/docs/example/cli.ts +191 -0
- package/docs/example/commit.test.ts +78 -0
- package/docs/example/commit.ts +26 -0
- package/docs/example/define.ts +16 -0
- package/docs/example/errors.ts +17 -0
- package/docs/example/export.test.ts +64 -0
- package/docs/example/export.ts +32 -0
- package/docs/example/fake-ports.ts +14 -0
- package/docs/example/folder.ts +48 -0
- package/docs/example/import.test.ts +53 -0
- package/docs/example/import.ts +50 -0
- package/docs/example/package.json +21 -0
- package/docs/example/ports.ts +105 -0
- package/docs/example/serve.test.ts +49 -0
- package/docs/example/serve.ts +40 -0
- package/docs/example/store.ts +62 -0
- package/docs/example/tsdown.config.ts +33 -0
- package/docs/getting-started.md +19 -0
- package/docs/guides/commands.md +35 -0
- package/docs/guides/doctor.md +52 -0
- package/docs/guides/effects-and-plans.md +44 -0
- package/docs/guides/errors.md +57 -0
- package/docs/guides/lists.md +18 -0
- package/docs/guides/local-state.md +28 -0
- package/docs/guides/long-runs.md +109 -0
- package/docs/guides/remote-services.md +124 -0
- package/docs/guides/shipping.md +61 -0
- package/docs/guides/testing.md +69 -0
- package/docs/principles.md +39 -0
- package/docs/reference.md +25 -0
- package/package.json +12 -1
- package/dist/agent.js.map +0 -1
- package/dist/confirm.js.map +0 -1
- package/dist/cost.js +0 -16
- package/dist/cost.js.map +0 -1
- package/dist/errno.js.map +0 -1
- package/dist/files.js.map +0 -1
- package/dist/flags.js.map +0 -1
- package/dist/input.js.map +0 -1
- package/dist/interrupts.js.map +0 -1
- package/dist/invocation.js.map +0 -1
- package/dist/operator.js.map +0 -1
- package/dist/outputs.js.map +0 -1
- package/dist/parse-input.js.map +0 -1
- package/dist/project-root.js.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/route.js.map +0 -1
- package/dist/secrets.js +0 -31
- package/dist/secrets.js.map +0 -1
- package/dist/side-task.js.map +0 -1
- package/dist/spawn.js.map +0 -1
- package/dist/spill.js.map +0 -1
- package/dist/stdin.js.map +0 -1
- package/dist/suggest.js.map +0 -1
- package/dist/tokenize.js.map +0 -1
- package/dist/tools.js.map +0 -1
- package/dist/usage.js.map +0 -1
- /package/dist/{project-root.d.ts → built-ins/project-root.d.ts} +0 -0
- /package/dist/{suggest.d.ts → command-line/suggest.d.ts} +0 -0
- /package/dist/{suggest.js → command-line/suggest.js} +0 -0
- /package/dist/{tokenize.d.ts → command-line/tokenize.d.ts} +0 -0
- /package/dist/{tokenize.js → command-line/tokenize.js} +0 -0
- /package/dist/{agent.js → context/agent.js} +0 -0
- /package/dist/{files.d.ts → context/files.d.ts} +0 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { CliError, runtimeCodes } from "@duyquangnvx/agent-cli";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { defineCommand } from "./define.js";
|
|
5
|
+
import { type Link, linkWithId } from "./store.js";
|
|
6
|
+
|
|
7
|
+
function shortcutOf(link: Link): string {
|
|
8
|
+
return `[InternetShortcut]\r\nURL=${link.url}\r\n`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const bundle = defineCommand({
|
|
12
|
+
description: "Write links as shortcut files into a new folder",
|
|
13
|
+
examples: [
|
|
14
|
+
{ command: "links bundle docs 1 2", note: "write links 1 and 2 to docs/1.url and docs/2.url" },
|
|
15
|
+
{ command: "links bundle docs 1 --force", note: "replace an existing docs folder" },
|
|
16
|
+
],
|
|
17
|
+
effect: "write",
|
|
18
|
+
errors: ["LINK_NOT_FOUND", "CORRUPT_DATA", "BUNDLE_EXISTS"],
|
|
19
|
+
force: "an existing folder, removing everything in it",
|
|
20
|
+
args: z.object({
|
|
21
|
+
folder: z.string().describe("the folder to create, relative to the working directory"),
|
|
22
|
+
ids: z.array(z.number().int()).min(1).describe("the links' ids, as `links list` shows them"),
|
|
23
|
+
}),
|
|
24
|
+
output: z.object({ folder: z.string(), files: z.array(z.string()) }),
|
|
25
|
+
plan: async ({ folder, ids }, ctx) => ({
|
|
26
|
+
folder,
|
|
27
|
+
links: await Promise.all(ids.map((id) => linkWithId(ctx, id))),
|
|
28
|
+
remove: ctx.force && (await ctx.files.exists(folder)) ? await ctx.files.list(folder) : [],
|
|
29
|
+
}),
|
|
30
|
+
apply: async (plan, ctx) => {
|
|
31
|
+
if (ctx.force) {
|
|
32
|
+
await ctx.files.remove(plan.folder);
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await ctx.files.mkdir(plan.folder, { exclusive: true });
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof CliError && error.code === runtimeCodes.ALREADY_EXISTS) {
|
|
38
|
+
const ids = plan.links.map((link) => String(link.id));
|
|
39
|
+
throw ctx.fail("BUNDLE_EXISTS", {
|
|
40
|
+
message: `${plan.folder} already exists`,
|
|
41
|
+
hint: "to replace it and everything in it, rerun with --force",
|
|
42
|
+
next: [["bundle", plan.folder, ...ids, "--force"]],
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
const files = plan.links.map((link) => `${link.id}.url`);
|
|
48
|
+
for (const link of plan.links) {
|
|
49
|
+
await ctx.files.write(`${plan.folder}/${link.id}.url`, shortcutOf(link));
|
|
50
|
+
}
|
|
51
|
+
return { folder: resolve(ctx.cwd, plan.folder), files };
|
|
52
|
+
},
|
|
53
|
+
render: ({ folder, files }) => `Bundled ${files.length} links into ${folder}`,
|
|
54
|
+
renderPlan: (plan) =>
|
|
55
|
+
`Would bundle ${plan.links.length} links into ${plan.folder}` +
|
|
56
|
+
(plan.remove.length === 0 ? "" : `, removing ${plan.remove.join(", ")}`),
|
|
57
|
+
});
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { runCli, runtimeCodes } from "@duyquangnvx/agent-cli";
|
|
7
|
+
import { checkExamples } from "@duyquangnvx/agent-cli/testing";
|
|
8
|
+
import { afterEach, describe, expect, test } from "vitest";
|
|
9
|
+
import { cli } from "./cli.js";
|
|
10
|
+
import { fakePorts } from "./fake-ports.js";
|
|
11
|
+
import { denied, fakeFiles, folderWith, LINKS, pathWith } from "./folder.js";
|
|
12
|
+
|
|
13
|
+
test("every example parses, names a Command and passes its schemas", async () => {
|
|
14
|
+
await checkExamples(cli);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("links list", () => {
|
|
18
|
+
test("prints one link per line, then how many were shown", async () => {
|
|
19
|
+
const cwd = await folderWith(LINKS);
|
|
20
|
+
|
|
21
|
+
const result = await runCli(cli, ["list"], { cwd });
|
|
22
|
+
|
|
23
|
+
expect(result).toEqual({
|
|
24
|
+
stdout: "1 https://zod.dev/\n2 https://vitest.dev/\n2 shown; no more pages\n",
|
|
25
|
+
stderr: "",
|
|
26
|
+
exitCode: 0,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("--fields keeps only the named fields under --json", async () => {
|
|
31
|
+
const cwd = await folderWith(LINKS);
|
|
32
|
+
|
|
33
|
+
const result = await runCli(cli, ["list", "--fields", "id,url", "--json"], { cwd });
|
|
34
|
+
|
|
35
|
+
expect(JSON.parse(result.stdout)).toEqual({
|
|
36
|
+
items: [
|
|
37
|
+
{ id: 1, url: "https://zod.dev/" },
|
|
38
|
+
{ id: 2, url: "https://vitest.dev/" },
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("--limit returns a page and the command for the next one", async () => {
|
|
44
|
+
const cwd = await folderWith(LINKS);
|
|
45
|
+
|
|
46
|
+
const result = await runCli(cli, ["list", "--limit", "1", "--json"], { cwd });
|
|
47
|
+
|
|
48
|
+
expect(JSON.parse(result.stdout)).toEqual({
|
|
49
|
+
items: [LINKS[0]],
|
|
50
|
+
next: "links list --limit 1 --json --cursor 1",
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a corrupt links.json fails with exit 1 and a hint", async () => {
|
|
55
|
+
const cwd = await folderWith("not links");
|
|
56
|
+
|
|
57
|
+
const result = await runCli(cli, ["list", "--json"], { cwd });
|
|
58
|
+
|
|
59
|
+
expect(result.exitCode).toBe(1);
|
|
60
|
+
expect(JSON.parse(result.stderr)).toMatchObject({ errors: [{ code: "CORRUPT_DATA" }] });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a links.json it may not read fails with PERMISSION_DENIED, exit 2", async () => {
|
|
64
|
+
const cwd = await folderWith(LINKS);
|
|
65
|
+
|
|
66
|
+
const result = await runCli(cli, ["list", "--json"], {
|
|
67
|
+
cwd,
|
|
68
|
+
files: fakeFiles({ exists: async () => true, read: denied }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result.exitCode).toBe(2);
|
|
72
|
+
expect(JSON.parse(result.stderr)).toMatchObject({
|
|
73
|
+
errors: [{ code: runtimeCodes.PERMISSION_DENIED }],
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("links add", () => {
|
|
79
|
+
test("--dry-run prints the Plan and saves nothing", async () => {
|
|
80
|
+
const cwd = await folderWith([]);
|
|
81
|
+
|
|
82
|
+
const result = await runCli(cli, ["add", "https://zod.dev", "--dry-run"], { cwd });
|
|
83
|
+
|
|
84
|
+
expect(result).toEqual({
|
|
85
|
+
stdout: "Would save link 1: https://zod.dev\n",
|
|
86
|
+
stderr: "",
|
|
87
|
+
exitCode: 0,
|
|
88
|
+
});
|
|
89
|
+
expect(await readFile(join(cwd, "links.json"), "utf8")).toBe("[]");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("saves the link", async () => {
|
|
93
|
+
const cwd = await folderWith([]);
|
|
94
|
+
|
|
95
|
+
await runCli(cli, ["add", "https://zod.dev", "--tag", "docs"], { cwd });
|
|
96
|
+
const listed = await runCli(cli, ["list", "--json"], { cwd });
|
|
97
|
+
|
|
98
|
+
expect(JSON.parse(listed.stdout)).toEqual({
|
|
99
|
+
items: [{ id: 1, url: "https://zod.dev", tags: ["docs"] }],
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("tags the link with the Project file's tag when --tag is absent", async () => {
|
|
104
|
+
const root = await folderWith([]);
|
|
105
|
+
await writeFile(join(root, "links.config.json"), JSON.stringify({ tag: "work" }));
|
|
106
|
+
const cwd = join(root, "notes");
|
|
107
|
+
await mkdir(cwd);
|
|
108
|
+
|
|
109
|
+
const result = await runCli(cli, ["add", "https://zod.dev", "--json"], { cwd });
|
|
110
|
+
|
|
111
|
+
expect(JSON.parse(result.stdout)).toEqual({ id: 1, url: "https://zod.dev", tags: ["work"] });
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("a Project file that fails its schema is PROJECT_INVALID, exit 2", async () => {
|
|
115
|
+
const cwd = await folderWith([]);
|
|
116
|
+
await writeFile(join(cwd, "links.config.json"), JSON.stringify({ tag: 3 }));
|
|
117
|
+
|
|
118
|
+
const result = await runCli(cli, ["add", "https://zod.dev", "--json"], { cwd });
|
|
119
|
+
|
|
120
|
+
expect(result.exitCode).toBe(2);
|
|
121
|
+
expect(JSON.parse(result.stderr)).toMatchObject({
|
|
122
|
+
errors: [{ code: runtimeCodes.PROJECT_INVALID }],
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("an address that is not a URL is a usage error", async () => {
|
|
127
|
+
const cwd = await folderWith([]);
|
|
128
|
+
|
|
129
|
+
const result = await runCli(cli, ["add", "zod"], { cwd });
|
|
130
|
+
|
|
131
|
+
expect(result.exitCode).toBe(1);
|
|
132
|
+
expect(result.stdout).toBe("");
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("links remove", () => {
|
|
137
|
+
test("without --confirm deletes nothing and hints the exact command", async () => {
|
|
138
|
+
const cwd = await folderWith(LINKS);
|
|
139
|
+
|
|
140
|
+
const result = await runCli(cli, ["remove", "1"], { cwd });
|
|
141
|
+
|
|
142
|
+
expect(result).toEqual({
|
|
143
|
+
stdout: "",
|
|
144
|
+
stderr:
|
|
145
|
+
"error: `links remove` is destructive and needs --confirm\n" +
|
|
146
|
+
"hint: to confirm, run the Next step\n" +
|
|
147
|
+
"run: links remove 1 --confirm https://zod.dev/\n",
|
|
148
|
+
exitCode: 1,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("with --confirm naming the link's url deletes it", async () => {
|
|
153
|
+
const cwd = await folderWith(LINKS);
|
|
154
|
+
|
|
155
|
+
const result = await runCli(cli, ["remove", "1", "--confirm", "https://zod.dev/"], {
|
|
156
|
+
cwd,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
expect(result.stdout).toBe("Deleted link 1: https://zod.dev/\n");
|
|
160
|
+
expect(JSON.parse(await readFile(join(cwd, "links.json"), "utf8"))).toEqual([LINKS[1]]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("links check", () => {
|
|
165
|
+
const TOKEN_ENV = { LINKS_TOKEN: "t0ken" };
|
|
166
|
+
let server: Server | undefined;
|
|
167
|
+
let authorization: string | undefined;
|
|
168
|
+
|
|
169
|
+
afterEach(() => {
|
|
170
|
+
authorization = undefined;
|
|
171
|
+
server?.closeAllConnections();
|
|
172
|
+
server?.close();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
async function serve(answer: boolean): Promise<string> {
|
|
176
|
+
server = createServer((request, response) => {
|
|
177
|
+
authorization = request.headers.authorization;
|
|
178
|
+
if (answer) {
|
|
179
|
+
response.writeHead(204).end();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
await new Promise<void>((resolve) => server?.listen(0, "127.0.0.1", resolve));
|
|
183
|
+
return `http://127.0.0.1:${(server.address() as AddressInfo).port}/`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
test("prints the status the link answers with, sending LINKS_TOKEN as a bearer token", async () => {
|
|
187
|
+
const url = await serve(true);
|
|
188
|
+
const cwd = await folderWith([{ id: 1, url, tags: [] }]);
|
|
189
|
+
|
|
190
|
+
const result = await runCli(cli, ["check", "1", "--json"], { env: TOKEN_ENV, cwd });
|
|
191
|
+
|
|
192
|
+
expect(JSON.parse(result.stdout)).toEqual({ id: 1, url, status: 204 });
|
|
193
|
+
expect(authorization).toBe("Bearer t0ken");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("without LINKS_TOKEN fails with SECRET_MISSING, exit 2, before any request", async () => {
|
|
197
|
+
const url = await serve(true);
|
|
198
|
+
const cwd = await folderWith([{ id: 1, url, tags: [] }]);
|
|
199
|
+
const home = await mkdtemp(join(tmpdir(), "links-home-"));
|
|
200
|
+
|
|
201
|
+
const result = await runCli(cli, ["check", "1", "--json"], {
|
|
202
|
+
env: { HOME: home, USERPROFILE: home },
|
|
203
|
+
cwd,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(result.exitCode).toBe(2);
|
|
207
|
+
expect(JSON.parse(result.stderr).errors[0].code).toBe(runtimeCodes.SECRET_MISSING);
|
|
208
|
+
expect(authorization).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("a link that never answers fails with DEADLINE_EXCEEDED, exit 75", async () => {
|
|
212
|
+
const url = await serve(false);
|
|
213
|
+
const cwd = await folderWith([{ id: 1, url, tags: [] }]);
|
|
214
|
+
|
|
215
|
+
const result = await runCli(cli, ["check", "1", "--timeout", "100", "--json"], {
|
|
216
|
+
env: TOKEN_ENV,
|
|
217
|
+
cwd,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(result.exitCode).toBe(75);
|
|
221
|
+
expect(JSON.parse(result.stderr)).toMatchObject({
|
|
222
|
+
errors: [{ code: runtimeCodes.DEADLINE_EXCEEDED }],
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("an interrupted check fails with INTERRUPTED, exit 130", async () => {
|
|
227
|
+
const url = await serve(false);
|
|
228
|
+
const cwd = await folderWith([{ id: 1, url, tags: [] }]);
|
|
229
|
+
|
|
230
|
+
const result = await runCli(cli, ["check", "1", "--json"], {
|
|
231
|
+
env: TOKEN_ENV,
|
|
232
|
+
cwd,
|
|
233
|
+
signal: AbortSignal.timeout(100),
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(result.exitCode).toBe(130);
|
|
237
|
+
expect(JSON.parse(result.stderr)).toMatchObject({
|
|
238
|
+
errors: [{ code: runtimeCodes.INTERRUPTED }],
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe("links doctor", () => {
|
|
244
|
+
async function doctor(env: Record<string, string>, config?: unknown) {
|
|
245
|
+
const cwd = await folderWith(LINKS);
|
|
246
|
+
if (config !== undefined) {
|
|
247
|
+
await writeFile(join(cwd, "links.config.json"), JSON.stringify(config));
|
|
248
|
+
}
|
|
249
|
+
const home = await mkdtemp(join(tmpdir(), "links-home-"));
|
|
250
|
+
const result = await runCli(cli, ["doctor", "--json"], {
|
|
251
|
+
env: { HOME: home, USERPROFILE: home, LINKS_TOKEN: "t0ken", ...env },
|
|
252
|
+
cwd,
|
|
253
|
+
});
|
|
254
|
+
const report = JSON.parse(result.stdout);
|
|
255
|
+
const row = (name: string): unknown =>
|
|
256
|
+
report.rows.find(({ check }: { check: string }) => check === name);
|
|
257
|
+
return { cwd, home, result, report, row };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
test("reports the store row", async () => {
|
|
261
|
+
const { cwd, result, row } = await doctor({ PATH: await pathWith("git") });
|
|
262
|
+
|
|
263
|
+
expect(result.exitCode).toBe(0);
|
|
264
|
+
expect(row("store")).toEqual({
|
|
265
|
+
check: "store",
|
|
266
|
+
state: "ok",
|
|
267
|
+
message: `${join(cwd, "links.json")} holds 2 links`,
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("the tag row reads the Project file's tag", async () => {
|
|
272
|
+
const { row } = await doctor({ PATH: await pathWith("git") }, { tag: "work" });
|
|
273
|
+
|
|
274
|
+
expect(row("tag")).toEqual({
|
|
275
|
+
check: "tag",
|
|
276
|
+
state: "ok",
|
|
277
|
+
message: "`links add` tags a link work unless given --tag",
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("the tag row says links stay untagged without a Project file", async () => {
|
|
282
|
+
const { row } = await doctor({ PATH: await pathWith("git") });
|
|
283
|
+
|
|
284
|
+
expect(row("tag")).toEqual({
|
|
285
|
+
check: "tag",
|
|
286
|
+
state: "ok",
|
|
287
|
+
message: "`links add` leaves a link untagged unless given --tag",
|
|
288
|
+
});
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("an invalid Project file skips the tag row and fails doctor once, as PROJECT_INVALID", async () => {
|
|
292
|
+
const { result, row } = await doctor({ PATH: await pathWith("git") }, { tag: 3 });
|
|
293
|
+
|
|
294
|
+
expect(row("tag")).toMatchObject({ check: "tag", state: "skipped" });
|
|
295
|
+
expect(JSON.parse(result.stderr).errors.map(({ code }: { code: string }) => code)).toEqual([
|
|
296
|
+
runtimeCodes.PROJECT_INVALID,
|
|
297
|
+
]);
|
|
298
|
+
expect(result.exitCode).toBe(2);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("a missing LINKS_TOKEN fails doctor with exit 2, naming where to get it and put it", async () => {
|
|
302
|
+
const { home, result, row } = await doctor({ PATH: await pathWith("git"), LINKS_TOKEN: "" });
|
|
303
|
+
const dotenv = join(home, ".config", "links", ".env");
|
|
304
|
+
|
|
305
|
+
expect(row("secrets")).toEqual({
|
|
306
|
+
check: "secrets",
|
|
307
|
+
state: "problem",
|
|
308
|
+
message: `LINKS_TOKEN is not set (get it from https://links.example.com/settings/tokens); add it to ${dotenv} or the environment`,
|
|
309
|
+
});
|
|
310
|
+
expect(result.exitCode).toBe(2);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("a missing git fails doctor with exit 2, its install command as the fix", async () => {
|
|
314
|
+
const { result, row } = await doctor({ PATH: await pathWith() });
|
|
315
|
+
|
|
316
|
+
expect(row("git")).toMatchObject({ check: "git", state: "problem", fix: "brew install git" });
|
|
317
|
+
expect(result.exitCode).toBe(2);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("reports where the token came from and never its value", async () => {
|
|
321
|
+
const cwd = await folderWith(LINKS);
|
|
322
|
+
const home = await mkdtemp(join(tmpdir(), "links-home-"));
|
|
323
|
+
await mkdir(join(home, ".config", "links"), { recursive: true });
|
|
324
|
+
const dotenv = join(home, ".config", "links", ".env");
|
|
325
|
+
await writeFile(dotenv, "LINKS_TOKEN=secret-from-file\n");
|
|
326
|
+
const env = { HOME: home, USERPROFILE: home };
|
|
327
|
+
const secretsRow = (stdout: string): unknown =>
|
|
328
|
+
JSON.parse(stdout).rows.find(({ check }: { check: string }) => check === "secrets");
|
|
329
|
+
|
|
330
|
+
const fromFile = await runCli(cli, ["doctor", "--json"], { env, cwd });
|
|
331
|
+
const fromEnv = await runCli(cli, ["doctor", "--json"], {
|
|
332
|
+
env: { ...env, LINKS_TOKEN: "secret-from-env" },
|
|
333
|
+
cwd,
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
expect(secretsRow(fromFile.stdout)).toEqual({
|
|
337
|
+
check: "secrets",
|
|
338
|
+
state: "ok",
|
|
339
|
+
message: `LINKS_TOKEN from ${dotenv}`,
|
|
340
|
+
});
|
|
341
|
+
expect(secretsRow(fromEnv.stdout)).toEqual({
|
|
342
|
+
check: "secrets",
|
|
343
|
+
state: "ok",
|
|
344
|
+
message: "LINKS_TOKEN from the environment",
|
|
345
|
+
});
|
|
346
|
+
expect(fromFile.stdout + fromEnv.stdout).not.toContain("secret-from");
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe("the usage count", () => {
|
|
351
|
+
const COLLECTOR = "https://usage.example.com/links";
|
|
352
|
+
|
|
353
|
+
function countingPorts() {
|
|
354
|
+
const counted: (readonly [string, readonly string[]])[] = [];
|
|
355
|
+
const ports = fakePorts({
|
|
356
|
+
usage: {
|
|
357
|
+
count: async (url, command) => {
|
|
358
|
+
counted.push([url, command]);
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
return { ports, counted };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
test("counts a run of a Command at the collector LINKS_USAGE_URL names", async () => {
|
|
366
|
+
const cwd = await folderWith([]);
|
|
367
|
+
const { ports, counted } = countingPorts();
|
|
368
|
+
|
|
369
|
+
const result = await runCli(cli, ["add", "https://zod.dev"], {
|
|
370
|
+
env: { LINKS_USAGE_URL: COLLECTOR },
|
|
371
|
+
cwd,
|
|
372
|
+
ports,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(result.exitCode).toBe(0);
|
|
376
|
+
expect(counted).toEqual([[COLLECTOR, ["add"]]]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test.each([
|
|
380
|
+
["without LINKS_USAGE_URL", {}, ["add", "https://zod.dev"]],
|
|
381
|
+
["for a dry run", { LINKS_USAGE_URL: COLLECTOR }, ["add", "https://zod.dev", "--dry-run"]],
|
|
382
|
+
])("counts nothing %s", async (_name, env, argv) => {
|
|
383
|
+
const cwd = await folderWith([]);
|
|
384
|
+
const { ports, counted } = countingPorts();
|
|
385
|
+
|
|
386
|
+
await runCli(cli, argv, { env, cwd, ports });
|
|
387
|
+
|
|
388
|
+
expect(counted).toEqual([]);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("a collector that fails changes nothing the caller sees", async () => {
|
|
392
|
+
const cwd = await folderWith([]);
|
|
393
|
+
const ports = fakePorts({
|
|
394
|
+
usage: {
|
|
395
|
+
count: async () => {
|
|
396
|
+
throw new Error("the collector is down");
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const result = await runCli(cli, ["add", "https://zod.dev"], {
|
|
402
|
+
env: { LINKS_USAGE_URL: COLLECTOR },
|
|
403
|
+
cwd,
|
|
404
|
+
ports,
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
expect(result).toEqual({ stdout: "Saved link 1: https://zod.dev\n", stderr: "", exitCode: 0 });
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("links --help --json", () => {
|
|
412
|
+
test("describes a Command's Effect and the JSON Schema of its output", async () => {
|
|
413
|
+
const result = await runCli(cli, ["check", "--help", "--json"], { cwd: "." });
|
|
414
|
+
|
|
415
|
+
const help = JSON.parse(result.stdout);
|
|
416
|
+
expect(help).toMatchObject({ name: "links check", effect: "read" });
|
|
417
|
+
expect(help.output.required).toEqual(["id", "url", "status"]);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("lists the Error table with each code's Exit class", async () => {
|
|
421
|
+
const result = await runCli(cli, ["--help", "--json"], { cwd: "." });
|
|
422
|
+
|
|
423
|
+
expect(JSON.parse(result.stdout).errors).toMatchObject({
|
|
424
|
+
LINK_NOT_FOUND: { class: "caller" },
|
|
425
|
+
ARCHIVE_FAILED: { class: "source" },
|
|
426
|
+
});
|
|
427
|
+
});
|
|
428
|
+
});
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { type DoctorCheck, paginate } from "@duyquangnvx/agent-cli";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { archive } from "./archive.js";
|
|
4
|
+
import { bundle } from "./bundle.js";
|
|
5
|
+
import { commit } from "./commit.js";
|
|
6
|
+
import { defineCli, defineCommand, type Settings } from "./define.js";
|
|
7
|
+
import { exportLinks } from "./export.js";
|
|
8
|
+
import { importLinks } from "./import.js";
|
|
9
|
+
import manifest from "./package.json" with { type: "json" };
|
|
10
|
+
import type { Ports } from "./ports.js";
|
|
11
|
+
import { serve } from "./serve.js";
|
|
12
|
+
import { linkSchema, linksPath, linkWithId, readLinks, writeLinks } from "./store.js";
|
|
13
|
+
|
|
14
|
+
const list = defineCommand({
|
|
15
|
+
description: "List saved links",
|
|
16
|
+
examples: [
|
|
17
|
+
{ command: "links list", note: "show every link with its id" },
|
|
18
|
+
{ command: "links list --tag docs", note: "show only the links tagged docs" },
|
|
19
|
+
{ command: "links list --fields id,url --json", note: "get only each link's id and url" },
|
|
20
|
+
],
|
|
21
|
+
effect: "read",
|
|
22
|
+
errors: ["CORRUPT_DATA"],
|
|
23
|
+
options: z.object({
|
|
24
|
+
tag: z.string().optional().describe("only show links with this tag"),
|
|
25
|
+
}),
|
|
26
|
+
rows: linkSchema,
|
|
27
|
+
run: async ({ tag, limit, cursor }, ctx) => {
|
|
28
|
+
const links = await readLinks(ctx);
|
|
29
|
+
const matching = tag === undefined ? links : links.filter((link) => link.tags.includes(tag));
|
|
30
|
+
return paginate(matching, { limit, cursor });
|
|
31
|
+
},
|
|
32
|
+
render: (page, style) =>
|
|
33
|
+
page.items.length === 0
|
|
34
|
+
? "No links."
|
|
35
|
+
: page.items.map((link) => `${style.dim(String(link.id))} ${link.url}`).join("\n"),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const add = defineCommand({
|
|
39
|
+
description: "Save a link",
|
|
40
|
+
examples: [
|
|
41
|
+
{ command: "links add https://zod.dev --tag docs", note: "save a link tagged docs" },
|
|
42
|
+
{ command: "links add https://zod.dev --dry-run", note: "show the link it would save" },
|
|
43
|
+
],
|
|
44
|
+
effect: "write",
|
|
45
|
+
errors: ["CORRUPT_DATA"],
|
|
46
|
+
args: z.object({
|
|
47
|
+
url: z.url().describe("the address to save"),
|
|
48
|
+
}),
|
|
49
|
+
options: z.object({
|
|
50
|
+
tag: z
|
|
51
|
+
.string()
|
|
52
|
+
.optional()
|
|
53
|
+
.describe("tag the link with this, in place of the Project file's tag"),
|
|
54
|
+
}),
|
|
55
|
+
output: linkSchema,
|
|
56
|
+
plan: async ({ url, tag }, ctx) => {
|
|
57
|
+
const links = await readLinks(ctx);
|
|
58
|
+
const id = Math.max(0, ...links.map((link) => link.id)) + 1;
|
|
59
|
+
const tags = [tag ?? ctx.project?.settings.tag].filter((name) => name !== undefined);
|
|
60
|
+
return { add: { id, url, tags } };
|
|
61
|
+
},
|
|
62
|
+
apply: async (plan, ctx) => {
|
|
63
|
+
const links = await readLinks(ctx);
|
|
64
|
+
await ctx.runToEnd("saving the links", () => writeLinks(ctx, [...links, plan.add]));
|
|
65
|
+
return plan.add;
|
|
66
|
+
},
|
|
67
|
+
render: (link) => `Saved link ${link.id}: ${link.url}`,
|
|
68
|
+
renderPlan: (plan) => `Would save link ${plan.add.id}: ${plan.add.url}`,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const remove = defineCommand({
|
|
72
|
+
description: "Delete a saved link",
|
|
73
|
+
examples: [
|
|
74
|
+
{ command: "links remove 3 --dry-run", note: "show the link it would delete" },
|
|
75
|
+
{
|
|
76
|
+
command: "links remove 3 --confirm https://zod.dev",
|
|
77
|
+
note: "delete link 3, confirmed by its url",
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
effect: "destructive",
|
|
81
|
+
errors: ["LINK_NOT_FOUND", "CORRUPT_DATA"],
|
|
82
|
+
args: z.object({
|
|
83
|
+
id: z.number().int().describe("the link's id, as `links list` shows it"),
|
|
84
|
+
}),
|
|
85
|
+
output: linkSchema,
|
|
86
|
+
plan: async ({ id }, ctx) => ({ remove: await linkWithId(ctx, id) }),
|
|
87
|
+
confirm: (plan) => plan.remove.url,
|
|
88
|
+
apply: async (plan, ctx) => {
|
|
89
|
+
const links = await readLinks(ctx);
|
|
90
|
+
await ctx.runToEnd("saving the links", () =>
|
|
91
|
+
writeLinks(
|
|
92
|
+
ctx,
|
|
93
|
+
links.filter((link) => link.id !== plan.remove.id),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
return plan.remove;
|
|
97
|
+
},
|
|
98
|
+
render: (link) => `Deleted link ${link.id}: ${link.url}`,
|
|
99
|
+
renderPlan: (plan) => `Would delete link ${plan.remove.id}: ${plan.remove.url}`,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const DEFAULT_TIMEOUT_MS = 5000;
|
|
103
|
+
|
|
104
|
+
const check = defineCommand({
|
|
105
|
+
description: "Check that a saved link still answers",
|
|
106
|
+
examples: [
|
|
107
|
+
{ command: "links check 3", note: "print the HTTP status link 3 answers with" },
|
|
108
|
+
{ command: "links check 3 --timeout 1000", note: "give up after 1 second" },
|
|
109
|
+
],
|
|
110
|
+
effect: "read",
|
|
111
|
+
errors: ["LINK_NOT_FOUND", "CORRUPT_DATA", "UNREACHABLE"],
|
|
112
|
+
secrets: [{ name: "LINKS_TOKEN", get: "https://links.example.com/settings/tokens" }],
|
|
113
|
+
args: z.object({
|
|
114
|
+
id: z.number().int().describe("the link's id, as `links list` shows it"),
|
|
115
|
+
}),
|
|
116
|
+
options: z.object({
|
|
117
|
+
timeout: z
|
|
118
|
+
.number()
|
|
119
|
+
.int()
|
|
120
|
+
.positive()
|
|
121
|
+
.default(DEFAULT_TIMEOUT_MS)
|
|
122
|
+
.describe("milliseconds to wait for an answer"),
|
|
123
|
+
}),
|
|
124
|
+
output: z.object({ id: z.number(), url: z.string(), status: z.number() }),
|
|
125
|
+
run: async ({ id, timeout }, ctx) => {
|
|
126
|
+
const link = await linkWithId(ctx, id);
|
|
127
|
+
const signal = ctx.deadline(timeout);
|
|
128
|
+
try {
|
|
129
|
+
const token = ctx.secret("LINKS_TOKEN").value;
|
|
130
|
+
return { id, url: link.url, status: await ctx.ports.web.status(link.url, token, signal) };
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// An interrupt or a missed deadline already carries the right error.
|
|
133
|
+
if (signal.aborted) {
|
|
134
|
+
throw signal.reason;
|
|
135
|
+
}
|
|
136
|
+
throw ctx.fail(
|
|
137
|
+
"UNREACHABLE",
|
|
138
|
+
{
|
|
139
|
+
message: `${link.url} did not answer`,
|
|
140
|
+
hint: "check the address, then try again",
|
|
141
|
+
next: [["check", String(id)]],
|
|
142
|
+
},
|
|
143
|
+
{ cause: error },
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const store: DoctorCheck<Ports, Settings> = {
|
|
150
|
+
name: "store",
|
|
151
|
+
run: async (ctx) => {
|
|
152
|
+
const links = await readLinks(ctx);
|
|
153
|
+
return { state: "ok", message: `${linksPath(ctx.cwd)} holds ${links.length} links` };
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const tag: DoctorCheck<Ports, Settings> = {
|
|
158
|
+
name: "tag",
|
|
159
|
+
run: (ctx) => {
|
|
160
|
+
const tag = ctx.project?.settings.tag;
|
|
161
|
+
return {
|
|
162
|
+
state: "ok",
|
|
163
|
+
message:
|
|
164
|
+
tag === undefined
|
|
165
|
+
? "`links add` leaves a link untagged unless given --tag"
|
|
166
|
+
: `\`links add\` tags a link ${tag} unless given --tag`,
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
export const cli = defineCli({
|
|
172
|
+
manifest,
|
|
173
|
+
// Counts only real runs, and only for an operator who named a collector.
|
|
174
|
+
sideTask: ({ command, dryRun, env, ports, signal }) => {
|
|
175
|
+
const url = env.LINKS_USAGE_URL;
|
|
176
|
+
return dryRun || url === undefined ? undefined : ports.usage.count(url, command, signal);
|
|
177
|
+
},
|
|
178
|
+
commands: {
|
|
179
|
+
list,
|
|
180
|
+
add,
|
|
181
|
+
import: importLinks,
|
|
182
|
+
remove,
|
|
183
|
+
check,
|
|
184
|
+
archive,
|
|
185
|
+
export: exportLinks,
|
|
186
|
+
bundle,
|
|
187
|
+
commit,
|
|
188
|
+
serve,
|
|
189
|
+
},
|
|
190
|
+
doctor: [store, tag],
|
|
191
|
+
});
|