@duyquangnvx/agent-cli 0.16.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 +76 -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} +7 -4
- package/dist/{flags.js → command-line/flags.js} +8 -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 +81 -61
- 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 +52 -23
- 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 +1 -0
- 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,78 @@
|
|
|
1
|
+
import { runCli, runtimeCodes, type Spawn } from "@duyquangnvx/agent-cli";
|
|
2
|
+
import { describe, expect, test } from "vitest";
|
|
3
|
+
import { cli } from "./cli.js";
|
|
4
|
+
import { folderWith, LINKS, pathWith } from "./folder.js";
|
|
5
|
+
|
|
6
|
+
/** Child processes that answer from `answers`, keyed by the command line, and record each one. */
|
|
7
|
+
function fakeSpawn(
|
|
8
|
+
answers: Readonly<Record<string, { exitCode: number; stdout?: string; stderr?: string }>>,
|
|
9
|
+
) {
|
|
10
|
+
const ran: string[] = [];
|
|
11
|
+
const spawn: Spawn = async (command, args) => {
|
|
12
|
+
const line = [command, ...args].join(" ");
|
|
13
|
+
ran.push(line);
|
|
14
|
+
const answer = answers[line] ?? { exitCode: 0 };
|
|
15
|
+
return { stdout: answer.stdout ?? "", stderr: answer.stderr ?? "", exitCode: answer.exitCode };
|
|
16
|
+
};
|
|
17
|
+
return { spawn, ran };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("links commit", () => {
|
|
21
|
+
test("adds links.json and commits it with git", async () => {
|
|
22
|
+
const cwd = await folderWith(LINKS);
|
|
23
|
+
const { spawn, ran } = fakeSpawn({
|
|
24
|
+
"git rev-parse --short HEAD": { exitCode: 0, stdout: "4f2a9c1\n" },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const result = await runCli(cli, ["commit", "--message", "Add zod"], {
|
|
28
|
+
env: { PATH: await pathWith("git") },
|
|
29
|
+
cwd,
|
|
30
|
+
spawn,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(result).toEqual({
|
|
34
|
+
stdout: "Committed links.json as 4f2a9c1\n",
|
|
35
|
+
stderr: "",
|
|
36
|
+
exitCode: 0,
|
|
37
|
+
});
|
|
38
|
+
expect(ran).toEqual([
|
|
39
|
+
"git add links.json",
|
|
40
|
+
"git commit --message Add zod",
|
|
41
|
+
"git rev-parse --short HEAD",
|
|
42
|
+
]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a failing git fails with TOOL_FAILED, exit 2, and the end of its stderr", async () => {
|
|
46
|
+
const cwd = await folderWith(LINKS);
|
|
47
|
+
const { spawn } = fakeSpawn({
|
|
48
|
+
"git commit --message Update links": { exitCode: 1, stderr: "nothing to commit\n" },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const result = await runCli(cli, ["commit", "--json"], {
|
|
52
|
+
env: { PATH: await pathWith("git") },
|
|
53
|
+
cwd,
|
|
54
|
+
spawn,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result.exitCode).toBe(2);
|
|
58
|
+
expect(JSON.parse(result.stderr).errors[0]).toMatchObject({
|
|
59
|
+
code: runtimeCodes.TOOL_FAILED,
|
|
60
|
+
message: "git exited with 1; its stderr ends:\nnothing to commit",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("without git on PATH fails with TOOL_MISSING, exit 2, before running anything", async () => {
|
|
65
|
+
const cwd = await folderWith(LINKS);
|
|
66
|
+
const { spawn, ran } = fakeSpawn({});
|
|
67
|
+
|
|
68
|
+
const result = await runCli(cli, ["commit", "--json"], {
|
|
69
|
+
env: { PATH: await pathWith() },
|
|
70
|
+
cwd,
|
|
71
|
+
spawn,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result.exitCode).toBe(2);
|
|
75
|
+
expect(JSON.parse(result.stderr).errors[0].code).toBe(runtimeCodes.TOOL_MISSING);
|
|
76
|
+
expect(ran).toEqual([]);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { defineCommand } from "./define.js";
|
|
3
|
+
import { LINKS_FILE } from "./store.js";
|
|
4
|
+
|
|
5
|
+
export const commit = defineCommand({
|
|
6
|
+
description: "Commit links.json to the git repository it is in",
|
|
7
|
+
examples: [
|
|
8
|
+
{ command: "links commit", note: "commit links.json with the message Update links" },
|
|
9
|
+
{ command: 'links commit --message "Add the zod docs"', note: "commit with your message" },
|
|
10
|
+
],
|
|
11
|
+
effect: "write",
|
|
12
|
+
tools: [{ name: "git", install: "brew install git" }],
|
|
13
|
+
options: z.object({
|
|
14
|
+
message: z.string().default("Update links").describe("the commit message"),
|
|
15
|
+
}),
|
|
16
|
+
output: z.object({ commit: z.string() }),
|
|
17
|
+
plan: ({ message }) => ({ message }),
|
|
18
|
+
apply: async ({ message }, ctx) => {
|
|
19
|
+
await ctx.spawn("git", ["add", LINKS_FILE], { check: true });
|
|
20
|
+
await ctx.spawn("git", ["commit", "--message", message], { check: true });
|
|
21
|
+
const head = await ctx.spawn("git", ["rev-parse", "--short", "HEAD"], { check: true });
|
|
22
|
+
return { commit: head.stdout.trim() };
|
|
23
|
+
},
|
|
24
|
+
render: ({ commit }) => `Committed ${LINKS_FILE} as ${commit}`,
|
|
25
|
+
renderPlan: ({ message }) => `Would commit ${LINKS_FILE} with the message "${message}"`,
|
|
26
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { agentCli } from "@duyquangnvx/agent-cli";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { errors } from "./errors.js";
|
|
4
|
+
import { ports } from "./ports.js";
|
|
5
|
+
|
|
6
|
+
const settingsSchema = z.object({
|
|
7
|
+
tag: z.string().optional().describe("the tag `links add` gives a link saved without --tag"),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export type Settings = z.infer<typeof settingsSchema>;
|
|
11
|
+
|
|
12
|
+
export const { defineCli, defineCommand, cliError } = agentCli({
|
|
13
|
+
errors,
|
|
14
|
+
ports,
|
|
15
|
+
project: { file: "links.config.json", schema: settingsSchema },
|
|
16
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineErrors } from "@duyquangnvx/agent-cli";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
|
|
4
|
+
export const errors = defineErrors({
|
|
5
|
+
LINK_NOT_FOUND: { class: "caller" },
|
|
6
|
+
CORRUPT_DATA: { class: "caller" },
|
|
7
|
+
BUNDLE_EXISTS: { class: "caller" },
|
|
8
|
+
UNREACHABLE: { class: "source" },
|
|
9
|
+
ARCHIVE_FAILED: {
|
|
10
|
+
class: "source",
|
|
11
|
+
details: z.object({ id: z.number(), url: z.string() }),
|
|
12
|
+
},
|
|
13
|
+
CAPTURE_UNANSWERED: {
|
|
14
|
+
class: "operator",
|
|
15
|
+
details: z.object({ id: z.number(), url: z.string() }),
|
|
16
|
+
},
|
|
17
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { runCli, runtimeCodes } from "@duyquangnvx/agent-cli";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { cli } from "./cli.js";
|
|
6
|
+
import { denied, fakeFiles, folderWith, LINKS } from "./folder.js";
|
|
7
|
+
|
|
8
|
+
describe("links export", () => {
|
|
9
|
+
test("writes the links as a Markdown list", async () => {
|
|
10
|
+
const cwd = await folderWith(LINKS);
|
|
11
|
+
|
|
12
|
+
const result = await runCli(cli, ["export", "LINKS.md"], { cwd });
|
|
13
|
+
|
|
14
|
+
expect(result).toEqual({
|
|
15
|
+
stdout: `Exported 2 links to ${join(cwd, "LINKS.md")}\n`,
|
|
16
|
+
stderr: "",
|
|
17
|
+
exitCode: 0,
|
|
18
|
+
});
|
|
19
|
+
expect(await readFile(join(cwd, "LINKS.md"), "utf8")).toBe(
|
|
20
|
+
"- <https://zod.dev/> (docs)\n- <https://vitest.dev/>\n",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("--dry-run lists the file it would write", async () => {
|
|
25
|
+
const cwd = await folderWith(LINKS);
|
|
26
|
+
|
|
27
|
+
const result = await runCli(cli, ["export", "LINKS.md", "--dry-run"], { cwd });
|
|
28
|
+
|
|
29
|
+
expect(result.stdout).toBe("Would export 2 links to LINKS.md\noutputs:\n LINKS.md\n");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("an existing file refuses with OUTPUT_EXISTS, exit 1, until --force", async () => {
|
|
33
|
+
const cwd = await folderWith(LINKS);
|
|
34
|
+
await writeFile(join(cwd, "LINKS.md"), "mine\n");
|
|
35
|
+
|
|
36
|
+
const refused = await runCli(cli, ["export", "LINKS.md"], { cwd });
|
|
37
|
+
const forced = await runCli(cli, ["export", "LINKS.md", "--force"], { cwd });
|
|
38
|
+
|
|
39
|
+
expect(refused).toEqual({
|
|
40
|
+
stdout: "",
|
|
41
|
+
stderr:
|
|
42
|
+
"error: `links export` would overwrite LINKS.md\n" +
|
|
43
|
+
"hint: to replace it, run the Next step\n" +
|
|
44
|
+
"run: links export LINKS.md --force\n",
|
|
45
|
+
exitCode: 1,
|
|
46
|
+
});
|
|
47
|
+
expect(forced.exitCode).toBe(0);
|
|
48
|
+
expect(await readFile(join(cwd, "LINKS.md"), "utf8")).toContain("https://zod.dev/");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("a file it may not write fails with PERMISSION_DENIED, exit 2", async () => {
|
|
52
|
+
const cwd = await folderWith(LINKS);
|
|
53
|
+
|
|
54
|
+
const result = await runCli(cli, ["export", "LINKS.md", "--json"], {
|
|
55
|
+
cwd,
|
|
56
|
+
files: fakeFiles({ write: denied }),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
expect(result.exitCode).toBe(2);
|
|
60
|
+
expect(JSON.parse(result.stderr)).toMatchObject({
|
|
61
|
+
errors: [{ code: runtimeCodes.PERMISSION_DENIED }],
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { defineCommand } from "./define.js";
|
|
4
|
+
import { type Link, readLinks } from "./store.js";
|
|
5
|
+
|
|
6
|
+
function markdownOf(links: readonly Link[]): string {
|
|
7
|
+
return links
|
|
8
|
+
.map((link) => `- <${link.url}>${link.tags.length === 0 ? "" : ` (${link.tags.join(", ")})`}\n`)
|
|
9
|
+
.join("");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const exportLinks = defineCommand({
|
|
13
|
+
description: "Write the links to a Markdown file",
|
|
14
|
+
examples: [
|
|
15
|
+
{ command: "links export LINKS.md", note: "write every link to LINKS.md" },
|
|
16
|
+
{ command: "links export LINKS.md --force", note: "replace an existing LINKS.md" },
|
|
17
|
+
],
|
|
18
|
+
effect: "write",
|
|
19
|
+
errors: ["CORRUPT_DATA"],
|
|
20
|
+
args: z.object({
|
|
21
|
+
file: z.string().describe("the Markdown file to write, relative to the working directory"),
|
|
22
|
+
}),
|
|
23
|
+
output: z.object({ file: z.string(), count: z.number() }),
|
|
24
|
+
plan: async ({ file }, ctx) => ({ file, links: await readLinks(ctx) }),
|
|
25
|
+
outputs: (plan) => [plan.file],
|
|
26
|
+
apply: async (plan, ctx) => {
|
|
27
|
+
await ctx.files.write(plan.file, markdownOf(plan.links));
|
|
28
|
+
return { file: resolve(ctx.cwd, plan.file), count: plan.links.length };
|
|
29
|
+
},
|
|
30
|
+
render: ({ file, count }) => `Exported ${count} links to ${file}`,
|
|
31
|
+
renderPlan: (plan) => `Would export ${plan.links.length} links to ${plan.file}`,
|
|
32
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Archive, Ports, Usage, Web } from "./ports.js";
|
|
2
|
+
|
|
3
|
+
async function unused(): Promise<never> {
|
|
4
|
+
throw new Error("this test does not expect a call to this port");
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
const unusedArchive: Archive = { capture: unused, status: unused };
|
|
8
|
+
const unusedWeb: Web = { status: unused };
|
|
9
|
+
const unusedUsage: Usage = { count: unused };
|
|
10
|
+
|
|
11
|
+
/** Ports that fail when called, with `overrides` in place of the ones a test uses. */
|
|
12
|
+
export function fakePorts(overrides: Partial<Ports>): Ports {
|
|
13
|
+
return { archive: unusedArchive, web: unusedWeb, usage: unusedUsage, ...overrides };
|
|
14
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { chmod, mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { Files } from "@duyquangnvx/agent-cli";
|
|
5
|
+
|
|
6
|
+
export const LINKS = [
|
|
7
|
+
{ id: 1, url: "https://zod.dev/", tags: ["docs"] },
|
|
8
|
+
{ id: 2, url: "https://vitest.dev/", tags: [] },
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
/** A new temporary folder whose links.json holds `links`. */
|
|
12
|
+
export async function folderWith(links: unknown): Promise<string> {
|
|
13
|
+
const cwd = await mkdtemp(join(tmpdir(), "links-"));
|
|
14
|
+
await writeFile(join(cwd, "links.json"), JSON.stringify(links));
|
|
15
|
+
return cwd;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** A folder to use as PATH, holding an empty executable for each name. */
|
|
19
|
+
export async function pathWith(...names: readonly string[]): Promise<string> {
|
|
20
|
+
const folder = await mkdtemp(join(tmpdir(), "links-path-"));
|
|
21
|
+
for (const name of names) {
|
|
22
|
+
if (process.platform === "win32") {
|
|
23
|
+
await writeFile(join(folder, `${name}.cmd`), "@echo off\r\n");
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
await writeFile(join(folder, name), "#!/bin/sh\n");
|
|
27
|
+
await chmod(join(folder, name), 0o755);
|
|
28
|
+
}
|
|
29
|
+
return folder;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Files that hold nothing and accept every write, with `overrides` in place of some methods. */
|
|
33
|
+
export function fakeFiles(overrides: Partial<Files>): Files {
|
|
34
|
+
return {
|
|
35
|
+
exists: async () => false,
|
|
36
|
+
write: async () => {},
|
|
37
|
+
read: async () => new Uint8Array(),
|
|
38
|
+
list: async () => [],
|
|
39
|
+
remove: async () => {},
|
|
40
|
+
mkdir: async () => {},
|
|
41
|
+
stat: async () => undefined,
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function denied(): Promise<never> {
|
|
47
|
+
throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" });
|
|
48
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { runCli } from "@duyquangnvx/agent-cli";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { cli } from "./cli.js";
|
|
6
|
+
import { folderWith, LINKS } from "./folder.js";
|
|
7
|
+
|
|
8
|
+
describe("links import", () => {
|
|
9
|
+
test("- reads the text from stdin and saves each link in it not saved yet", async () => {
|
|
10
|
+
const cwd = await folderWith(LINKS);
|
|
11
|
+
const notes = "Read https://zod.dev/ and https://biomejs.dev/.\nThen (https://pnpm.io).\n";
|
|
12
|
+
|
|
13
|
+
const result = await runCli(cli, ["import", "-"], { cwd, stdin: notes });
|
|
14
|
+
|
|
15
|
+
expect(result).toEqual({
|
|
16
|
+
stdout: "Saved link 3: https://biomejs.dev/\nSaved link 4: https://pnpm.io\n",
|
|
17
|
+
stderr: "",
|
|
18
|
+
exitCode: 0,
|
|
19
|
+
});
|
|
20
|
+
expect(JSON.parse(await readFile(join(cwd, "links.json"), "utf8"))).toHaveLength(4);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("a repeated --tag gives each link every tag, in order", async () => {
|
|
24
|
+
const cwd = await folderWith([]);
|
|
25
|
+
|
|
26
|
+
const result = await runCli(
|
|
27
|
+
cli,
|
|
28
|
+
["import", "see https://zod.dev", "--tag", "docs", "--tag=zod", "--json"],
|
|
29
|
+
{ cwd },
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(JSON.parse(result.stdout)).toEqual([
|
|
33
|
+
{ id: 1, url: "https://zod.dev", tags: ["docs", "zod"] },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("an empty stdin is a usage error and saves nothing", async () => {
|
|
38
|
+
const cwd = await folderWith([]);
|
|
39
|
+
|
|
40
|
+
const result = await runCli(cli, ["import", "-"], { cwd, stdin: "" });
|
|
41
|
+
|
|
42
|
+
expect(result.exitCode).toBe(1);
|
|
43
|
+
expect(result.stdout).toBe("");
|
|
44
|
+
expect(await readFile(join(cwd, "links.json"), "utf8")).toBe("[]");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("--help shows the stdin form, and the help paragraph after the options", async () => {
|
|
48
|
+
const result = await runCli(cli, ["import", "--help"], { cwd: "." });
|
|
49
|
+
|
|
50
|
+
expect(result.stdout).toContain(" <text|-> the text holding the links");
|
|
51
|
+
expect(result.stdout).toMatch(/Options:[\s\S]*\n\nGive - to read the text from stdin, /);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { orStdin } from "@duyquangnvx/agent-cli";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { defineCommand } from "./define.js";
|
|
4
|
+
import { linkSchema, readLinks, writeLinks } from "./store.js";
|
|
5
|
+
|
|
6
|
+
// Ends on a character that cannot close a sentence, so "see https://zod.dev." keeps no dot.
|
|
7
|
+
const URL_PATTERN = /https?:\/\/[^\s<>()[\]"'`]*[^\s<>()[\]"'`.,;:!?]/g;
|
|
8
|
+
|
|
9
|
+
export const importLinks = defineCommand({
|
|
10
|
+
description: "Save every link found in a text, such as a Markdown file",
|
|
11
|
+
help:
|
|
12
|
+
"Give - to read the text from stdin, so a file of any length or quoting goes in whole: " +
|
|
13
|
+
"`links import - < notes.md`. Links already saved are skipped.",
|
|
14
|
+
examples: [
|
|
15
|
+
{ command: "links import - < notes.md", note: "save every link in notes.md" },
|
|
16
|
+
{
|
|
17
|
+
command: "links import 'see https://zod.dev' --tag docs --tag zod",
|
|
18
|
+
note: "save one link with two tags",
|
|
19
|
+
},
|
|
20
|
+
],
|
|
21
|
+
effect: "write",
|
|
22
|
+
errors: ["CORRUPT_DATA"],
|
|
23
|
+
args: z.object({
|
|
24
|
+
text: orStdin(z.string().min(1)).describe("the text holding the links; - reads it from stdin"),
|
|
25
|
+
}),
|
|
26
|
+
options: z.object({
|
|
27
|
+
tag: z
|
|
28
|
+
.array(z.string())
|
|
29
|
+
.default([])
|
|
30
|
+
.describe("tag each saved link with this; repeat for more tags"),
|
|
31
|
+
}),
|
|
32
|
+
output: z.array(linkSchema),
|
|
33
|
+
plan: async ({ text, tag }, ctx) => {
|
|
34
|
+
const links = await readLinks(ctx);
|
|
35
|
+
const saved = new Set(links.map((link) => link.url));
|
|
36
|
+
const urls = [...new Set(text.match(URL_PATTERN))].filter((url) => !saved.has(url));
|
|
37
|
+
const firstId = Math.max(0, ...links.map((link) => link.id)) + 1;
|
|
38
|
+
return { add: urls.map((url, index) => ({ id: firstId + index, url, tags: tag })) };
|
|
39
|
+
},
|
|
40
|
+
apply: async (plan, ctx) => {
|
|
41
|
+
const links = await readLinks(ctx);
|
|
42
|
+
await ctx.runToEnd("saving the links", () => writeLinks(ctx, [...links, ...plan.add]));
|
|
43
|
+
return plan.add;
|
|
44
|
+
},
|
|
45
|
+
render: (added) =>
|
|
46
|
+
added.length === 0
|
|
47
|
+
? "No new links."
|
|
48
|
+
: added.map((link) => `Saved link ${link.id}: ${link.url}`).join("\n"),
|
|
49
|
+
renderPlan: (plan) => `Would save ${plan.add.length} links`,
|
|
50
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "links",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Keep a list of links and check that they still resolve.",
|
|
5
|
+
"private": true,
|
|
6
|
+
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.12.0"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"links": "./dist/bin.js"
|
|
12
|
+
},
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsdown"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"@duyquangnvx/agent-cli": "workspace:*",
|
|
18
|
+
"tsdown": "0.23.0",
|
|
19
|
+
"zod": "4.6.7"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
|
|
3
|
+
export type Capture =
|
|
4
|
+
| { readonly state: "pending" }
|
|
5
|
+
| { readonly state: "done"; readonly snapshot: string }
|
|
6
|
+
| { readonly state: "failed"; readonly reason: string };
|
|
7
|
+
|
|
8
|
+
/** The Wayback Machine's capture service; a test replaces it through `runCli`'s `ports`. */
|
|
9
|
+
export interface Archive {
|
|
10
|
+
/** Starts capturing `url` and returns the capture's job id. */
|
|
11
|
+
capture(url: string, signal: AbortSignal): Promise<string>;
|
|
12
|
+
status(job: string, signal: AbortSignal): Promise<Capture>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The web the saved links live on. */
|
|
16
|
+
export interface Web {
|
|
17
|
+
/** The HTTP status `url` answers a HEAD request with, sent with `token` as a bearer token. */
|
|
18
|
+
status(url: string, token: string, signal: AbortSignal): Promise<number>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The operator's own collector of usage counts, which `LINKS_USAGE_URL` names. */
|
|
22
|
+
export interface Usage {
|
|
23
|
+
/** Counts one run of the Command at `command`. */
|
|
24
|
+
count(url: string, command: readonly string[], signal: AbortSignal): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Ports {
|
|
28
|
+
readonly archive: Archive;
|
|
29
|
+
readonly web: Web;
|
|
30
|
+
readonly usage: Usage;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const WAYBACK = "https://web.archive.org";
|
|
34
|
+
|
|
35
|
+
const jobSchema = z.object({ job_id: z.string() });
|
|
36
|
+
|
|
37
|
+
const statusSchema = z.discriminatedUnion("status", [
|
|
38
|
+
z.object({ status: z.literal("pending") }),
|
|
39
|
+
z.object({ status: z.literal("success"), timestamp: z.string(), original_url: z.string() }),
|
|
40
|
+
z.object({ status: z.literal("error"), message: z.string() }),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
async function json(response: Response): Promise<unknown> {
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
throw new Error(`${response.url} answered ${response.status}`);
|
|
46
|
+
}
|
|
47
|
+
return response.json();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function waybackArchive(): Archive {
|
|
51
|
+
return {
|
|
52
|
+
capture: async (url, signal) => {
|
|
53
|
+
const response = await fetch(`${WAYBACK}/save`, {
|
|
54
|
+
method: "POST",
|
|
55
|
+
headers: { accept: "application/json" },
|
|
56
|
+
body: new URLSearchParams({ url }),
|
|
57
|
+
signal,
|
|
58
|
+
});
|
|
59
|
+
return jobSchema.parse(await json(response)).job_id;
|
|
60
|
+
},
|
|
61
|
+
status: async (job, signal) => {
|
|
62
|
+
const response = await fetch(`${WAYBACK}/save/status/${job}`, {
|
|
63
|
+
headers: { accept: "application/json" },
|
|
64
|
+
signal,
|
|
65
|
+
});
|
|
66
|
+
const status = statusSchema.parse(await json(response));
|
|
67
|
+
switch (status.status) {
|
|
68
|
+
case "pending":
|
|
69
|
+
return { state: "pending" };
|
|
70
|
+
case "success":
|
|
71
|
+
return {
|
|
72
|
+
state: "done",
|
|
73
|
+
snapshot: `${WAYBACK}/web/${status.timestamp}/${status.original_url}`,
|
|
74
|
+
};
|
|
75
|
+
case "error":
|
|
76
|
+
return { state: "failed", reason: status.message };
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const web: Web = {
|
|
83
|
+
status: async (url, token, signal) => {
|
|
84
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
85
|
+
return (await fetch(url, { method: "HEAD", headers, signal })).status;
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const usage: Usage = {
|
|
90
|
+
count: async (url, command, signal) => {
|
|
91
|
+
const response = await fetch(url, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "content-type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ command: command.join(" ") }),
|
|
95
|
+
signal,
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new Error(`${url} answered ${response.status}`);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function ports(): Ports {
|
|
104
|
+
return { archive: waybackArchive(), web, usage };
|
|
105
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import { runCli } from "@duyquangnvx/agent-cli";
|
|
3
|
+
import { describe, expect, test } from "vitest";
|
|
4
|
+
import { cli } from "./cli.js";
|
|
5
|
+
import { folderWith, LINKS } from "./folder.js";
|
|
6
|
+
|
|
7
|
+
const RETRY_MS = 10;
|
|
8
|
+
|
|
9
|
+
async function freePort(): Promise<number> {
|
|
10
|
+
const server = createServer();
|
|
11
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
12
|
+
const address = server.address();
|
|
13
|
+
await new Promise((resolve) => server.close(resolve));
|
|
14
|
+
if (address === null || typeof address === "string") throw new Error("no port");
|
|
15
|
+
return address.port;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The page at `url`, asked again until the server is up. */
|
|
19
|
+
async function pageAt(url: string): Promise<string> {
|
|
20
|
+
for (;;) {
|
|
21
|
+
try {
|
|
22
|
+
return await (await fetch(url)).text();
|
|
23
|
+
} catch {
|
|
24
|
+
await new Promise((resolve) => setTimeout(resolve, RETRY_MS));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("links serve", () => {
|
|
30
|
+
test("serves the links until stopped, then exits 0", async () => {
|
|
31
|
+
const cwd = await folderWith(LINKS);
|
|
32
|
+
const port = await freePort();
|
|
33
|
+
const stop = new AbortController();
|
|
34
|
+
|
|
35
|
+
const run = runCli(cli, ["serve", "--port", String(port)], {
|
|
36
|
+
cwd,
|
|
37
|
+
signal: stop.signal,
|
|
38
|
+
});
|
|
39
|
+
const page = await pageAt(`http://127.0.0.1:${port}/`);
|
|
40
|
+
stop.abort();
|
|
41
|
+
|
|
42
|
+
expect(page).toContain('<a href="https://zod.dev/">https://zod.dev/</a>');
|
|
43
|
+
expect(await run).toEqual({
|
|
44
|
+
stdout: `Serving the links at http://127.0.0.1:${port}/ until Ctrl-C\n`,
|
|
45
|
+
stderr: "",
|
|
46
|
+
exitCode: 0,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { defineCommand } from "./define.js";
|
|
4
|
+
import { type Link, readLinks } from "./store.js";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_PORT = 4173;
|
|
7
|
+
const HOST = "127.0.0.1";
|
|
8
|
+
|
|
9
|
+
function pageOf(links: readonly Link[]): string {
|
|
10
|
+
const items = links.map((link) => `<li><a href="${link.url}">${link.url}</a></li>`).join("");
|
|
11
|
+
return `<!doctype html><title>Links</title><ul>${items}</ul>\n`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const serve = defineCommand({
|
|
15
|
+
description: "Serve the links as a web page until stopped",
|
|
16
|
+
examples: [
|
|
17
|
+
{ command: "links serve", note: `serve the links at http://${HOST}:${DEFAULT_PORT}/` },
|
|
18
|
+
{ command: "links serve --port 8080", note: "serve them on port 8080" },
|
|
19
|
+
],
|
|
20
|
+
effect: "serve",
|
|
21
|
+
options: z.object({
|
|
22
|
+
port: z.number().int().positive().default(DEFAULT_PORT).describe("the port to listen on"),
|
|
23
|
+
}),
|
|
24
|
+
output: z.object({ url: z.string() }),
|
|
25
|
+
start: async ({ port }, ctx) => {
|
|
26
|
+
const server = createServer(async (_request, response) => {
|
|
27
|
+
try {
|
|
28
|
+
const links = await readLinks(ctx);
|
|
29
|
+
response.writeHead(200, { "content-type": "text/html" }).end(pageOf(links));
|
|
30
|
+
} catch (error) {
|
|
31
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
32
|
+
response.writeHead(500, { "content-type": "text/plain" }).end(`${message}\n`);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
await new Promise<void>((resolve) => server.listen(port, HOST, resolve));
|
|
36
|
+
ctx.signal.addEventListener("abort", () => server.close(), { once: true });
|
|
37
|
+
return { url: `http://${HOST}:${port}/` };
|
|
38
|
+
},
|
|
39
|
+
render: ({ url }) => `Serving the links at ${url} until Ctrl-C`,
|
|
40
|
+
});
|