@iseoane/agent-depot 0.6.4 → 0.6.5
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 +86 -0
- package/README.es.md +14 -10
- package/README.md +22 -8
- package/dist/src/app-cli.js +10 -5
- package/dist/src/app-flow.js +2 -2
- package/dist/src/atomic-file.js +9 -0
- package/dist/src/cli.js +98 -67
- package/dist/src/executable-resolution.js +9 -3
- package/dist/src/git-source.js +2 -4
- package/dist/src/package-cli.js +9 -4
- package/dist/src/package-flow.js +2 -1
- package/dist/src/package-host-state.js +44 -6
- package/dist/src/package-state.js +2 -5
- package/dist/src/process-runner.js +234 -16
- package/dist/src/profile-cli.js +8 -6
- package/dist/src/progress.js +157 -0
- package/dist/src/source-state.js +2 -5
- package/dist/src/tui/adoption-actions.js +4 -3
- package/dist/src/tui/app-actions.js +16 -9
- package/dist/src/tui/bulk-actions.js +7 -6
- package/dist/src/tui/catalog-flow.js +3 -2
- package/dist/src/tui/catalog-panel.js +2 -2
- package/dist/src/tui/catalog-view.js +26 -6
- package/dist/src/tui/file-picker.js +52 -20
- package/dist/src/tui/installations-view.js +44 -11
- package/dist/src/tui/manage-actions.js +7 -6
- package/dist/src/tui/manage-panel.js +2 -2
- package/dist/src/tui/package-actions.js +63 -31
- package/dist/src/tui/panel-parts.js +20 -0
- package/dist/src/tui/profile-view.js +84 -15
- package/dist/src/tui/sources-mode.js +2 -1
- package/dist/src/tui/sources-recipes.js +22 -10
- package/dist/src/tui/sources-view.js +13 -12
- package/dist/src/tui/text-wrap.js +13 -0
- package/dist/src/tui/unmanaged-actions.js +3 -2
- package/dist/src/tui/updates-flow.js +10 -7
- package/dist/src/tui/updates-keys.js +3 -2
- package/dist/src/tui/updates-model.js +2 -2
- package/dist/src/tui/updates-panel.js +3 -2
- package/dist/src/tui/updates-view.js +6 -3
- package/dist/src/tui/view-state.js +12 -0
- package/dist/src/tui/working-model.js +33 -0
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { lstat, readFile } from "node:fs/promises";
|
|
1
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { canonicalizeGitSourceUrl } from "./git-source.js";
|
|
@@ -246,8 +246,10 @@ async function readPiCheckoutEvidence(checkoutPath, sourceIdentity) {
|
|
|
246
246
|
runPiGit(checkoutPath, ["status", "--porcelain", "--untracked-files=all"]),
|
|
247
247
|
runPiGit(checkoutPath, ["show", "HEAD:package.json"]),
|
|
248
248
|
]);
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
// Git prints a forward-slash, long-name path on Windows (C:/Users/runneradmin/...) while the
|
|
250
|
+
// checkout path may use backslashes or an 8.3 short name (C:\Users\RUNNER~1\...); realpath makes both canonical.
|
|
251
|
+
const [canonicalTopLevel, canonicalCheckout] = await Promise.all([realpath(topLevel), realpath(checkoutPath)]);
|
|
252
|
+
if (canonicalTopLevel !== canonicalCheckout)
|
|
251
253
|
return unavailable("the package path is not the root of its Git checkout");
|
|
252
254
|
if (normalizeRepository(piSourceIdentityFromUrl(origin)) !== normalizeRepository(sourceIdentity)) {
|
|
253
255
|
return unavailable("the Git checkout origin does not match the selected Pi package Source");
|
|
@@ -303,7 +305,7 @@ async function isMatchingRootOnlyNpmLock(checkoutPath, manifest) {
|
|
|
303
305
|
if (manifest[key] !== undefined && (!isRecord(manifest[key]) || Object.keys(manifest[key]).length !== 0))
|
|
304
306
|
return false;
|
|
305
307
|
}
|
|
306
|
-
const allowed = ["name", "version", "license", "peerDependencies", "peerDependenciesMeta"];
|
|
308
|
+
const allowed = ["name", "version", "license", "peerDependencies", "peerDependenciesMeta", "bin"];
|
|
307
309
|
const equalJson = (left, right) => {
|
|
308
310
|
if (left === right)
|
|
309
311
|
return true;
|
|
@@ -313,13 +315,49 @@ async function isMatchingRootOnlyNpmLock(checkoutPath, manifest) {
|
|
|
313
315
|
return keys.length === Object.keys(right).length
|
|
314
316
|
&& keys.every((key) => Object.hasOwn(right, key) && equalJson(left[key], right[key]));
|
|
315
317
|
};
|
|
316
|
-
|
|
317
|
-
|
|
318
|
+
// npm records `bin` in its normalized form, so compare against that, not the raw manifest value.
|
|
319
|
+
const expectedBin = normalizeNpmBin(manifest);
|
|
320
|
+
return Object.keys(root).every((key) => allowed.includes(key)
|
|
321
|
+
&& equalJson(root[key], key === "bin" ? expectedBin : manifest[key]))
|
|
322
|
+
&& ["peerDependencies", "peerDependenciesMeta"].every((key) => equalJson(root[key], manifest[key]))
|
|
323
|
+
&& equalJson(root.bin, expectedBin);
|
|
318
324
|
}
|
|
319
325
|
catch {
|
|
320
326
|
return false;
|
|
321
327
|
}
|
|
322
328
|
}
|
|
329
|
+
/**
|
|
330
|
+
* npm's `bin` normalization for the string and object forms (npm-normalize-package-bin): a string
|
|
331
|
+
* becomes `{ [unscoped name]: path }` and every target loses a leading `./`. Verified against
|
|
332
|
+
* `npm install --package-lock-only` (npm 12). Array and malformed forms yield `undefined`, so a
|
|
333
|
+
* lock that carries `bin` for them is rejected instead of guessed.
|
|
334
|
+
*/
|
|
335
|
+
function normalizeNpmBin(manifest) {
|
|
336
|
+
const raw = manifest.bin;
|
|
337
|
+
let entries;
|
|
338
|
+
if (typeof raw === "string") {
|
|
339
|
+
if (typeof manifest.name !== "string")
|
|
340
|
+
return undefined;
|
|
341
|
+
entries = { [manifest.name.replace(/^@[^/]+\//u, "")]: raw };
|
|
342
|
+
}
|
|
343
|
+
else if (isRecord(raw)) {
|
|
344
|
+
entries = raw;
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
return undefined;
|
|
348
|
+
}
|
|
349
|
+
const clean = {};
|
|
350
|
+
for (const [key, target] of Object.entries(entries)) {
|
|
351
|
+
const name = path.posix.basename(key.replace(/[\\:]/gu, "/"));
|
|
352
|
+
if (typeof target !== "string" || name === "" || name === "." || name === "..")
|
|
353
|
+
return undefined;
|
|
354
|
+
const normalizedTarget = path.posix.join("/", target.replace(/\\/gu, "/")).slice(1);
|
|
355
|
+
if (normalizedTarget === "")
|
|
356
|
+
return undefined;
|
|
357
|
+
clean[name] = normalizedTarget;
|
|
358
|
+
}
|
|
359
|
+
return Object.keys(clean).length === 0 ? undefined : clean;
|
|
360
|
+
}
|
|
323
361
|
async function runPiGit(checkoutPath, args) {
|
|
324
362
|
const result = await runProcess("git", ["-C", checkoutPath, ...args], {
|
|
325
363
|
captureStdout: true,
|
|
@@ -2,7 +2,7 @@ import { chmod, mkdir, readFile, rmdir } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { writeFileAtomically } from "./atomic-file.js";
|
|
5
|
+
import { isLockContention, writeFileAtomically } from "./atomic-file.js";
|
|
6
6
|
import { PACKAGE_HOSTS, selectionKey, } from "./package-model.js";
|
|
7
7
|
import { PROJECT_MANIFEST_VERSION, ProjectManifestError, parseProjectManifest } from "./project-manifest.js";
|
|
8
8
|
import { defaultSourceStatePath } from "./source-state.js";
|
|
@@ -385,7 +385,7 @@ export class PackageStateStore {
|
|
|
385
385
|
};
|
|
386
386
|
}
|
|
387
387
|
catch (error) {
|
|
388
|
-
if (!
|
|
388
|
+
if (!isLockContention(error)) {
|
|
389
389
|
throw error;
|
|
390
390
|
}
|
|
391
391
|
if (Date.now() >= deadline) {
|
|
@@ -399,6 +399,3 @@ export class PackageStateStore {
|
|
|
399
399
|
function isMissingFile(error) {
|
|
400
400
|
return typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT";
|
|
401
401
|
}
|
|
402
|
-
function isAlreadyExists(error) {
|
|
403
|
-
return typeof error === "object" && error !== null && "code" in error && error.code === "EEXIST";
|
|
404
|
-
}
|
|
@@ -1,11 +1,164 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
-
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { StringDecoder } from "node:string_decoder";
|
|
5
|
+
const childOutputSinks = new AsyncLocalStorage();
|
|
6
|
+
/**
|
|
7
|
+
* Runs `work` so that every child started by `runProcess` inside it (however deep, whichever runner
|
|
8
|
+
* a caller injected) also reports its output to `sink`. This is a read-only tap: capture, caps,
|
|
9
|
+
* timeouts and results behave exactly as without a sink.
|
|
10
|
+
*/
|
|
11
|
+
export function withChildOutput(sink, work) {
|
|
12
|
+
return childOutputSinks.run(sink, work);
|
|
13
|
+
}
|
|
14
|
+
/** cmd.exe metacharacters, escaped with `^` (same set as cross-spawn). */
|
|
15
|
+
const CMD_META_CHARACTERS = /([()\][%!^"`<>&|;, *?])/gu;
|
|
16
|
+
/**
|
|
17
|
+
* Characters that could chain commands or split the line if an npm cmd-shim's
|
|
18
|
+
* `%*` re-parse flips quote parity; rejecting them prevents injection, though an
|
|
19
|
+
* argument with `"` can still reach the program altered.
|
|
20
|
+
*/
|
|
21
|
+
const CMD_UNSAFE_ARGUMENT = /[\r\n&|<>]/u;
|
|
22
|
+
/** Quotes one argument for the MSVCRT parser: backslashes double only before a quote or the closing quote. */
|
|
23
|
+
function quoteMsvcrtArgument(argument) {
|
|
24
|
+
let encoded = "";
|
|
25
|
+
let backslashes = 0;
|
|
26
|
+
for (const character of argument) {
|
|
27
|
+
if (character === "\\") {
|
|
28
|
+
backslashes += 1;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
encoded += character === '"'
|
|
32
|
+
? `${"\\".repeat(backslashes * 2 + 1)}"`
|
|
33
|
+
: `${"\\".repeat(backslashes)}${character}`;
|
|
34
|
+
backslashes = 0;
|
|
35
|
+
}
|
|
36
|
+
return `"${encoded}${"\\".repeat(backslashes * 2)}"`;
|
|
37
|
+
}
|
|
38
|
+
function escapeCmdArgument(argument, index) {
|
|
39
|
+
if (CMD_UNSAFE_ARGUMENT.test(argument)) {
|
|
40
|
+
throw Object.assign(new Error(`Argument ${index} cannot be passed safely to a Windows batch file (line break or & | < > character)`), { code: "EINVAL" });
|
|
41
|
+
}
|
|
42
|
+
return quoteMsvcrtArgument(argument).replace(CMD_META_CHARACTERS, "^$1");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Windows cannot spawn a `.cmd`/`.bat` file without a shell (Node rejects it
|
|
46
|
+
* since CVE-2024-27980), so it runs through `cmd.exe /d /s /c` with every
|
|
47
|
+
* argument quoted and escaped; any other command is spawned directly. An
|
|
48
|
+
* argument cmd.exe could misparse is rejected with `EINVAL`.
|
|
49
|
+
*/
|
|
50
|
+
export function spawnInvocation(command, args, platform = process.platform) {
|
|
51
|
+
if (platform !== "win32" || !/\.(?:cmd|bat)$/iu.test(command)) {
|
|
52
|
+
return { command, args, windowsVerbatimArguments: false };
|
|
53
|
+
}
|
|
54
|
+
const line = [path.win32.normalize(command).replace(CMD_META_CHARACTERS, "^$1"), ...args.map(escapeCmdArgument)].join(" ");
|
|
55
|
+
return { command: process.env.ComSpec || "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], windowsVerbatimArguments: true };
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* How long `runProcess` waits after asking a process (tree) to stop before it escalates (POSIX: SIGKILL)
|
|
59
|
+
* or, once nothing more can be done, gives up on the stdio pipes a surviving descendant holds open.
|
|
60
|
+
*/
|
|
61
|
+
const KILL_GRACE_MS = 2_000;
|
|
62
|
+
/** Upper bound for the `ps` snapshot of the process tree. */
|
|
63
|
+
const PROCESS_LIST_TIMEOUT_MS = 1_000;
|
|
64
|
+
/**
|
|
65
|
+
* Terminates a Windows child and everything it started. `child.kill()` ends
|
|
66
|
+
* only the direct child, so for a batch file it would stop cmd.exe and leave
|
|
67
|
+
* the real program running with the stdio pipes open; a program that starts
|
|
68
|
+
* helpers has the same problem. Falls back to
|
|
69
|
+
* `child.kill()` when taskkill cannot be started or fails (for example because
|
|
70
|
+
* the child already exited). Never throws and does not wait for taskkill.
|
|
71
|
+
*/
|
|
72
|
+
function killWindowsProcessTree(child) {
|
|
73
|
+
const { pid } = child;
|
|
74
|
+
if (pid === undefined) {
|
|
75
|
+
child.kill();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const taskkill = path.win32.join(process.env.SystemRoot || process.env.windir || "C:\\Windows", "System32", "taskkill.exe");
|
|
79
|
+
try {
|
|
80
|
+
spawn(taskkill, ["/pid", String(pid), "/T", "/F"], { shell: false, stdio: "ignore", windowsHide: true })
|
|
81
|
+
.on("error", () => { child.kill(); })
|
|
82
|
+
.on("close", (code) => { if (code !== 0)
|
|
83
|
+
child.kill(); });
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
child.kill();
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The pids of `rootPid` and every process descended from it, from a single `ps` snapshot. The child
|
|
91
|
+
* stays in agent-depot's own process group and session (a detached group has no controlling terminal,
|
|
92
|
+
* so a git or ssh prompt would fail, and a terminal Ctrl-C would no longer reach it), so a group kill
|
|
93
|
+
* is not available. Never rejects: falls back to `rootPid` alone when `ps` cannot be run or read.
|
|
94
|
+
*/
|
|
95
|
+
function listProcessTree(rootPid) {
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
const tree = [rootPid];
|
|
98
|
+
let ps;
|
|
99
|
+
try {
|
|
100
|
+
ps = spawn("ps", ["-A", "-o", "pid=,ppid="], { stdio: ["ignore", "pipe", "ignore"], timeout: PROCESS_LIST_TIMEOUT_MS });
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
resolve(tree);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
let output = "";
|
|
107
|
+
ps.stdout?.setEncoding("utf8");
|
|
108
|
+
ps.stdout?.on("data", (chunk) => { output += chunk; });
|
|
109
|
+
// A missing `ps` is followed by `close` with no output, which leaves just the root.
|
|
110
|
+
ps.on("error", () => { });
|
|
111
|
+
ps.on("close", () => {
|
|
112
|
+
const children = new Map();
|
|
113
|
+
for (const line of output.split("\n")) {
|
|
114
|
+
const [pid, parent] = line.trim().split(/\s+/u).map(Number);
|
|
115
|
+
if (pid === undefined || parent === undefined || !Number.isInteger(pid) || !Number.isInteger(parent))
|
|
116
|
+
continue;
|
|
117
|
+
children.set(parent, [...(children.get(parent) ?? []), pid]);
|
|
118
|
+
}
|
|
119
|
+
for (const pid of tree) {
|
|
120
|
+
for (const child of children.get(pid) ?? [])
|
|
121
|
+
if (!tree.includes(child))
|
|
122
|
+
tree.push(child);
|
|
123
|
+
}
|
|
124
|
+
resolve(tree);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
function signalProcesses(pids, signal) {
|
|
129
|
+
for (const pid of pids) {
|
|
130
|
+
try {
|
|
131
|
+
process.kill(pid, signal);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// Already gone.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Runs a command with an argument vector and no shell (except cmd.exe for
|
|
140
|
+
* Windows batch files); rejects only when spawning fails. For every command, a timeout or output overflow kills the whole process tree and reports
|
|
141
|
+
* it as terminated by `SIGTERM`, and the result is returned within a bounded
|
|
142
|
+
* time even if a descendant survives and holds the stdio pipes open. On POSIX the
|
|
143
|
+
* tree is the child and its descendants at the moment of the kill (from a `ps`
|
|
144
|
+
* snapshot, because the child shares agent-depot's process group): they get
|
|
145
|
+
* `SIGTERM`, and if the command has still not closed its output after a grace
|
|
146
|
+
* period, the members of that snapshot still running get `SIGKILL`.
|
|
147
|
+
* A process that left the tree beforehand (daemonized, or orphaned by an exited
|
|
148
|
+
* child) is not killed. On Windows, an argument to a `.cmd`/`.bat` file containing a line
|
|
149
|
+
* break or `& | < >` is rejected with `EINVAL` because cmd.exe cannot carry it
|
|
150
|
+
* safely.
|
|
151
|
+
*/
|
|
3
152
|
export function runProcess(command, args, options = {}) {
|
|
4
153
|
return new Promise((resolve, reject) => {
|
|
5
|
-
const
|
|
154
|
+
const invocation = spawnInvocation(command, args);
|
|
155
|
+
const sink = childOutputSinks.getStore();
|
|
156
|
+
const child = spawn(invocation.command, [...invocation.args], {
|
|
6
157
|
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
7
158
|
shell: false,
|
|
8
|
-
|
|
159
|
+
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
160
|
+
// Without a capture, stdout is piped only for a sink, and then discarded after it is shown.
|
|
161
|
+
stdio: ["ignore", options.captureStdout || sink !== undefined ? "pipe" : "ignore", "pipe"],
|
|
9
162
|
});
|
|
10
163
|
const stdout = [];
|
|
11
164
|
let stdoutBytes = 0;
|
|
@@ -13,22 +166,91 @@ export function runProcess(command, args, options = {}) {
|
|
|
13
166
|
let stderrBytes = 0;
|
|
14
167
|
let outputTooLarge = false;
|
|
15
168
|
let timedOut = false;
|
|
169
|
+
let settled = false;
|
|
170
|
+
let terminating = false;
|
|
171
|
+
let killedByRunner = false;
|
|
172
|
+
let graceTimer;
|
|
173
|
+
let posixTree = [];
|
|
174
|
+
const isRunning = () => child.exitCode === null && child.signalCode === null;
|
|
175
|
+
const settle = (code, signal) => {
|
|
176
|
+
if (settled)
|
|
177
|
+
return;
|
|
178
|
+
settled = true;
|
|
179
|
+
// taskkill ends the process with exit code 1 and no signal, and an escalated kill ends with SIGKILL; report both like child.kill() does, so callers see a terminated process.
|
|
180
|
+
if (killedByRunner && (signal === null || signal === "SIGKILL")) {
|
|
181
|
+
code = null;
|
|
182
|
+
signal = "SIGTERM";
|
|
183
|
+
}
|
|
184
|
+
if (timeout !== undefined)
|
|
185
|
+
clearTimeout(timeout);
|
|
186
|
+
if (graceTimer !== undefined)
|
|
187
|
+
clearTimeout(graceTimer);
|
|
188
|
+
resolve({ code, signal, stdout: Buffer.concat(stdout), stderr, outputTooLarge, ...(timedOut ? { timedOut: true } : {}) });
|
|
189
|
+
};
|
|
190
|
+
// A surviving descendant keeps the pipes open, so `close` may never come: give up on them.
|
|
191
|
+
const giveUp = () => {
|
|
192
|
+
child.stdout?.destroy();
|
|
193
|
+
child.stderr?.destroy();
|
|
194
|
+
settle(child.exitCode, child.signalCode);
|
|
195
|
+
};
|
|
196
|
+
const terminate = () => {
|
|
197
|
+
if (terminating)
|
|
198
|
+
return;
|
|
199
|
+
terminating = true;
|
|
200
|
+
// A child that already exited must not be killed: its pid may have been reused.
|
|
201
|
+
const running = isRunning();
|
|
202
|
+
if (running)
|
|
203
|
+
killedByRunner = true;
|
|
204
|
+
if (process.platform === "win32") {
|
|
205
|
+
if (running)
|
|
206
|
+
killWindowsProcessTree(child);
|
|
207
|
+
graceTimer = setTimeout(giveUp, KILL_GRACE_MS);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (child.pid === undefined)
|
|
211
|
+
return; // never spawned: `error` has already rejected
|
|
212
|
+
if (!running) {
|
|
213
|
+
graceTimer = setTimeout(giveUp, KILL_GRACE_MS);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
posixTree = [child.pid];
|
|
217
|
+
// The child's own pid is left alone once it has exited, as it may have been reused.
|
|
218
|
+
const alive = (pids) => pids.filter((pid) => pid !== child.pid || isRunning());
|
|
219
|
+
// Snapshot before signalling: once the child dies its descendants are reparented and can no longer be found.
|
|
220
|
+
listProcessTree(child.pid).then((tree) => {
|
|
221
|
+
posixTree = tree;
|
|
222
|
+
signalProcesses(alive(tree), "SIGTERM");
|
|
223
|
+
}, () => {
|
|
224
|
+
signalProcesses(alive(posixTree), "SIGTERM");
|
|
225
|
+
});
|
|
226
|
+
graceTimer = setTimeout(() => {
|
|
227
|
+
signalProcesses(alive(posixTree), "SIGKILL");
|
|
228
|
+
graceTimer = setTimeout(giveUp, KILL_GRACE_MS);
|
|
229
|
+
}, KILL_GRACE_MS);
|
|
230
|
+
};
|
|
16
231
|
const timeout = options.timeoutMs === undefined ? undefined : setTimeout(() => {
|
|
17
232
|
timedOut = true;
|
|
18
|
-
|
|
233
|
+
terminate();
|
|
19
234
|
}, options.timeoutMs);
|
|
20
235
|
timeout?.unref();
|
|
236
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
237
|
+
const showStdout = sink !== undefined && (!options.captureStdout || options.streamStdout === true);
|
|
21
238
|
child.stdout?.on("data", (chunk) => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
239
|
+
if (options.captureStdout) {
|
|
240
|
+
stdoutBytes += chunk.length;
|
|
241
|
+
if (options.maxOutputBytes !== undefined && stdoutBytes > options.maxOutputBytes) {
|
|
242
|
+
outputTooLarge = true;
|
|
243
|
+
terminate();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
stdout.push(chunk);
|
|
27
247
|
}
|
|
28
|
-
|
|
248
|
+
if (showStdout)
|
|
249
|
+
sink(stdoutDecoder.write(chunk), "stdout");
|
|
29
250
|
});
|
|
30
251
|
child.stderr?.setEncoding("utf8");
|
|
31
252
|
child.stderr?.on("data", (chunk) => {
|
|
253
|
+
sink?.(chunk, "stderr");
|
|
32
254
|
const bytes = Buffer.from(chunk);
|
|
33
255
|
const remaining = options.maxStderrBytes === undefined
|
|
34
256
|
? bytes.length : Math.max(0, options.maxStderrBytes - stderrBytes);
|
|
@@ -36,14 +258,10 @@ export function runProcess(command, args, options = {}) {
|
|
|
36
258
|
stderr += bytes.subarray(0, remaining).toString("utf8");
|
|
37
259
|
if (options.maxStderrBytes !== undefined && stderrBytes > options.maxStderrBytes) {
|
|
38
260
|
outputTooLarge = true;
|
|
39
|
-
|
|
261
|
+
terminate();
|
|
40
262
|
}
|
|
41
263
|
});
|
|
42
264
|
child.on("error", reject);
|
|
43
|
-
child.on("close",
|
|
44
|
-
if (timeout !== undefined)
|
|
45
|
-
clearTimeout(timeout);
|
|
46
|
-
resolve({ code, signal, stdout: Buffer.concat(stdout), stderr, outputTooLarge, ...(timedOut ? { timedOut: true } : {}) });
|
|
47
|
-
});
|
|
265
|
+
child.on("close", settle);
|
|
48
266
|
});
|
|
49
267
|
}
|
package/dist/src/profile-cli.js
CHANGED
|
@@ -2,16 +2,17 @@ import { readFile } from "node:fs/promises";
|
|
|
2
2
|
import { buildProfileExport, requireProfileExportSelection, writeProfileExport } from "./profile-export.js";
|
|
3
3
|
import { applyProfileImport, buildProfileImport } from "./profile-import.js";
|
|
4
4
|
import { serializeProfile } from "./profile.js";
|
|
5
|
+
import { NO_PROGRESS } from "./progress.js";
|
|
5
6
|
import { CliUsageError } from "./usage-error.js";
|
|
6
7
|
export const EXPORT_USAGE = "agent-depot export [--out <file>] [--no-sources] [--no-skills] [--no-apps] [--no-packages] [--source <id|url>...] [--skill <path|name>...] [--app <name>...] [--package <selection>...]";
|
|
7
8
|
export const IMPORT_USAGE = "agent-depot import <file> [--yes] [--no-sources] [--no-skills] [--no-apps] [--no-packages] [--source <id|url>...] [--skill <path|name>...] [--app <name>...] [--package <selection>...]";
|
|
8
|
-
export async function runProfileExport(values, operations, apps, output, diagnostic, homeDirectory, packageOperations) {
|
|
9
|
+
export async function runProfileExport(values, operations, apps, output, diagnostic, homeDirectory, packageOperations, progress = NO_PROGRESS) {
|
|
9
10
|
const { filters, out } = parseProfileOptions(values, false);
|
|
10
|
-
const plan = await buildProfileExport(operations, apps, {
|
|
11
|
+
const plan = await progress.run("Building Profile export", () => buildProfileExport(operations, apps, {
|
|
11
12
|
...filters,
|
|
12
13
|
homeDirectory,
|
|
13
14
|
...(packageOperations === undefined ? {} : { packageOperations }),
|
|
14
|
-
});
|
|
15
|
+
}));
|
|
15
16
|
for (const line of plan.preview)
|
|
16
17
|
diagnostic(line);
|
|
17
18
|
requireProfileExportSelection(plan.profile);
|
|
@@ -67,7 +68,7 @@ function parseProfileOptions(values, importing) {
|
|
|
67
68
|
}
|
|
68
69
|
return { filters, out, confirmed };
|
|
69
70
|
}
|
|
70
|
-
export async function runProfileImport(values, operations, apps, output, environment = {}) {
|
|
71
|
+
export async function runProfileImport(values, operations, apps, output, environment = {}, progress = NO_PROGRESS) {
|
|
71
72
|
const file = values[0];
|
|
72
73
|
if (!file || file.startsWith("--"))
|
|
73
74
|
throw new CliUsageError(`Usage: ${IMPORT_USAGE}`);
|
|
@@ -80,14 +81,15 @@ export async function runProfileImport(values, operations, apps, output, environ
|
|
|
80
81
|
catch (error) {
|
|
81
82
|
throw new Error(`${file}: invalid JSON (${error instanceof Error ? error.message : String(error)})`);
|
|
82
83
|
}
|
|
83
|
-
const plan = await buildProfileImport(input, operations, apps, filters, environment.packageOperations);
|
|
84
|
+
const plan = await progress.run("Building import preview", () => buildProfileImport(input, operations, apps, filters, environment.packageOperations));
|
|
84
85
|
for (const line of plan.preview)
|
|
85
86
|
output(line);
|
|
86
87
|
if (!confirmed) {
|
|
87
88
|
output("Import not confirmed; rerun with --yes. Nothing was written.");
|
|
88
89
|
return 0;
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
+
// Skill and Package items refresh their Git Sources, and git may ask for credentials on the terminal.
|
|
92
|
+
const results = await progress.run("Applying import", () => applyProfileImport(plan, operations, apps, confirmed, output, environment), { mayPrompt: true });
|
|
91
93
|
for (const result of results)
|
|
92
94
|
output(`${result.status}: ${result.item.label}${result.detail ? `; ${result.detail}` : ""}`);
|
|
93
95
|
return results.some(result => result.status === "failed") ? 1 : 0;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import stringWidth from "string-width";
|
|
2
|
+
import { animationEnabled, formatElapsed, SPINNER_FRAMES, SPINNER_TICK_MS, STATIC_GLYPH } from "./tui/working-model.js";
|
|
3
|
+
/** Progress that shows nothing, for embedders and tests that capture output themselves. */
|
|
4
|
+
export const NO_PROGRESS = { run: (_label, work) => work(), clear: () => undefined };
|
|
5
|
+
const ERASE_LINE = "\r\u001b[2K";
|
|
6
|
+
/** Cuts `text` to under `width` terminal columns so a redraw never wraps and cursor movement stays exact. */
|
|
7
|
+
function fit(text, width) {
|
|
8
|
+
if (width === undefined || width < 2 || stringWidth(text) < width)
|
|
9
|
+
return text;
|
|
10
|
+
let kept = "";
|
|
11
|
+
let used = 0;
|
|
12
|
+
for (const character of text) {
|
|
13
|
+
const columns = stringWidth(character);
|
|
14
|
+
if (used + columns > width - 2)
|
|
15
|
+
break;
|
|
16
|
+
kept += character;
|
|
17
|
+
used += columns;
|
|
18
|
+
}
|
|
19
|
+
return `${kept}…`;
|
|
20
|
+
}
|
|
21
|
+
/** How many child output lines the spinner shows under its own line. */
|
|
22
|
+
const TAIL_LINES = 3;
|
|
23
|
+
/** A child line longer than this without a line break is shown as it is, so a runaway line cannot grow memory. */
|
|
24
|
+
const MAX_PENDING_LINE = 4096;
|
|
25
|
+
// eslint-disable-next-line no-control-regex
|
|
26
|
+
const TERMINAL_ESCAPES = /\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007\u001b]*(?:\u0007|\u001b\\)|[@-Z\\-_])/gu;
|
|
27
|
+
// eslint-disable-next-line no-control-regex
|
|
28
|
+
const CONTROL_CHARACTERS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu;
|
|
29
|
+
/**
|
|
30
|
+
* Child output is untrusted terminal input: an escape sequence would move the cursor and break the
|
|
31
|
+
* redraw, and a `\r` progress bar (npm, git) should show only its latest state.
|
|
32
|
+
*/
|
|
33
|
+
function displayLine(raw) {
|
|
34
|
+
const afterReturn = raw.replace(/\r$/u, "").split("\r").at(-1) ?? "";
|
|
35
|
+
return afterReturn.replace(TERMINAL_ESCAPES, "").replaceAll("\t", " ").replace(CONTROL_CHARACTERS, "").trimEnd();
|
|
36
|
+
}
|
|
37
|
+
/** Turns chunks of each output stream into complete display lines; `flush` emits what is left unterminated. */
|
|
38
|
+
function lineSplitter(onLine) {
|
|
39
|
+
const pending = { stdout: "", stderr: "" };
|
|
40
|
+
const emit = (raw) => {
|
|
41
|
+
const line = displayLine(raw);
|
|
42
|
+
if (line !== "")
|
|
43
|
+
onLine(line);
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
write(text, stream) {
|
|
47
|
+
const parts = (pending[stream] + text).split("\n");
|
|
48
|
+
pending[stream] = parts.pop() ?? "";
|
|
49
|
+
for (const part of parts)
|
|
50
|
+
emit(part);
|
|
51
|
+
if (pending[stream].length > MAX_PENDING_LINE) {
|
|
52
|
+
emit(pending[stream]);
|
|
53
|
+
pending[stream] = "";
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
flush() {
|
|
57
|
+
for (const stream of ["stdout", "stderr"]) {
|
|
58
|
+
emit(pending[stream]);
|
|
59
|
+
pending[stream] = "";
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** Moves to the first row of a frame of `rows` rows and erases it, so a frame with a tail leaves nothing behind. */
|
|
65
|
+
function eraseFrame(rows) {
|
|
66
|
+
return rows <= 1 ? ERASE_LINE : `\u001b[${rows - 1}A\r\u001b[J`;
|
|
67
|
+
}
|
|
68
|
+
function withEllipsis(label) {
|
|
69
|
+
return label.endsWith("…") ? label : `${label}…`;
|
|
70
|
+
}
|
|
71
|
+
/** Creates the progress reporter for one CLI run. */
|
|
72
|
+
export function createProgress(options = {}) {
|
|
73
|
+
const stream = options.stream ?? process.stderr;
|
|
74
|
+
const env = options.env ?? process.env;
|
|
75
|
+
const now = options.now ?? Date.now;
|
|
76
|
+
const timers = options.timers ?? {
|
|
77
|
+
setInterval: (callback, ms) => setInterval(callback, ms),
|
|
78
|
+
clearInterval: (timer) => clearInterval(timer),
|
|
79
|
+
};
|
|
80
|
+
const tap = options.tapChildOutput;
|
|
81
|
+
const verbose = options.verbose === true;
|
|
82
|
+
let active = false;
|
|
83
|
+
let drawnRows = 0;
|
|
84
|
+
function clear() {
|
|
85
|
+
if (drawnRows === 0)
|
|
86
|
+
return;
|
|
87
|
+
const rows = drawnRows;
|
|
88
|
+
drawnRows = 0;
|
|
89
|
+
stream.write(eraseFrame(rows));
|
|
90
|
+
}
|
|
91
|
+
/** Runs `work` while its child output goes to `splitter`; the tap goes silent as soon as the step ends. */
|
|
92
|
+
async function showChildOutput(work, splitter) {
|
|
93
|
+
if (tap === undefined)
|
|
94
|
+
return work();
|
|
95
|
+
let live = true;
|
|
96
|
+
try {
|
|
97
|
+
return await tap((text, source) => { if (live)
|
|
98
|
+
splitter.write(text, source); }, work);
|
|
99
|
+
}
|
|
100
|
+
finally {
|
|
101
|
+
live = false;
|
|
102
|
+
splitter.flush();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
async function run(label, work, runOptions = {}) {
|
|
106
|
+
if (active)
|
|
107
|
+
return work();
|
|
108
|
+
const text = withEllipsis(label);
|
|
109
|
+
if (verbose || stream.isTTY !== true || !animationEnabled(env) || runOptions.mayPrompt === true) {
|
|
110
|
+
// Not animated: one persistent line, so logs and pipes stay readable and nothing needs erasing.
|
|
111
|
+
stream.write(`${stream.isTTY === true ? `${STATIC_GLYPH} ` : ""}${text}\n`);
|
|
112
|
+
active = true;
|
|
113
|
+
try {
|
|
114
|
+
// Verbose output is plain lines, so it is also safe next to a prompt; otherwise child output stays hidden.
|
|
115
|
+
return verbose ? await showChildOutput(work, lineSplitter((line) => stream.write(`${line}\n`))) : await work();
|
|
116
|
+
}
|
|
117
|
+
finally {
|
|
118
|
+
active = false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const startedAt = now();
|
|
122
|
+
let tick = 0;
|
|
123
|
+
const tail = [];
|
|
124
|
+
// A terminal too short for the tail keeps the single-line spinner rather than scrolling.
|
|
125
|
+
const room = stream.rows === undefined || stream.rows >= TAIL_LINES + 2;
|
|
126
|
+
const splitter = lineSplitter((line) => {
|
|
127
|
+
if (!room)
|
|
128
|
+
return;
|
|
129
|
+
tail.push(line);
|
|
130
|
+
if (tail.length > TAIL_LINES)
|
|
131
|
+
tail.shift();
|
|
132
|
+
});
|
|
133
|
+
const draw = () => {
|
|
134
|
+
const elapsed = formatElapsed(now() - startedAt);
|
|
135
|
+
const line = `${SPINNER_FRAMES[tick % SPINNER_FRAMES.length]} ${text}${elapsed === "" ? "" : ` ${elapsed}`}`;
|
|
136
|
+
tick += 1;
|
|
137
|
+
const frame = [line, ...tail.map((child) => ` ${child}`)].map((row) => fit(row, stream.columns));
|
|
138
|
+
const previousRows = drawnRows;
|
|
139
|
+
drawnRows = frame.length;
|
|
140
|
+
stream.write(`${eraseFrame(previousRows)}${frame.join("\n")}`);
|
|
141
|
+
};
|
|
142
|
+
active = true;
|
|
143
|
+
draw();
|
|
144
|
+
const timer = timers.setInterval(draw, SPINNER_TICK_MS);
|
|
145
|
+
// A redraw timer alone must never keep the process alive.
|
|
146
|
+
timer.unref();
|
|
147
|
+
try {
|
|
148
|
+
return await showChildOutput(work, splitter);
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
timers.clearInterval(timer);
|
|
152
|
+
active = false;
|
|
153
|
+
clear();
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { run, clear };
|
|
157
|
+
}
|
package/dist/src/source-state.js
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdir, readFile, rmdir, unlink } from "node:fs/promises";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
|
-
import { writeFileAtomically } from "./atomic-file.js";
|
|
5
|
+
import { isLockContention, writeFileAtomically } from "./atomic-file.js";
|
|
6
6
|
import { canonicalizeGitSourceUrl, sourceIdForUrl } from "./git-source.js";
|
|
7
7
|
import { parseProjectManifest } from "./project-manifest.js";
|
|
8
8
|
const EMPTY_STATE = Object.freeze({
|
|
@@ -218,7 +218,7 @@ export class SourceStateStore {
|
|
|
218
218
|
};
|
|
219
219
|
}
|
|
220
220
|
catch (error) {
|
|
221
|
-
if (!
|
|
221
|
+
if (!isLockContention(error)) {
|
|
222
222
|
throw error;
|
|
223
223
|
}
|
|
224
224
|
if (Date.now() >= deadline) {
|
|
@@ -235,6 +235,3 @@ function errorCode(error) {
|
|
|
235
235
|
function isMissingFile(error) {
|
|
236
236
|
return errorCode(error) === "ENOENT";
|
|
237
237
|
}
|
|
238
|
-
function isAlreadyExists(error) {
|
|
239
|
-
return errorCode(error) === "EEXIST";
|
|
240
|
-
}
|
|
@@ -3,6 +3,7 @@ import { runInstall } from "./catalog-installs.js";
|
|
|
3
3
|
import { errorText } from "./batch.js";
|
|
4
4
|
import { defaultAdoptionHosts, findAdoptionCandidates, prepareAdoption } from "./installations.js";
|
|
5
5
|
import { answerYesNo, applyChecklistOutcome, hostChecklistKey, moveCursor, versionInputKey } from "./mode-keys.js";
|
|
6
|
+
import { busy } from "./working-model.js";
|
|
6
7
|
function cancel(context) {
|
|
7
8
|
context.setMessage({ kind: "ok", text: "Adoption cancelled" });
|
|
8
9
|
context.browse();
|
|
@@ -22,7 +23,7 @@ export async function startAdoption(context, group) {
|
|
|
22
23
|
return;
|
|
23
24
|
}
|
|
24
25
|
context.setMessage(undefined);
|
|
25
|
-
context.setMode(
|
|
26
|
+
context.setMode(busy(`Looking for a Source Skill named ${entry.name}…`));
|
|
26
27
|
try {
|
|
27
28
|
const candidates = await findAdoptionCandidates(context.operations, entry);
|
|
28
29
|
if (!context.isMounted())
|
|
@@ -43,7 +44,7 @@ export async function startAdoption(context, group) {
|
|
|
43
44
|
}
|
|
44
45
|
}
|
|
45
46
|
async function prepare(context, mode, version) {
|
|
46
|
-
context.setMode(
|
|
47
|
+
context.setMode(busy(`Refreshing source and checking that ${mode.entry.name} matches…`));
|
|
47
48
|
try {
|
|
48
49
|
const adoption = await prepareAdoption(context.operations, context.env, mode.entry, mode.skill, { hosts: mode.hosts, version });
|
|
49
50
|
if (!context.isMounted())
|
|
@@ -55,7 +56,7 @@ async function prepare(context, mode, version) {
|
|
|
55
56
|
}
|
|
56
57
|
}
|
|
57
58
|
async function confirm(context, mode, exposure) {
|
|
58
|
-
context.setMode(
|
|
59
|
+
context.setMode(busy(`Adopting ${mode.entry.name}…`));
|
|
59
60
|
let result;
|
|
60
61
|
try {
|
|
61
62
|
result = { kind: "ok", text: (await runInstall(mode.adoption.prepared, context.operations, exposure)).join(" ") };
|