@iseoane/agent-depot 0.6.3 → 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.
Files changed (43) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.es.md +14 -10
  3. package/README.md +22 -8
  4. package/dist/src/app-cli.js +10 -5
  5. package/dist/src/app-flow.js +2 -2
  6. package/dist/src/atomic-file.js +9 -0
  7. package/dist/src/cli.js +98 -67
  8. package/dist/src/executable-resolution.js +9 -3
  9. package/dist/src/git-source.js +3 -5
  10. package/dist/src/package-cli.js +9 -4
  11. package/dist/src/package-flow.js +2 -1
  12. package/dist/src/package-host-state.js +44 -6
  13. package/dist/src/package-state.js +2 -5
  14. package/dist/src/process-runner.js +234 -16
  15. package/dist/src/profile-cli.js +8 -6
  16. package/dist/src/progress.js +157 -0
  17. package/dist/src/source-state.js +2 -5
  18. package/dist/src/tui/adoption-actions.js +4 -3
  19. package/dist/src/tui/app-actions.js +16 -9
  20. package/dist/src/tui/bulk-actions.js +7 -6
  21. package/dist/src/tui/catalog-flow.js +3 -2
  22. package/dist/src/tui/catalog-panel.js +2 -2
  23. package/dist/src/tui/catalog-view.js +26 -6
  24. package/dist/src/tui/file-picker.js +52 -20
  25. package/dist/src/tui/installations-view.js +44 -11
  26. package/dist/src/tui/manage-actions.js +7 -6
  27. package/dist/src/tui/manage-panel.js +2 -2
  28. package/dist/src/tui/package-actions.js +63 -31
  29. package/dist/src/tui/panel-parts.js +20 -0
  30. package/dist/src/tui/profile-view.js +84 -15
  31. package/dist/src/tui/sources-mode.js +2 -1
  32. package/dist/src/tui/sources-recipes.js +22 -10
  33. package/dist/src/tui/sources-view.js +13 -12
  34. package/dist/src/tui/text-wrap.js +13 -0
  35. package/dist/src/tui/unmanaged-actions.js +3 -2
  36. package/dist/src/tui/updates-flow.js +10 -7
  37. package/dist/src/tui/updates-keys.js +3 -2
  38. package/dist/src/tui/updates-model.js +2 -2
  39. package/dist/src/tui/updates-panel.js +3 -2
  40. package/dist/src/tui/updates-view.js +6 -3
  41. package/dist/src/tui/view-state.js +12 -0
  42. package/dist/src/tui/working-model.js +33 -0
  43. 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
- const expectedRoot = path.resolve(checkoutPath);
250
- if (path.resolve(topLevel) !== expectedRoot)
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
- return Object.keys(root).every((key) => allowed.includes(key) && equalJson(root[key], manifest[key]))
317
- && ["peerDependencies", "peerDependenciesMeta"].every((key) => equalJson(root[key], manifest[key]));
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 (!isAlreadyExists(error)) {
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
- /** Runs a command with an argument vector and no shell; rejects only when spawning fails. */
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 child = spawn(command, [...args], {
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
- stdio: ["ignore", options.captureStdout ? "pipe" : "ignore", "pipe"],
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
- child.kill();
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
- stdoutBytes += chunk.length;
23
- if (options.maxOutputBytes !== undefined && stdoutBytes > options.maxOutputBytes) {
24
- outputTooLarge = true;
25
- child.kill();
26
- return;
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
- stdout.push(chunk);
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
- child.kill();
261
+ terminate();
40
262
  }
41
263
  });
42
264
  child.on("error", reject);
43
- child.on("close", (code, signal) => {
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
  }
@@ -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
- const results = await applyProfileImport(plan, operations, apps, confirmed, output, environment);
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
+ }
@@ -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 (!isAlreadyExists(error)) {
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({ kind: "busy", label: `Looking for a Source Skill named ${entry.name}...` });
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({ kind: "busy", label: `Refreshing source and checking that ${mode.entry.name} matches...` });
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({ kind: "busy", label: `Adopting ${mode.entry.name}...` });
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(" ") };