@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
package/docs/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# agent-cli
|
|
2
|
+
|
|
3
|
+
> `@duyquangnvx/agent-cli` is the Runtime for Agent CLIs: command-line tools whose main caller is a Coding agent running them through a shell. It owns parsing, help, output, errors and exit codes, so every Agent CLI keeps the Output contract; a CLI declares Commands and the Runtime does the rest. These docs describe the version installed beside them.
|
|
4
|
+
|
|
5
|
+
Read [Principles](principles.md) once, then the guide for the task at hand. Every TypeScript block in the guides is an excerpt of the [example](example/cli.ts).
|
|
6
|
+
|
|
7
|
+
## Start
|
|
8
|
+
|
|
9
|
+
- [Principles](principles.md): what a Coding agent needs from a CLI and how the Runtime gives it; read it before a judgement call a type leaves open, such as which Effect or Exit class.
|
|
10
|
+
- [Getting started](getting-started.md): creating a CLI with the Scaffolder, its flags, and when the new repo counts as started.
|
|
11
|
+
|
|
12
|
+
## Guides
|
|
13
|
+
|
|
14
|
+
- [Commands](guides/commands.md): declaring a Command, its schemas, examples, output and `render`, and reaching files, programs, Secrets and progress through `ctx`.
|
|
15
|
+
- [Effects and Plans](guides/effects-and-plans.md): choosing an Effect, splitting a write into `plan` and `apply`, `--dry-run`, `--force`, `--confirm`.
|
|
16
|
+
- [Errors](guides/errors.md): the Error table, Exit classes, `ctx.fail`, Next steps, several failures at once.
|
|
17
|
+
- [Lists](guides/lists.md): a List Command, `paginate` and `page`, `--limit`, `--cursor`, `--fields`.
|
|
18
|
+
- [Long runs](guides/long-runs.md): a Command that calls a slow outside system, polls, could outlast the caller's tool timeout, starts a job to check on later, has a step that must not stop halfway, works through a queue, reports progress, keeps running as a server, or must keep finished work when it fails.
|
|
19
|
+
- [Remote services](guides/remote-services.md): a port to a remote service: declaring ports, classifying failures, retrying, a request that starts a job or bills, a cost the operator approves, an SDK or LLM call, a Side task such as a usage count beside every run.
|
|
20
|
+
- [Local state](guides/local-state.md): a Daemon or a store that outlives a run.
|
|
21
|
+
- [`doctor`](guides/doctor.md): adding a `doctor` row, a tool the whole CLI relies on, or what the `install`, `registry` and `skill` rows check.
|
|
22
|
+
- [Testing](guides/testing.md): writing a test, faking a port, the clock, files, a child process or a tool.
|
|
23
|
+
- [Shipping](guides/shipping.md): choosing a Global or a Dependency install, releasing the CLI, its install lines, its own skill from a Skill Hub or packaged with it, or an Agents section.
|
|
24
|
+
|
|
25
|
+
## Reference
|
|
26
|
+
|
|
27
|
+
- [API reference](reference.md): which `.d.ts` file documents each export, for any field, option or method a guide does not spell out.
|
|
28
|
+
- [Example](example/cli.ts): `links`, a complete Agent CLI using every feature the guides cover, with its tests beside each file.
|
|
29
|
+
|
|
30
|
+
## A Command is done when
|
|
31
|
+
|
|
32
|
+
- Every Error table code it throws, its helpers' and ports' included, is in its `errors`, and it throws its own through `ctx.fail`.
|
|
33
|
+
- Every key it reads is in its `secrets`, whose `get` says where to obtain it.
|
|
34
|
+
- Each line to run next is a Next step in `next`, never in the hint's prose; a rerun of the caller's own line comes from `ctx.rerun()`; the hint says whether running them is safe, and a step only the operator can take says so.
|
|
35
|
+
- It declares `output` or `rows`.
|
|
36
|
+
- It has `examples` with realistic full command lines starting with the CLI's name, which `checkExamples(cli)` passes.
|
|
37
|
+
- Every field has `.describe()`.
|
|
38
|
+
- `ctx.signal` reaches every outside call.
|
|
39
|
+
- Every call to a slow outside system runs under a deadline.
|
|
40
|
+
- Its tests, through `runCli`, cover:
|
|
41
|
+
- the success output, in text and under `--json`;
|
|
42
|
+
- each `CliError` code;
|
|
43
|
+
- for a Command with `secrets`, `SECRET_MISSING` before any work;
|
|
44
|
+
- for a write or destructive Command, that `--dry-run` and each refused guard change nothing;
|
|
45
|
+
- for a write Command, that it is idempotent: a second run after a success changes nothing;
|
|
46
|
+
- cancellation by passing `signal` to `runCli`, aborted once with the reason `"SIGTERM"` for 143;
|
|
47
|
+
- for a `serve` Command, aborting `signal` once the server answers;
|
|
48
|
+
- each `doctor` row it adds, with `HOME` pointed at an empty directory so no installed skill or secret leaks in.
|
|
49
|
+
- The CLI's `pnpm test`, `pnpm typecheck` and `pnpm lint` pass.
|
|
50
|
+
|
|
51
|
+
The example's tests have each of these.
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { setTimeout as wait } from "node:timers/promises";
|
|
2
|
+
import { runCli } from "@duyquangnvx/agent-cli";
|
|
3
|
+
import { fakeClock } from "@duyquangnvx/agent-cli/testing";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { cli } from "./cli.js";
|
|
6
|
+
import { cliError } from "./define.js";
|
|
7
|
+
import { fakePorts } from "./fake-ports.js";
|
|
8
|
+
import { folderWith, LINKS } from "./folder.js";
|
|
9
|
+
import type { Archive, Capture } from "./ports.js";
|
|
10
|
+
|
|
11
|
+
/** An archive whose captures stay pending for one poll, then end as `captures` says. */
|
|
12
|
+
function fakeArchive(captures: Readonly<Record<string, Capture>>): Archive {
|
|
13
|
+
const polled = new Set<string>();
|
|
14
|
+
return {
|
|
15
|
+
capture: async (url) => url,
|
|
16
|
+
status: async (job) => {
|
|
17
|
+
if (!polled.has(job)) {
|
|
18
|
+
polled.add(job);
|
|
19
|
+
return { state: "pending" };
|
|
20
|
+
}
|
|
21
|
+
return captures[job] ?? { state: "pending" };
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Real milliseconds a capture takes on threads the fake clock cannot see, as sharp's work does.
|
|
27
|
+
const OFF_THREAD_MS = 20;
|
|
28
|
+
|
|
29
|
+
const SNAPSHOTS: Record<string, Capture> = {
|
|
30
|
+
"https://zod.dev/": { state: "done", snapshot: "https://web.archive.org/web/1/https://zod.dev/" },
|
|
31
|
+
"https://vitest.dev/": {
|
|
32
|
+
state: "done",
|
|
33
|
+
snapshot: "https://web.archive.org/web/2/https://vitest.dev/",
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
describe("links archive", () => {
|
|
38
|
+
test("--dry-run prints the Plan and its cost", async () => {
|
|
39
|
+
const cwd = await folderWith(LINKS);
|
|
40
|
+
|
|
41
|
+
const result = await runCli(cli, ["archive", "--dry-run"], { cwd });
|
|
42
|
+
|
|
43
|
+
expect(result).toEqual({
|
|
44
|
+
stdout:
|
|
45
|
+
"Would archive 2 links:\n https://zod.dev/\n https://vitest.dev/\n" +
|
|
46
|
+
"cost: Archiving 2 links uses 2 of the Wayback Machine's daily captures.\n",
|
|
47
|
+
stderr: "",
|
|
48
|
+
exitCode: 0,
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("without --yes refuses with NOT_CONFIRMED, exit 2, naming the cost", async () => {
|
|
53
|
+
const cwd = await folderWith(LINKS);
|
|
54
|
+
|
|
55
|
+
const result = await runCli(cli, ["archive"], {
|
|
56
|
+
cwd,
|
|
57
|
+
ports: fakePorts({ archive: fakeArchive(SNAPSHOTS) }),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
expect(result).toEqual({
|
|
61
|
+
stdout: "",
|
|
62
|
+
stderr:
|
|
63
|
+
"error: `links archive` needs --yes: Archiving 2 links uses 2 of the Wayback Machine's daily captures.\n" +
|
|
64
|
+
"hint: to approve the cost, run the Next step\n" +
|
|
65
|
+
"run: links archive --yes\n",
|
|
66
|
+
exitCode: 2,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a port's own CliError keeps its code and Exit class", async () => {
|
|
71
|
+
const cwd = await folderWith(LINKS);
|
|
72
|
+
const archive: Archive = {
|
|
73
|
+
capture: async () => {
|
|
74
|
+
throw cliError("UNREACHABLE", {
|
|
75
|
+
message: "web.archive.org did not answer",
|
|
76
|
+
hint: "run the Next step later",
|
|
77
|
+
next: [["archive", "--yes"]],
|
|
78
|
+
});
|
|
79
|
+
},
|
|
80
|
+
status: async () => ({ state: "pending" }),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const result = await runCli(cli, ["archive", "--yes", "--json"], {
|
|
84
|
+
cwd,
|
|
85
|
+
ports: fakePorts({ archive }),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
expect(result.exitCode).toBe(3);
|
|
89
|
+
expect(JSON.parse(result.stderr).errors.map(({ code }: { code: string }) => code)).toEqual([
|
|
90
|
+
"UNREACHABLE",
|
|
91
|
+
"UNREACHABLE",
|
|
92
|
+
]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("polls each capture until it is done and saves its snapshot", async () => {
|
|
96
|
+
const cwd = await folderWith(LINKS);
|
|
97
|
+
|
|
98
|
+
const result = await runCli(cli, ["archive", "--yes"], {
|
|
99
|
+
cwd,
|
|
100
|
+
ports: fakePorts({ archive: fakeArchive(SNAPSHOTS) }),
|
|
101
|
+
clock: fakeClock(),
|
|
102
|
+
});
|
|
103
|
+
const listed = await runCli(cli, ["list", "--fields", "id,snapshot", "--json"], {
|
|
104
|
+
cwd,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(result).toEqual({
|
|
108
|
+
stdout:
|
|
109
|
+
"1 https://web.archive.org/web/1/https://zod.dev/\n" +
|
|
110
|
+
"2 https://web.archive.org/web/2/https://vitest.dev/\n",
|
|
111
|
+
stderr: "",
|
|
112
|
+
exitCode: 0,
|
|
113
|
+
});
|
|
114
|
+
expect(JSON.parse(listed.stdout).items).toEqual([
|
|
115
|
+
{ id: 1, snapshot: "https://web.archive.org/web/1/https://zod.dev/" },
|
|
116
|
+
{ id: 2, snapshot: "https://web.archive.org/web/2/https://vitest.dev/" },
|
|
117
|
+
]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("is idempotent: a second run captures nothing", async () => {
|
|
121
|
+
const cwd = await folderWith(LINKS);
|
|
122
|
+
await runCli(cli, ["archive", "--yes"], {
|
|
123
|
+
cwd,
|
|
124
|
+
ports: fakePorts({ archive: fakeArchive(SNAPSHOTS) }),
|
|
125
|
+
clock: fakeClock(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const again = await runCli(cli, ["archive", "--yes", "--json"], {
|
|
129
|
+
cwd,
|
|
130
|
+
ports: fakePorts({}),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(again).toEqual({ stdout: "[]\n", stderr: "", exitCode: 0 });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a link the archive cannot capture fails with ARCHIVE_FAILED, exit 3, after the rest", async () => {
|
|
137
|
+
const cwd = await folderWith(LINKS);
|
|
138
|
+
const archive = fakeArchive({
|
|
139
|
+
"https://zod.dev/": { state: "failed", reason: "the site refused the crawler" },
|
|
140
|
+
"https://vitest.dev/": SNAPSHOTS["https://vitest.dev/"] as Capture,
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const result = await runCli(cli, ["archive", "--yes", "--json"], {
|
|
144
|
+
cwd,
|
|
145
|
+
ports: fakePorts({ archive }),
|
|
146
|
+
clock: fakeClock(),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
expect(result.exitCode).toBe(3);
|
|
150
|
+
expect(JSON.parse(result.stdout)).toEqual([
|
|
151
|
+
{ id: 2, snapshot: "https://web.archive.org/web/2/https://vitest.dev/" },
|
|
152
|
+
]);
|
|
153
|
+
expect(JSON.parse(result.stderr)).toEqual({
|
|
154
|
+
errors: [
|
|
155
|
+
{
|
|
156
|
+
code: "ARCHIVE_FAILED",
|
|
157
|
+
message:
|
|
158
|
+
"the Wayback Machine could not capture https://zod.dev/: the site refused the crawler",
|
|
159
|
+
hint: "retry later to archive the links not archived yet",
|
|
160
|
+
details: { id: 1, url: "https://zod.dev/" },
|
|
161
|
+
next: ["links archive --yes"],
|
|
162
|
+
},
|
|
163
|
+
],
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a capture request never answered fails with CAPTURE_UNANSWERED, exit 2, after the rest", async () => {
|
|
168
|
+
const cwd = await folderWith(LINKS);
|
|
169
|
+
const archive: Archive = {
|
|
170
|
+
...fakeArchive(SNAPSHOTS),
|
|
171
|
+
capture: (url, signal) =>
|
|
172
|
+
url === "https://zod.dev/"
|
|
173
|
+
? new Promise((_resolve, reject) =>
|
|
174
|
+
signal.addEventListener("abort", () => reject(signal.reason), { once: true }),
|
|
175
|
+
)
|
|
176
|
+
: Promise.resolve(url),
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const result = await runCli(cli, ["archive", "--yes", "--json"], {
|
|
180
|
+
cwd,
|
|
181
|
+
ports: fakePorts({ archive }),
|
|
182
|
+
clock: fakeClock(),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
expect(result.exitCode).toBe(2);
|
|
186
|
+
expect(JSON.parse(result.stdout)).toEqual([
|
|
187
|
+
{ id: 2, snapshot: "https://web.archive.org/web/2/https://vitest.dev/" },
|
|
188
|
+
]);
|
|
189
|
+
expect(JSON.parse(result.stderr)).toEqual({
|
|
190
|
+
errors: [
|
|
191
|
+
{
|
|
192
|
+
code: "CAPTURE_UNANSWERED",
|
|
193
|
+
message: "the Wayback Machine did not answer the capture of https://zod.dev/ in 60000 ms",
|
|
194
|
+
hint: "it may have used one of today's captures: ask your user before running again",
|
|
195
|
+
details: { id: 1, url: "https://zod.dev/" },
|
|
196
|
+
next: ["links archive --yes"],
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("a capture still running off Node's thread holds the clock when the test says it is busy", async () => {
|
|
203
|
+
const cwd = await folderWith(LINKS);
|
|
204
|
+
let inFlight = 0;
|
|
205
|
+
const archive: Archive = {
|
|
206
|
+
...fakeArchive(SNAPSHOTS),
|
|
207
|
+
capture: async (url) => {
|
|
208
|
+
inFlight++;
|
|
209
|
+
try {
|
|
210
|
+
await wait(OFF_THREAD_MS);
|
|
211
|
+
return url;
|
|
212
|
+
} finally {
|
|
213
|
+
inFlight--;
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const result = await runCli(cli, ["archive", "--yes"], {
|
|
219
|
+
cwd,
|
|
220
|
+
ports: fakePorts({ archive }),
|
|
221
|
+
clock: fakeClock({ busy: () => inFlight > 0 }),
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
expect(result).toEqual({
|
|
225
|
+
stdout:
|
|
226
|
+
"1 https://web.archive.org/web/1/https://zod.dev/\n" +
|
|
227
|
+
"2 https://web.archive.org/web/2/https://vitest.dev/\n",
|
|
228
|
+
stderr: "",
|
|
229
|
+
exitCode: 0,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("past its run deadline stops with exit 75, prints what it archived and hints how to resume", async () => {
|
|
234
|
+
const cwd = await folderWith(LINKS);
|
|
235
|
+
const archive = fakeArchive({ "https://zod.dev/": SNAPSHOTS["https://zod.dev/"] as Capture });
|
|
236
|
+
|
|
237
|
+
const result = await runCli(cli, ["archive", "--yes"], {
|
|
238
|
+
cwd,
|
|
239
|
+
ports: fakePorts({ archive }),
|
|
240
|
+
clock: fakeClock(),
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(result).toEqual({
|
|
244
|
+
stdout: "1 https://web.archive.org/web/1/https://zod.dev/\n",
|
|
245
|
+
stderr:
|
|
246
|
+
"error: links archive ran past its 240000 ms deadline\n" +
|
|
247
|
+
"hint: run the Next step to continue the work\n" +
|
|
248
|
+
"run: links archive --yes\n",
|
|
249
|
+
exitCode: 75,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("interrupted, prints what it archived and says how many links are left", async () => {
|
|
254
|
+
const cwd = await folderWith(LINKS);
|
|
255
|
+
const stop = new AbortController();
|
|
256
|
+
const archive: Archive = {
|
|
257
|
+
...fakeArchive(SNAPSHOTS),
|
|
258
|
+
capture: async (url) => {
|
|
259
|
+
if (url === "https://vitest.dev/") stop.abort();
|
|
260
|
+
return url;
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
const result = await runCli(cli, ["archive", "--yes"], {
|
|
265
|
+
cwd,
|
|
266
|
+
ports: fakePorts({ archive }),
|
|
267
|
+
clock: fakeClock(),
|
|
268
|
+
signal: stop.signal,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
expect(result).toEqual({
|
|
272
|
+
stdout: "1 https://web.archive.org/web/1/https://zod.dev/\n",
|
|
273
|
+
stderr:
|
|
274
|
+
"error: interrupted after archiving 1 of 2 links\n" +
|
|
275
|
+
"hint: unless it was stopped on purpose, run again to archive the rest\n" +
|
|
276
|
+
"run: links archive --yes\n",
|
|
277
|
+
exitCode: 130,
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { CliError, type Context, runtimeCodes } from "@duyquangnvx/agent-cli";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { cliError, defineCommand } from "./define.js";
|
|
4
|
+
import type { Ports } from "./ports.js";
|
|
5
|
+
import { type Link, readLinks, writeLinks } from "./store.js";
|
|
6
|
+
|
|
7
|
+
const CAPTURE_POLL_MS = 5000;
|
|
8
|
+
const CAPTURE_TIMEOUT_MS = 60_000;
|
|
9
|
+
// Below the two to ten minutes a Coding agent gives a shell call.
|
|
10
|
+
const ARCHIVE_DEADLINE_MS = 4 * 60_000;
|
|
11
|
+
|
|
12
|
+
const archivedSchema = z.object({ id: z.number(), snapshot: z.string() });
|
|
13
|
+
|
|
14
|
+
type Archived = z.infer<typeof archivedSchema>;
|
|
15
|
+
|
|
16
|
+
function archiveFailed(link: Link, reason: string, cause?: unknown): CliError {
|
|
17
|
+
return cliError(
|
|
18
|
+
"ARCHIVE_FAILED",
|
|
19
|
+
{
|
|
20
|
+
message: `the Wayback Machine could not capture ${link.url}: ${reason}`,
|
|
21
|
+
hint: "retry later to archive the links not archived yet",
|
|
22
|
+
next: [["archive", "--yes"]],
|
|
23
|
+
details: { id: link.id, url: link.url },
|
|
24
|
+
},
|
|
25
|
+
{ cause },
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A missed run deadline keeps its own error, whose Next step is already the resume line.
|
|
30
|
+
function stoppedAfter(ctx: Context<Ports>, done: number, total: number): unknown {
|
|
31
|
+
const reason: unknown = ctx.signal.reason;
|
|
32
|
+
if (!(reason instanceof CliError && reason.code === runtimeCodes.INTERRUPTED)) {
|
|
33
|
+
return reason;
|
|
34
|
+
}
|
|
35
|
+
return new CliError({
|
|
36
|
+
code: runtimeCodes.INTERRUPTED,
|
|
37
|
+
message: `interrupted after archiving ${done} of ${total} links`,
|
|
38
|
+
hint: "unless it was stopped on purpose, run again to archive the rest",
|
|
39
|
+
next: [ctx.rerun()],
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A sent capture may use one of the day's captures, so neither Ctrl-C nor the run deadline cuts it off.
|
|
44
|
+
async function sendCapture(link: Link, ctx: Context<Ports>): Promise<string> {
|
|
45
|
+
try {
|
|
46
|
+
return await ctx.runToEnd("sending the capture", () =>
|
|
47
|
+
ctx.ports.archive.capture(link.url, ctx.deadline(CAPTURE_TIMEOUT_MS, { ignoreStop: true })),
|
|
48
|
+
);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!(error instanceof CliError && error.code === runtimeCodes.DEADLINE_EXCEEDED)) throw error;
|
|
51
|
+
throw cliError(
|
|
52
|
+
"CAPTURE_UNANSWERED",
|
|
53
|
+
{
|
|
54
|
+
message: `the Wayback Machine did not answer the capture of ${link.url} in ${CAPTURE_TIMEOUT_MS} ms`,
|
|
55
|
+
hint: "it may have used one of today's captures: ask your user before running again",
|
|
56
|
+
next: [["archive", "--yes"]],
|
|
57
|
+
details: { id: link.id, url: link.url },
|
|
58
|
+
},
|
|
59
|
+
{ cause: error },
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function snapshotOf(link: Link, ctx: Context<Ports>): Promise<string> {
|
|
65
|
+
const { archive } = ctx.ports;
|
|
66
|
+
let capture: Awaited<ReturnType<typeof archive.status>>;
|
|
67
|
+
try {
|
|
68
|
+
const job = await sendCapture(link, ctx);
|
|
69
|
+
capture = await ctx.poll(
|
|
70
|
+
async () => {
|
|
71
|
+
const status = await archive.status(job, ctx.signal);
|
|
72
|
+
return status.state === "pending" ? undefined : status;
|
|
73
|
+
},
|
|
74
|
+
{ intervalMs: CAPTURE_POLL_MS },
|
|
75
|
+
);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// An interrupt or a missed deadline already carries the right error.
|
|
78
|
+
if (ctx.signal.aborted) {
|
|
79
|
+
throw ctx.signal.reason;
|
|
80
|
+
}
|
|
81
|
+
if (error instanceof CliError) {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
throw archiveFailed(link, error instanceof Error ? error.message : String(error), error);
|
|
85
|
+
}
|
|
86
|
+
if (capture.state === "failed") {
|
|
87
|
+
throw archiveFailed(link, capture.reason);
|
|
88
|
+
}
|
|
89
|
+
return capture.snapshot;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function saveSnapshot(ctx: Context<Ports>, archived: Archived): Promise<void> {
|
|
93
|
+
const links = await readLinks(ctx);
|
|
94
|
+
await writeLinks(
|
|
95
|
+
ctx,
|
|
96
|
+
links.map((link) =>
|
|
97
|
+
link.id === archived.id ? { ...link, snapshot: archived.snapshot } : link,
|
|
98
|
+
),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export const archive = defineCommand({
|
|
103
|
+
description: "Save a snapshot of each link not archived yet in the Wayback Machine",
|
|
104
|
+
examples: [
|
|
105
|
+
{ command: "links archive --dry-run", note: "show the links it would archive and the cost" },
|
|
106
|
+
{ command: "links archive --tag docs --yes", note: "archive the links tagged docs" },
|
|
107
|
+
],
|
|
108
|
+
effect: "write",
|
|
109
|
+
errors: ["CORRUPT_DATA", "ARCHIVE_FAILED", "CAPTURE_UNANSWERED", "UNREACHABLE"],
|
|
110
|
+
options: z.object({
|
|
111
|
+
tag: z.string().optional().describe("only archive links with this tag"),
|
|
112
|
+
}),
|
|
113
|
+
output: z.array(archivedSchema),
|
|
114
|
+
deadline: {
|
|
115
|
+
ms: ARCHIVE_DEADLINE_MS,
|
|
116
|
+
// Archived links are skipped, so running the same command again continues the work.
|
|
117
|
+
resume: (_input, rerun) => rerun(),
|
|
118
|
+
},
|
|
119
|
+
plan: async ({ tag }, ctx) => {
|
|
120
|
+
const links = await readLinks(ctx);
|
|
121
|
+
return {
|
|
122
|
+
archive: links.filter(
|
|
123
|
+
(link) => link.snapshot === undefined && (tag === undefined || link.tags.includes(tag)),
|
|
124
|
+
),
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
cost: (plan) =>
|
|
128
|
+
plan.archive.length === 0
|
|
129
|
+
? undefined
|
|
130
|
+
: `Archiving ${plan.archive.length} links uses ${plan.archive.length} of the Wayback Machine's daily captures.`,
|
|
131
|
+
apply: async (plan, ctx) => {
|
|
132
|
+
let archived: Archived[] = [];
|
|
133
|
+
let failures: unknown[] = [];
|
|
134
|
+
for (const link of plan.archive) {
|
|
135
|
+
try {
|
|
136
|
+
const done = { id: link.id, snapshot: await snapshotOf(link, ctx) };
|
|
137
|
+
await ctx.runToEnd("saving the links", () => saveSnapshot(ctx, done));
|
|
138
|
+
archived = [...archived, done];
|
|
139
|
+
ctx.deliver(archived);
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (ctx.signal.aborted) {
|
|
142
|
+
throw stoppedAfter(ctx, archived.length, plan.archive.length);
|
|
143
|
+
}
|
|
144
|
+
failures = [...failures, error];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (failures.length > 0) {
|
|
148
|
+
throw new AggregateError(failures, `${failures.length} links were not archived`);
|
|
149
|
+
}
|
|
150
|
+
return archived;
|
|
151
|
+
},
|
|
152
|
+
render: (archived) =>
|
|
153
|
+
archived.length === 0
|
|
154
|
+
? "Nothing to archive."
|
|
155
|
+
: archived.map((link) => `${link.id} ${link.snapshot}`).join("\n"),
|
|
156
|
+
renderPlan: (plan) =>
|
|
157
|
+
plan.archive.length === 0
|
|
158
|
+
? "Nothing to archive."
|
|
159
|
+
: `Would archive ${plan.archive.length} links:\n${plan.archive.map((link) => ` ${link.url}`).join("\n")}`,
|
|
160
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { type ChildProcess, execFileSync, spawn } from "node:child_process";
|
|
2
|
+
import { mkdtemp, realpath, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createServer, type Server } from "node:http";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { type RunResult, runCli, runtimeCodes } from "@duyquangnvx/agent-cli";
|
|
8
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
9
|
+
import { cli } from "./cli.js";
|
|
10
|
+
import { folderWith, LINKS } from "./folder.js";
|
|
11
|
+
|
|
12
|
+
// Under a full parallel run a spawned bin can take seconds just to start.
|
|
13
|
+
const SPAWN_TIMEOUT_MS = 20_000;
|
|
14
|
+
|
|
15
|
+
vi.setConfig({ testTimeout: SPAWN_TIMEOUT_MS });
|
|
16
|
+
|
|
17
|
+
const BIN = fileURLToPath(new URL("./dist/bin.js", import.meta.url));
|
|
18
|
+
|
|
19
|
+
interface Spawned {
|
|
20
|
+
readonly child: ChildProcess;
|
|
21
|
+
readonly result: Promise<RunResult>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function spawnBin(argv: readonly string[], cwd: string, env: NodeJS.ProcessEnv): Spawned {
|
|
25
|
+
const child = spawn(process.execPath, [BIN, ...argv], { cwd, env });
|
|
26
|
+
const result = new Promise<RunResult>((resolve, reject) => {
|
|
27
|
+
let stdout = "";
|
|
28
|
+
let stderr = "";
|
|
29
|
+
child.stdout.setEncoding("utf8").on("data", (chunk: string) => {
|
|
30
|
+
stdout += chunk;
|
|
31
|
+
});
|
|
32
|
+
child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
|
|
33
|
+
stderr += chunk;
|
|
34
|
+
});
|
|
35
|
+
child.on("error", reject);
|
|
36
|
+
child.on("close", (exitCode) => resolve({ stdout, stderr, exitCode: exitCode ?? -1 }));
|
|
37
|
+
});
|
|
38
|
+
return { child, result };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function runBin(argv: readonly string[], cwd: string, env: NodeJS.ProcessEnv): Promise<RunResult> {
|
|
42
|
+
return spawnBin(argv, cwd, env).result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("built links bin", () => {
|
|
46
|
+
test.each([
|
|
47
|
+
[["list"]],
|
|
48
|
+
[["list", "--json"]],
|
|
49
|
+
[["--json", "list"]],
|
|
50
|
+
[["list", "--tag", "docs"]],
|
|
51
|
+
[["lsit"]],
|
|
52
|
+
[["list", "--tags", "docs"]],
|
|
53
|
+
])("%j matches runCli", async (argv) => {
|
|
54
|
+
const cwd = await folderWith(LINKS);
|
|
55
|
+
const env = { ...process.env };
|
|
56
|
+
|
|
57
|
+
const fromBin = await runBin(argv, cwd, env);
|
|
58
|
+
const inProcess = await runCli(cli, argv, { env, cwd });
|
|
59
|
+
|
|
60
|
+
expect(fromBin).toEqual(inProcess);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a corrupt data file fails the same way as runCli", async () => {
|
|
64
|
+
// The bin sees its cwd resolved (macOS: /private/var), so runCli must get the same path.
|
|
65
|
+
const cwd = await realpath(await mkdtemp(join(tmpdir(), "links-bin-")));
|
|
66
|
+
await writeFile(join(cwd, "links.json"), "{ not json");
|
|
67
|
+
const env = { ...process.env };
|
|
68
|
+
|
|
69
|
+
const fromBin = await runBin(["list"], cwd, env);
|
|
70
|
+
const inProcess = await runCli(cli, ["list"], { env, cwd });
|
|
71
|
+
|
|
72
|
+
expect(fromBin).toEqual(inProcess);
|
|
73
|
+
expect(fromBin.exitCode).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("the built bin prints its version, the commit and time it was built, and its changelog", async () => {
|
|
78
|
+
const head = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
79
|
+
const packageRoot = await realpath(fileURLToPath(new URL(".", import.meta.url)));
|
|
80
|
+
|
|
81
|
+
const { stdout, exitCode } = await runBin(["--version"], tmpdir(), { ...process.env });
|
|
82
|
+
|
|
83
|
+
const [, sha, changelog] =
|
|
84
|
+
/^1\.0\.0 \(([0-9a-f]{7,40})(?:\+dirty)?\) built \d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z\nchangelog (.+)\n$/.exec(
|
|
85
|
+
stdout,
|
|
86
|
+
) ?? [];
|
|
87
|
+
expect(sha, stdout).toBeDefined();
|
|
88
|
+
expect(head.startsWith(sha ?? "-")).toBe(true);
|
|
89
|
+
expect(changelog).toBe(join(packageRoot, "CHANGELOG.md"));
|
|
90
|
+
expect(exitCode).toBe(0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// Windows has no SIGINT a parent process can send to a child.
|
|
94
|
+
describe.skipIf(process.platform === "win32")("built links bin on Ctrl-C", () => {
|
|
95
|
+
let server: Server | undefined;
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
server?.closeAllConnections();
|
|
99
|
+
server?.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("one SIGINT while a link is checked ends the run with INTERRUPTED and exit 130", async () => {
|
|
103
|
+
let bin: Spawned | undefined;
|
|
104
|
+
// A link that never answers, so the check is still running when the request arrives.
|
|
105
|
+
server = createServer(() => bin?.child.kill("SIGINT"));
|
|
106
|
+
await new Promise<void>((resolve) => server?.listen(0, "127.0.0.1", resolve));
|
|
107
|
+
const address = server.address();
|
|
108
|
+
if (address === null || typeof address === "string") throw new Error("no port");
|
|
109
|
+
const cwd = await folderWith([{ id: 1, url: `http://127.0.0.1:${address.port}/`, tags: [] }]);
|
|
110
|
+
|
|
111
|
+
bin = spawnBin(["check", "1", "--json"], cwd, { ...process.env, LINKS_TOKEN: "token" });
|
|
112
|
+
const result = await bin.result;
|
|
113
|
+
|
|
114
|
+
expect(result.exitCode).toBe(130);
|
|
115
|
+
expect(result.stdout).toBe("");
|
|
116
|
+
expect(result.stderr).toContain(`"code":"${runtimeCodes.INTERRUPTED}"`);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } 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 bundle", () => {
|
|
9
|
+
test("writes one shortcut file per link into a new folder", async () => {
|
|
10
|
+
const cwd = await folderWith(LINKS);
|
|
11
|
+
|
|
12
|
+
const result = await runCli(cli, ["bundle", "docs", "1", "2"], { cwd });
|
|
13
|
+
|
|
14
|
+
expect(result).toEqual({
|
|
15
|
+
stdout: `Bundled 2 links into ${resolve(cwd, "docs")}\n`,
|
|
16
|
+
stderr: "",
|
|
17
|
+
exitCode: 0,
|
|
18
|
+
});
|
|
19
|
+
expect(await readdir(resolve(cwd, "docs"))).toEqual(["1.url", "2.url"]);
|
|
20
|
+
expect(await readFile(resolve(cwd, "docs", "1.url"), "utf8")).toBe(
|
|
21
|
+
"[InternetShortcut]\r\nURL=https://zod.dev/\r\n",
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("an existing folder refuses with BUNDLE_EXISTS, exit 1, hinting --force", async () => {
|
|
26
|
+
const cwd = await folderWith(LINKS);
|
|
27
|
+
await mkdir(resolve(cwd, "docs"));
|
|
28
|
+
await writeFile(resolve(cwd, "docs", "notes.txt"), "mine\n");
|
|
29
|
+
|
|
30
|
+
const result = await runCli(cli, ["bundle", "docs", "1"], { cwd });
|
|
31
|
+
|
|
32
|
+
expect(result).toEqual({
|
|
33
|
+
stdout: "",
|
|
34
|
+
stderr:
|
|
35
|
+
"error: docs already exists\n" +
|
|
36
|
+
"hint: to replace it and everything in it, rerun with --force\n" +
|
|
37
|
+
"run: links bundle docs 1 --force\n",
|
|
38
|
+
exitCode: 1,
|
|
39
|
+
});
|
|
40
|
+
expect(await readdir(resolve(cwd, "docs"))).toEqual(["notes.txt"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("--force replaces the folder, and its dry run names what it removes", async () => {
|
|
44
|
+
const cwd = await folderWith(LINKS);
|
|
45
|
+
await mkdir(resolve(cwd, "docs"));
|
|
46
|
+
await writeFile(resolve(cwd, "docs", "notes.txt"), "mine\n");
|
|
47
|
+
|
|
48
|
+
const dryRun = await runCli(cli, ["bundle", "docs", "1", "--force", "--dry-run"], {
|
|
49
|
+
cwd,
|
|
50
|
+
});
|
|
51
|
+
const forced = await runCli(cli, ["bundle", "docs", "1", "--force"], { cwd });
|
|
52
|
+
|
|
53
|
+
expect(dryRun.stdout).toBe("Would bundle 1 links into docs, removing notes.txt\n");
|
|
54
|
+
expect(forced.exitCode).toBe(0);
|
|
55
|
+
expect(await readdir(resolve(cwd, "docs"))).toEqual(["1.url"]);
|
|
56
|
+
});
|
|
57
|
+
});
|