@iseoane/agent-depot 0.6.13 → 0.6.15
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 +61 -0
- package/README.md +3 -1
- package/dist/src/app-flow.js +8 -1
- package/dist/src/atomic-file.js +29 -3
- package/dist/src/cli.js +2 -0
- package/dist/src/flow-progress.js +9 -0
- package/dist/src/git-source.js +257 -15
- package/dist/src/preview-model.js +37 -0
- package/dist/src/process-runner.js +1 -0
- package/dist/src/profile-cli.js +12 -4
- package/dist/src/profile-export.js +23 -3
- package/dist/src/skill-update.js +1 -0
- package/dist/src/source-refresh-session.js +139 -0
- package/dist/src/sources.js +4 -4
- package/dist/src/tui/adoption-actions.js +21 -2
- package/dist/src/tui/app-actions.js +57 -47
- package/dist/src/tui/app.js +22 -4
- package/dist/src/tui/bulk-actions.js +45 -11
- package/dist/src/tui/catalog-actions.js +12 -0
- package/dist/src/tui/catalog-flow.js +31 -11
- package/dist/src/tui/catalog-panel.js +15 -13
- package/dist/src/tui/catalog-tree.js +7 -2
- package/dist/src/tui/catalog-view.js +12 -6
- package/dist/src/tui/file-picker.js +2 -1
- package/dist/src/tui/installations-browse.js +1 -1
- package/dist/src/tui/installations-panel.js +12 -4
- package/dist/src/tui/installations-rows.js +2 -2
- package/dist/src/tui/installations-tree.js +1 -1
- package/dist/src/tui/installations-view.js +16 -7
- package/dist/src/tui/keys.js +35 -4
- package/dist/src/tui/manage-actions.js +55 -27
- package/dist/src/tui/manage-panel.js +15 -29
- package/dist/src/tui/mode-keys.js +11 -1
- package/dist/src/tui/package-actions.js +25 -18
- package/dist/src/tui/package-rows.js +9 -4
- package/dist/src/tui/panel-parts.js +54 -37
- package/dist/src/tui/preview-render.js +93 -0
- package/dist/src/tui/preview-window.js +185 -20
- package/dist/src/tui/profile-view.js +54 -44
- package/dist/src/tui/render.js +13 -1
- package/dist/src/tui/safe-text.js +25 -0
- package/dist/src/tui/sources-mode.js +2 -2
- package/dist/src/tui/sources-recipes.js +53 -32
- package/dist/src/tui/sources-view.js +85 -39
- package/dist/src/tui/text-wrap.js +47 -3
- package/dist/src/tui/unmanaged-actions.js +17 -5
- package/dist/src/tui/updates-flow.js +11 -5
- package/dist/src/tui/updates-keys.js +2 -2
- package/dist/src/tui/updates-model.js +5 -2
- package/dist/src/tui/updates-panel.js +10 -9
- package/dist/src/tui/updates-preview.js +27 -44
- package/dist/src/tui/updates-view.js +15 -11
- package/dist/src/tui/updates.js +117 -33
- package/dist/src/update-flow.js +31 -15
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.15] - 2026-11-12
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- Sources marks a refreshed Source as `✓ refreshed` without the elapsed time;
|
|
15
|
+
`… refreshing Ns` keeps its counter (#123).
|
|
16
|
+
- Progress lines share one format; a one-item batch no longer shows `(1/1)` (#123).
|
|
17
|
+
- Update previews say "No updates can be applied" when nothing applies, empty
|
|
18
|
+
previews no longer show "Review 1 of 0", and commit-only Package versions read
|
|
19
|
+
`commit xxxxxxx` everywhere (#124).
|
|
20
|
+
- Wrapped preview rows keep their indent and step guide; long titles are shortened
|
|
21
|
+
so the confirmation question always stays on screen (#125).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Installations marks a tracked App whose recipe changed as needing approval and
|
|
26
|
+
Enter opens the recipe review; an invalid recipe shows its reason (#127).
|
|
27
|
+
- A Source cache lock left by a process that died is recovered safely, and a busy
|
|
28
|
+
lock names its holder (#120, #122).
|
|
29
|
+
- Escapes typed in text can no longer be cut so they look like real ones, uppercase
|
|
30
|
+
`\X`/`\U` are handled, and quoted paths mark invisible characters (#128).
|
|
31
|
+
- State files are flushed to disk before and after replacing them, so a power cut
|
|
32
|
+
does not leave them empty (#129).
|
|
33
|
+
|
|
34
|
+
### Changed (development)
|
|
35
|
+
|
|
36
|
+
- The TUI start refresh is an injected option; confirmation and file-picker tests no
|
|
37
|
+
longer depend on terminal size, and review coverage gaps are closed (#119, #121, #126).
|
|
38
|
+
|
|
39
|
+
## [0.6.14] - 2026-11-12
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- The TUI names the item being installed or updated, with its position in the
|
|
44
|
+
batch (for example `Updating Skill tdd (2/5)…`); the elapsed time keeps running
|
|
45
|
+
across items and manual App steps (#104).
|
|
46
|
+
- The Sources view marks each Source as not refreshed, refreshing, refreshed or
|
|
47
|
+
failed. On start the TUI refreshes registered Git Sources in the background
|
|
48
|
+
(no prompts, 60 s limit), and previews reuse a successful refresh from the last
|
|
49
|
+
hour; `r` always refreshes again (#105).
|
|
50
|
+
- Exporting a Profile to an existing file offers to overwrite it after an explicit
|
|
51
|
+
confirmation; the CLI needs `profile export --out <file> --overwrite` (#107).
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- Every TUI confirmation uses a shared footer that shows reading progress and what
|
|
56
|
+
`y` will do; `y` is ignored until the preview has been read to the end
|
|
57
|
+
(#106, #111, #112, #113, #114).
|
|
58
|
+
- The update batch preview starts with a summary and the collected warnings, then
|
|
59
|
+
groups Skills, Apps and Packages with version arrows and `$ command` lines (#108).
|
|
60
|
+
- App recipe reviews and App confirmations show a header and one block per step
|
|
61
|
+
(#109, #110).
|
|
62
|
+
|
|
63
|
+
### Security
|
|
64
|
+
|
|
65
|
+
- A `y` that arrives together with other keys (pasted text, held arrows or Page
|
|
66
|
+
Down) never confirms; it must be pressed on its own (#115).
|
|
67
|
+
- The TUI escapes control and format characters from recipes, Sources, Packages and
|
|
68
|
+
Host output in every view, so a remote `SKILL.md` or recipe can no longer send
|
|
69
|
+
terminal escape sequences or disguise a command (#116, #117).
|
|
70
|
+
|
|
10
71
|
## [0.6.13] - 2026-11-11
|
|
11
72
|
|
|
12
73
|
### Fixed
|
package/README.md
CHANGED
|
@@ -971,7 +971,9 @@ independently. Unknown selections and combining a block's inclusion filter with
|
|
|
971
971
|
`--no-*` flag are errors.
|
|
972
972
|
|
|
973
973
|
Stdout contains JSON only; stderr shows the preview and exclusion reasons.
|
|
974
|
-
`--out` requires a new file
|
|
974
|
+
`--out` requires a new file unless `--overwrite` is given, which atomically replaces an
|
|
975
|
+
existing regular file; directories and symlinks are never replaced. Overwriting creates a
|
|
976
|
+
new file, so it does not keep the original's permissions, owner or hard links.
|
|
975
977
|
Profiles contain canonical Git URLs with discovery inclusion, individual Skill
|
|
976
978
|
selections with recorded version policies, Hosts and user methods, complete App
|
|
977
979
|
recipes (including recipes for other platforms), and Package selections as
|
package/dist/src/app-flow.js
CHANGED
|
@@ -156,7 +156,14 @@ export function createAppOperations(environment = {}) {
|
|
|
156
156
|
if (!entry.applicable)
|
|
157
157
|
return { status: "not applicable here" };
|
|
158
158
|
if (cached?.installedVersion) {
|
|
159
|
-
|
|
159
|
+
const approval = await approvalStatus(entry);
|
|
160
|
+
if (approval.status === "invalid") {
|
|
161
|
+
return { status: "invalid", installedVersion: cached.installedVersion, evidence: "recorded", reason: approval.reason };
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
status: "installed", installedVersion: cached.installedVersion, evidence: "recorded",
|
|
165
|
+
...(approval.status === "needs approval" ? { needsApproval: true } : {}),
|
|
166
|
+
};
|
|
160
167
|
}
|
|
161
168
|
const approval = await approvalProblem(entry);
|
|
162
169
|
if (approval)
|
package/dist/src/atomic-file.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { rename, unlink, writeFile } from "node:fs/promises";
|
|
2
|
+
import { open, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
3
4
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
5
|
export const WINDOWS_RENAME_ATTEMPTS = 20;
|
|
5
6
|
const WINDOWS_RENAME_RETRY_MS = 5;
|
|
@@ -23,11 +24,35 @@ export async function renameWithWindowsRetry(from, to, deps = { rename, platform
|
|
|
23
24
|
}
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
|
-
/**
|
|
27
|
+
/** Persist a directory entry change; Windows cannot open a directory for fsync. */
|
|
28
|
+
async function syncDirectory(directory) {
|
|
29
|
+
if (process.platform === "win32")
|
|
30
|
+
return;
|
|
31
|
+
const handle = await open(directory, "r");
|
|
32
|
+
try {
|
|
33
|
+
await handle.sync();
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
// Some filesystems do not implement fsync on directories.
|
|
37
|
+
if (!["EINVAL", "ENOTSUP"].includes(error.code ?? ""))
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await handle.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/** Write a unique temporary sibling, fsync it, publish it with rename, then fsync the directory. */
|
|
27
45
|
export async function writeFileAtomically(file, contents, options = {}) {
|
|
28
46
|
const temporaryPath = `${file}.${process.pid}.${randomUUID()}.tmp`;
|
|
29
47
|
try {
|
|
30
|
-
await
|
|
48
|
+
const handle = await open(temporaryPath, "wx", options.mode);
|
|
49
|
+
try {
|
|
50
|
+
await handle.writeFile(contents, "utf8");
|
|
51
|
+
await handle.sync();
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await handle.close();
|
|
55
|
+
}
|
|
31
56
|
try {
|
|
32
57
|
await renameWithWindowsRetry(temporaryPath, file);
|
|
33
58
|
}
|
|
@@ -39,6 +64,7 @@ export async function writeFileAtomically(file, contents, options = {}) {
|
|
|
39
64
|
// Source state holds its read-modify-write lock while using this Windows fallback.
|
|
40
65
|
await writeFile(file, contents, "utf8");
|
|
41
66
|
}
|
|
67
|
+
await syncDirectory(path.dirname(file));
|
|
42
68
|
}
|
|
43
69
|
finally {
|
|
44
70
|
await unlink(temporaryPath).catch((error) => {
|
package/dist/src/cli.js
CHANGED
|
@@ -155,6 +155,8 @@ const COMMANDS = new Map([
|
|
|
155
155
|
// Loaded lazily so non-interactive commands never import Ink/React.
|
|
156
156
|
const renderTui = dependencies.renderTui ?? (await import("./tui/render.js")).renderTui;
|
|
157
157
|
await renderTui(operations, {
|
|
158
|
+
// The one place the opt-out is read: E2E tests start the real binary and cannot inject options.
|
|
159
|
+
refreshOnStart: process.env.AGENT_DEPOT_NO_BACKGROUND_REFRESH !== "1",
|
|
158
160
|
homeDirectory: dependencies.homeDirectory,
|
|
159
161
|
projectRoot,
|
|
160
162
|
sourceAccess: dependencies.sourceAccess,
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const KIND_LABEL = { skill: "Skill", app: "App", package: "Package" };
|
|
2
|
+
/** `Removing demo (2/5)…`: the shared busy label of a batch; a batch of one has no position, and the `…` marks work in progress. */
|
|
3
|
+
export function formatStepLabel(text, position, total) {
|
|
4
|
+
return `${text}${total > 1 ? ` (${position}/${total})` : ""}…`;
|
|
5
|
+
}
|
|
6
|
+
/** `Updating Skill tdd (2/5)…`. */
|
|
7
|
+
export function formatProgressLabel(event) {
|
|
8
|
+
return formatStepLabel(`${event.verb} ${KIND_LABEL[event.kind]} ${event.name}`, event.position, event.total);
|
|
9
|
+
}
|
package/dist/src/git-source.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { lstat, mkdir, rename, rm, rmdir } from "node:fs/promises";
|
|
3
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { link, lstat, mkdir, readFile, rename, rm, rmdir, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir, hostname } from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { isLockContention } from "./atomic-file.js";
|
|
6
6
|
import { assertNoSymlinkPath } from "./path-safety.js";
|
|
@@ -50,7 +50,14 @@ function gitFailureDetail(result) {
|
|
|
50
50
|
}
|
|
51
51
|
class NodeGitCommandRunner {
|
|
52
52
|
async run(command, args, options = {}) {
|
|
53
|
-
const result = await runProcess(command, args,
|
|
53
|
+
const result = await runProcess(command, args, {
|
|
54
|
+
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
55
|
+
...(options.env === undefined ? {} : { env: options.env }),
|
|
56
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
57
|
+
});
|
|
58
|
+
if (result.timedOut) {
|
|
59
|
+
throw new Error(`git command timed out after ${Math.round((options.timeoutMs ?? 0) / 1000)} s`);
|
|
60
|
+
}
|
|
54
61
|
if (result.code !== 0) {
|
|
55
62
|
throw new Error(`git command failed: ${gitFailureDetail(result)}`);
|
|
56
63
|
}
|
|
@@ -78,7 +85,21 @@ class NodeGitSnapshotCommandRunner {
|
|
|
78
85
|
}
|
|
79
86
|
}
|
|
80
87
|
const SOURCE_LOCK_TIMEOUT_MS = 30_000;
|
|
88
|
+
/** D2 (I0031): nobody is watching a background refresh, so it must neither prompt nor hang. */
|
|
89
|
+
const BACKGROUND_REFRESH_OPTIONS = Object.freeze({
|
|
90
|
+
env: Object.freeze({ GIT_TERMINAL_PROMPT: "0" }),
|
|
91
|
+
timeoutMs: 60_000,
|
|
92
|
+
});
|
|
81
93
|
const SOURCE_LOCK_RETRY_MS = 10;
|
|
94
|
+
const SOURCE_LOCK_OWNER_FILE = "owner.json";
|
|
95
|
+
const SOURCE_LOCK_OWNER_MAX_BYTES = 4096;
|
|
96
|
+
const SOURCE_RECOVERY_MUTEX_ID_FILE = "id";
|
|
97
|
+
const LOCK_REMOVAL_ATTEMPTS = 5;
|
|
98
|
+
const LOCK_REMOVAL_RETRY_MS = 50;
|
|
99
|
+
/** A recovery takes milliseconds, so a mutex this old was left by a process that died mid-recovery. */
|
|
100
|
+
const SOURCE_RECOVERY_MUTEX_MAX_AGE_MS = 60_000;
|
|
101
|
+
/** Covers locks from versions without an owner record and an acquisition interrupted before writing it. */
|
|
102
|
+
const SOURCE_LOCK_OWNERLESS_MAX_AGE_MS = 10 * 60_000;
|
|
82
103
|
/**
|
|
83
104
|
* Clones each Source into a local mirror and refreshes it from the registered
|
|
84
105
|
* URL. Mirrors are intentionally not checked out: discovery owns later reads.
|
|
@@ -86,11 +107,13 @@ const SOURCE_LOCK_RETRY_MS = 10;
|
|
|
86
107
|
export class GitSourceAccessAdapter {
|
|
87
108
|
cachePath;
|
|
88
109
|
runner;
|
|
110
|
+
lockHooks;
|
|
89
111
|
constructor(options = {}) {
|
|
112
|
+
this.lockHooks = options.lockHooks ?? {};
|
|
90
113
|
this.cachePath = options.cachePath ?? defaultGitSourceCachePath();
|
|
91
114
|
this.runner = options.runner ?? new NodeGitCommandRunner();
|
|
92
115
|
}
|
|
93
|
-
async refresh(source) {
|
|
116
|
+
async refresh(source, options = {}) {
|
|
94
117
|
const expectedId = sourceIdForUrl(source.url);
|
|
95
118
|
if (source.kind !== "git" || source.id !== expectedId) {
|
|
96
119
|
throw new GitSourceAccessError(source, "the Source identity does not match its registered URL");
|
|
@@ -100,12 +123,14 @@ export class GitSourceAccessAdapter {
|
|
|
100
123
|
await assertNoSymlinkPath(this.cachePath, "cache path");
|
|
101
124
|
await mkdir(this.cachePath, { recursive: true });
|
|
102
125
|
await assertNoSymlinkPath(this.cachePath, "cache path");
|
|
103
|
-
const
|
|
126
|
+
const lock = await acquireSourceLock(`${destination}.lock`, this.lockHooks);
|
|
104
127
|
try {
|
|
105
|
-
await this.
|
|
128
|
+
await this.lockHooks.afterLockAcquired?.();
|
|
129
|
+
await lock.assertHeld();
|
|
130
|
+
await this.refreshLocked(source, destination, options.background ? BACKGROUND_REFRESH_OPTIONS : undefined);
|
|
106
131
|
}
|
|
107
132
|
finally {
|
|
108
|
-
await release();
|
|
133
|
+
await lock.release();
|
|
109
134
|
}
|
|
110
135
|
}
|
|
111
136
|
catch (error) {
|
|
@@ -116,7 +141,7 @@ export class GitSourceAccessAdapter {
|
|
|
116
141
|
throw new GitSourceAccessError(source, reason);
|
|
117
142
|
}
|
|
118
143
|
}
|
|
119
|
-
async refreshLocked(source, destination) {
|
|
144
|
+
async refreshLocked(source, destination, runOptions) {
|
|
120
145
|
const existing = await pathType(destination);
|
|
121
146
|
if (existing === "symlink") {
|
|
122
147
|
throw new Error("the Source cache path is a symbolic link");
|
|
@@ -136,12 +161,12 @@ export class GitSourceAccessAdapter {
|
|
|
136
161
|
"--force",
|
|
137
162
|
gitSourceLocation(source.url).repositoryUrl,
|
|
138
163
|
"+refs/*:refs/*",
|
|
139
|
-
]);
|
|
164
|
+
], runOptions);
|
|
140
165
|
return;
|
|
141
166
|
}
|
|
142
167
|
const temporaryDestination = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
143
168
|
try {
|
|
144
|
-
await this.runner.run("git", ["clone", "--mirror", gitSourceLocation(source.url).repositoryUrl, temporaryDestination]);
|
|
169
|
+
await this.runner.run("git", ["clone", "--mirror", gitSourceLocation(source.url).repositoryUrl, temporaryDestination], runOptions);
|
|
145
170
|
await assertNoSymlinkPath(temporaryDestination, "cache path");
|
|
146
171
|
if ((await pathType(temporaryDestination)) !== "directory") {
|
|
147
172
|
throw new Error("Git clone did not create a directory mirror");
|
|
@@ -429,14 +454,60 @@ async function pathType(candidate) {
|
|
|
429
454
|
throw error;
|
|
430
455
|
}
|
|
431
456
|
}
|
|
432
|
-
|
|
457
|
+
const LOCK_TAKEN_OVER_MESSAGE = "the Source cache lock was taken over by another process; this refresh stopped without touching the cache";
|
|
458
|
+
/** Identifies a directory across its lifetime; a recreated path gets a new inode or birth time. */
|
|
459
|
+
async function directoryIdentity(directory) {
|
|
460
|
+
try {
|
|
461
|
+
const { dev, ino, birthtimeNs } = await lstat(directory, { bigint: true });
|
|
462
|
+
return `${dev}:${ino}:${birthtimeNs}`;
|
|
463
|
+
}
|
|
464
|
+
catch (error) {
|
|
465
|
+
if (isMissing(error)) {
|
|
466
|
+
return undefined;
|
|
467
|
+
}
|
|
468
|
+
throw error;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Removes `directory` if `stillOurs` holds, then retries a failed removal only on that same directory.
|
|
473
|
+
* `rm` deletes the owner record before the directory, so after a partial failure ownership can no longer
|
|
474
|
+
* be re-read; identity stands in for it, and a successor's directory recreated at the path never matches.
|
|
475
|
+
* `rm`'s own retry option would repeat the removal on the path blindly instead. Windows reports EBUSY,
|
|
476
|
+
* EPERM or ENOTEMPTY while another process still holds a handle on something inside, so only those are retried.
|
|
477
|
+
* Residual limit: the checks and the removal are separate calls (the filesystem has no atomic conditional
|
|
478
|
+
* remove), so a successor that takes the path inside that window, microseconds wide, is still removed;
|
|
479
|
+
* two reclaimers of an aged-out recovery mutex can race the same way.
|
|
480
|
+
*/
|
|
481
|
+
async function removeWhileOurs(directory, stillOurs, hooks) {
|
|
482
|
+
if (!(await stillOurs())) {
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
const identity = await directoryIdentity(directory);
|
|
486
|
+
for (let attempt = 1;; attempt += 1) {
|
|
487
|
+
try {
|
|
488
|
+
// rm unlinks links instead of following them, so nothing outside the directory is touched.
|
|
489
|
+
await (hooks.removeDirectory ?? ((target) => rm(target, { recursive: true, force: true })))(directory);
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
catch (error) {
|
|
493
|
+
const transient = ["EBUSY", "EPERM", "ENOTEMPTY"].includes(error.code ?? "");
|
|
494
|
+
if (!transient || attempt >= LOCK_REMOVAL_ATTEMPTS) {
|
|
495
|
+
throw error;
|
|
496
|
+
}
|
|
497
|
+
await new Promise((resolve) => setTimeout(resolve, LOCK_REMOVAL_RETRY_MS));
|
|
498
|
+
if (identity === undefined || (await directoryIdentity(directory)) !== identity) {
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
async function acquireSourceLock(lockPath, hooks) {
|
|
433
505
|
const deadline = Date.now() + SOURCE_LOCK_TIMEOUT_MS;
|
|
434
506
|
while (true) {
|
|
435
507
|
try {
|
|
436
508
|
await mkdir(lockPath);
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
};
|
|
509
|
+
await hooks.afterLockCreated?.();
|
|
510
|
+
return await publishLockOwner(lockPath, hooks);
|
|
440
511
|
}
|
|
441
512
|
catch (error) {
|
|
442
513
|
if (!isLockContention(error)) {
|
|
@@ -445,13 +516,184 @@ async function acquireSourceLock(lockPath) {
|
|
|
445
516
|
if ((await pathType(lockPath)) === "symlink") {
|
|
446
517
|
throw new Error("the Source cache lock is a symbolic link");
|
|
447
518
|
}
|
|
519
|
+
if (await recoverOrphanedLock(lockPath, hooks)) {
|
|
520
|
+
continue;
|
|
521
|
+
}
|
|
448
522
|
if (Date.now() >= deadline) {
|
|
449
|
-
|
|
523
|
+
const record = await readLockOwner(lockPath);
|
|
524
|
+
const holder = record.kind === "owner"
|
|
525
|
+
? ` (held by process ${record.owner.pid} on host ${record.owner.hostname})`
|
|
526
|
+
: record.kind === "none"
|
|
527
|
+
? ` (held by an unknown process: it has no owner record and will be recovered once it is ${SOURCE_LOCK_OWNERLESS_MAX_AGE_MS / 60_000} minutes old)`
|
|
528
|
+
: "";
|
|
529
|
+
throw new Error(`the Source cache lock directory is busy: ${lockPath}${holder}; another refresh might be active. Wait for it to finish before retrying`);
|
|
450
530
|
}
|
|
451
531
|
await new Promise((resolve) => setTimeout(resolve, SOURCE_LOCK_RETRY_MS));
|
|
452
532
|
}
|
|
453
533
|
}
|
|
454
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* Publishes the owner record without ever replacing one: a holder that stalled and lost its lock
|
|
537
|
+
* would otherwise overwrite its successor's record and let both believe they hold it.
|
|
538
|
+
* A hard link fails with EEXIST when the record exists. Where links are unsupported, an exclusive
|
|
539
|
+
* create gives the same no-overwrite guarantee, but a reader can briefly see a partial record,
|
|
540
|
+
* which it treats as unreadable and so never as a reason to remove the lock.
|
|
541
|
+
*/
|
|
542
|
+
async function publishLockOwner(lockPath, hooks) {
|
|
543
|
+
const owner = { pid: process.pid, hostname: hostname(), startedAt: new Date().toISOString(), id: randomUUID() };
|
|
544
|
+
const ownerPath = path.join(lockPath, SOURCE_LOCK_OWNER_FILE);
|
|
545
|
+
const temporaryPath = `${ownerPath}.${owner.id}.tmp`;
|
|
546
|
+
const contents = JSON.stringify(owner);
|
|
547
|
+
try {
|
|
548
|
+
await writeFile(temporaryPath, contents, { encoding: "utf8", flag: "wx" });
|
|
549
|
+
try {
|
|
550
|
+
await link(temporaryPath, ownerPath);
|
|
551
|
+
}
|
|
552
|
+
catch (error) {
|
|
553
|
+
if (!["ENOSYS", "ENOTSUP", "EOPNOTSUPP", "EPERM"].includes(error.code ?? "")) {
|
|
554
|
+
throw error;
|
|
555
|
+
}
|
|
556
|
+
await writeFile(ownerPath, contents, { encoding: "utf8", flag: "wx" });
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
561
|
+
if (error.code === "EEXIST") {
|
|
562
|
+
throw new Error(LOCK_TAKEN_OVER_MESSAGE);
|
|
563
|
+
}
|
|
564
|
+
if (error.code === "ENOENT") {
|
|
565
|
+
// The directory this call created is gone: the lock was recovered, and a successor may already hold a new one.
|
|
566
|
+
throw new Error(LOCK_TAKEN_OVER_MESSAGE);
|
|
567
|
+
}
|
|
568
|
+
// rmdir only removes an empty directory: ours, or a successor's before it published, whose own write then
|
|
569
|
+
// reports the takeover. A successor with an owner record is never empty.
|
|
570
|
+
await rmdir(lockPath).catch(() => undefined);
|
|
571
|
+
throw new Error(`could not record the Source cache lock owner: ${error instanceof Error ? error.message : String(error)}`);
|
|
572
|
+
}
|
|
573
|
+
await unlink(temporaryPath).catch(() => undefined);
|
|
574
|
+
return {
|
|
575
|
+
async assertHeld() {
|
|
576
|
+
const record = await readLockOwner(lockPath);
|
|
577
|
+
if (record.kind !== "owner" || record.owner.id !== owner.id) {
|
|
578
|
+
throw new Error(LOCK_TAKEN_OVER_MESSAGE);
|
|
579
|
+
}
|
|
580
|
+
},
|
|
581
|
+
async release() {
|
|
582
|
+
await removeWhileOurs(lockPath, async () => {
|
|
583
|
+
const record = await readLockOwner(lockPath);
|
|
584
|
+
return record.kind === "owner" && record.owner.id === owner.id;
|
|
585
|
+
}, hooks);
|
|
586
|
+
},
|
|
587
|
+
};
|
|
588
|
+
}
|
|
589
|
+
/** Reads the owner record without following links. */
|
|
590
|
+
async function readLockOwner(lockPath) {
|
|
591
|
+
const ownerPath = path.join(lockPath, SOURCE_LOCK_OWNER_FILE);
|
|
592
|
+
try {
|
|
593
|
+
const information = await lstat(ownerPath);
|
|
594
|
+
if (!information.isFile() || information.size > SOURCE_LOCK_OWNER_MAX_BYTES) {
|
|
595
|
+
return { kind: "unreadable" };
|
|
596
|
+
}
|
|
597
|
+
const parsed = JSON.parse(await readFile(ownerPath, "utf8"));
|
|
598
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
599
|
+
return { kind: "unreadable" };
|
|
600
|
+
}
|
|
601
|
+
const { pid, hostname: host, startedAt, id } = parsed;
|
|
602
|
+
return Number.isInteger(pid) && pid > 0 && typeof host === "string" && typeof startedAt === "string" && typeof id === "string"
|
|
603
|
+
? { kind: "owner", owner: { pid: pid, hostname: host, startedAt, id } }
|
|
604
|
+
: { kind: "unreadable" };
|
|
605
|
+
}
|
|
606
|
+
catch (error) {
|
|
607
|
+
return isMissing(error) ? { kind: "none" } : { kind: "unreadable" };
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function isProcessAlive(pid) {
|
|
611
|
+
try {
|
|
612
|
+
process.kill(pid, 0);
|
|
613
|
+
return true;
|
|
614
|
+
}
|
|
615
|
+
catch (error) {
|
|
616
|
+
// EPERM means the process exists but belongs to someone else.
|
|
617
|
+
return error.code !== "ESRCH";
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
async function isOldLock(lockPath, maxAgeMs) {
|
|
621
|
+
try {
|
|
622
|
+
return Date.now() - (await lstat(lockPath)).mtimeMs > maxAgeMs;
|
|
623
|
+
}
|
|
624
|
+
catch {
|
|
625
|
+
return false;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
/** The orphan a recoverer judged: a dead owner on this host (by id) or an old lock with no owner record. */
|
|
629
|
+
async function judgeOrphan(lockPath) {
|
|
630
|
+
const record = await readLockOwner(lockPath);
|
|
631
|
+
if (record.kind === "owner") {
|
|
632
|
+
return record.owner.hostname === hostname() && !isProcessAlive(record.owner.pid) ? record : undefined;
|
|
633
|
+
}
|
|
634
|
+
return record.kind === "none" && await isOldLock(lockPath, SOURCE_LOCK_OWNERLESS_MAX_AGE_MS) ? record : undefined;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Removes a lock whose holder is provably gone. Recoverers serialize on a short-lived `.recovering`
|
|
638
|
+
* mutex and re-judge the lock in place while holding it, so a judgment made before another process
|
|
639
|
+
* recovered and re-took the lock can never remove (or move) that live lock: nothing but a recoverer
|
|
640
|
+
* removes a lock whose owner is dead, and acquirers cannot replace a lock that still exists.
|
|
641
|
+
* Returns true when the caller should retry the acquisition at once.
|
|
642
|
+
*/
|
|
643
|
+
async function recoverOrphanedLock(lockPath, hooks) {
|
|
644
|
+
const judged = await judgeOrphan(lockPath);
|
|
645
|
+
if (judged === undefined) {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
await hooks.afterJudging?.();
|
|
649
|
+
const mutexPath = `${lockPath}.recovering`;
|
|
650
|
+
try {
|
|
651
|
+
await mkdir(mutexPath);
|
|
652
|
+
}
|
|
653
|
+
catch (error) {
|
|
654
|
+
if (!isLockContention(error)) {
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
// A recoverer that died holding the mutex would otherwise block recovery for good.
|
|
658
|
+
if (await isOldLock(mutexPath, SOURCE_RECOVERY_MUTEX_MAX_AGE_MS)) {
|
|
659
|
+
await removeWhileOurs(mutexPath, () => isOldLock(mutexPath, SOURCE_RECOVERY_MUTEX_MAX_AGE_MS), hooks);
|
|
660
|
+
return true;
|
|
661
|
+
}
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
const mutexId = randomUUID();
|
|
665
|
+
const mutexIdPath = path.join(mutexPath, SOURCE_RECOVERY_MUTEX_ID_FILE);
|
|
666
|
+
const ownsMutex = async () => (await readFile(mutexIdPath, "utf8").catch(() => undefined)) === mutexId;
|
|
667
|
+
try {
|
|
668
|
+
await writeFile(mutexIdPath, mutexId, { encoding: "utf8", flag: "wx" });
|
|
669
|
+
}
|
|
670
|
+
catch (error) {
|
|
671
|
+
// The mutex was reclaimed before it was identified; give up this recovery attempt.
|
|
672
|
+
if (!["EEXIST", "ENOENT"].includes(error.code ?? "")) {
|
|
673
|
+
await rmdir(mutexPath).catch(() => undefined);
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
const isJudgedOrphan = async () => {
|
|
680
|
+
const current = await judgeOrphan(lockPath);
|
|
681
|
+
return current !== undefined && (current.kind === "owner"
|
|
682
|
+
? judged.kind === "owner" && judged.owner.id === current.owner.id
|
|
683
|
+
: judged.kind === "none");
|
|
684
|
+
};
|
|
685
|
+
if (await isJudgedOrphan()) {
|
|
686
|
+
await hooks.beforeOrphanRemoval?.();
|
|
687
|
+
// A recoverer paused past the mutex's age limit may have had the mutex reclaimed and the lock re-taken,
|
|
688
|
+
// so the mutex and the orphan are re-checked before every removal attempt.
|
|
689
|
+
await removeWhileOurs(lockPath, async () => await ownsMutex() && await isJudgedOrphan(), hooks);
|
|
690
|
+
}
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
finally {
|
|
694
|
+
await removeWhileOurs(mutexPath, ownsMutex, hooks);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
455
697
|
export function validateGitRef(candidate) {
|
|
456
698
|
if (typeof candidate !== "string" || candidate.length === 0 || candidate.trim() !== candidate ||
|
|
457
699
|
Array.from(candidate).some((character) => (character.codePointAt(0) ?? 0) <= 0x20) ||
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Anything outside this set could be read differently by a shell, so the argument is shown quoted.
|
|
2
|
+
const PLAIN_ARGUMENT = /^[\w@%+=:,./-]+$/u;
|
|
3
|
+
// What the TUI does not escape itself: spaces other than U+0020, default-ignorable code points that are not control or format, and
|
|
4
|
+
// the zero-width joiner (kept in ordinary text for emoji).
|
|
5
|
+
const NOT_ESCAPED_BY_TUI = /(?! )(?![\p{Cc}\p{Cf}])[\p{Zs}\p{Default_Ignorable_Code_Point}]|\u200D/gu;
|
|
6
|
+
// JSON.stringify leaves these raw; shown as is, a right-to-left override, zero-width space or no-break space can make one argument
|
|
7
|
+
// look like another. A plain space stays: inside the quotes it is what it looks like.
|
|
8
|
+
const INVISIBLE = /(?! )[\p{Cc}\p{Cf}\p{Zs}\p{Default_Ignorable_Code_Point}\u2028\u2029]/gu;
|
|
9
|
+
function quoted(argument, terminal) {
|
|
10
|
+
// The TUI escapes control and format characters once, when it draws; escaping them here too would show a real RLO as typed text.
|
|
11
|
+
if (terminal)
|
|
12
|
+
return quoteText(argument);
|
|
13
|
+
return JSON.stringify(argument).replace(INVISIBLE, escapeCharacter);
|
|
14
|
+
}
|
|
15
|
+
function escapeCharacter(character) {
|
|
16
|
+
const code = character.codePointAt(0).toString(16);
|
|
17
|
+
return code.length > 4 ? `\\u{${code}}` : `\\u${code.padStart(4, "0")}`;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* `text` in double quotes with quotes and backslashes escaped. Control and format characters stay as they are for the TUI to escape;
|
|
21
|
+
* spaces other than U+0020 and ignorable characters are written out, as in `commandLine`.
|
|
22
|
+
*/
|
|
23
|
+
export function quoteText(text) {
|
|
24
|
+
return `"${text.replace(/[\\"]/gu, "\\$&").replace(NOT_ESCAPED_BY_TUI, escapeCharacter)}"`;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The argv as one shell-like line: arguments with spaces, quotes, shell metacharacters or invisible characters are JSON-quoted.
|
|
28
|
+
* With `terminal`, control and format characters are left for the TUI's own escaping.
|
|
29
|
+
*/
|
|
30
|
+
export function commandLine(argv, options = {}) {
|
|
31
|
+
const terminal = options.terminal === true;
|
|
32
|
+
return argv.map((argument) => PLAIN_ARGUMENT.test(argument) ? argument : quoted(argument, terminal)).join(" ");
|
|
33
|
+
}
|
|
34
|
+
/** Every warning row of the item, in order. */
|
|
35
|
+
export function itemWarnings(item) {
|
|
36
|
+
return item.rows.flatMap((row) => row.kind === "warning" ? [row.text] : []);
|
|
37
|
+
}
|
|
@@ -159,6 +159,7 @@ export function runProcess(command, args, options = {}) {
|
|
|
159
159
|
const sink = childOutputSinks.getStore();
|
|
160
160
|
const child = spawn(invocation.command, [...invocation.args], {
|
|
161
161
|
...(options.cwd === undefined ? {} : { cwd: options.cwd }),
|
|
162
|
+
...(options.env === undefined ? {} : { env: { ...process.env, ...options.env } }),
|
|
162
163
|
shell: false,
|
|
163
164
|
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
|
|
164
165
|
// Without a capture, stdout is piped only for a sink, and then discarded after it is shown.
|
package/dist/src/profile-cli.js
CHANGED
|
@@ -4,10 +4,10 @@ import { applyProfileImport, buildProfileImport } from "./profile-import.js";
|
|
|
4
4
|
import { serializeProfile } from "./profile.js";
|
|
5
5
|
import { NO_PROGRESS } from "./progress.js";
|
|
6
6
|
import { CliUsageError } from "./usage-error.js";
|
|
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
|
+
export const EXPORT_USAGE = "agent-depot export [--out <file> [--overwrite]] [--no-sources] [--no-skills] [--no-apps] [--no-packages] [--source <id|url>...] [--skill <path|name>...] [--app <name>...] [--package <selection>...]";
|
|
8
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>...]";
|
|
9
9
|
export async function runProfileExport(values, operations, apps, output, diagnostic, homeDirectory, packageOperations, progress = NO_PROGRESS) {
|
|
10
|
-
const { filters, out } = parseProfileOptions(values, false);
|
|
10
|
+
const { filters, out, overwrite } = parseProfileOptions(values, false);
|
|
11
11
|
const plan = await progress.run("Building Profile export", () => buildProfileExport(operations, apps, {
|
|
12
12
|
...filters,
|
|
13
13
|
homeDirectory,
|
|
@@ -20,7 +20,7 @@ export async function runProfileExport(values, operations, apps, output, diagnos
|
|
|
20
20
|
if (out === undefined)
|
|
21
21
|
output(content.trimEnd());
|
|
22
22
|
else {
|
|
23
|
-
await writeProfileExport(out, plan.profile);
|
|
23
|
+
await writeProfileExport(out, plan.profile, { overwrite });
|
|
24
24
|
diagnostic(`Exported profile to ${out}`);
|
|
25
25
|
}
|
|
26
26
|
return 0;
|
|
@@ -30,6 +30,7 @@ function parseProfileOptions(values, importing) {
|
|
|
30
30
|
const filters = { sources, skills, apps: appNames, packages: packageNames };
|
|
31
31
|
let out;
|
|
32
32
|
let confirmed = false;
|
|
33
|
+
let overwrite = false;
|
|
33
34
|
for (let index = 0; index < values.length; index++) {
|
|
34
35
|
const option = values[index];
|
|
35
36
|
if (option === "--yes" && importing) {
|
|
@@ -37,6 +38,11 @@ function parseProfileOptions(values, importing) {
|
|
|
37
38
|
throw new CliUsageError("Duplicate --yes");
|
|
38
39
|
confirmed = true;
|
|
39
40
|
}
|
|
41
|
+
else if (option === "--overwrite" && !importing) {
|
|
42
|
+
if (overwrite)
|
|
43
|
+
throw new CliUsageError("Duplicate --overwrite");
|
|
44
|
+
overwrite = true;
|
|
45
|
+
}
|
|
40
46
|
else if (option === "--no-sources")
|
|
41
47
|
filters.noSources = true;
|
|
42
48
|
else if (option === "--no-skills")
|
|
@@ -66,7 +72,9 @@ function parseProfileOptions(values, importing) {
|
|
|
66
72
|
else
|
|
67
73
|
throw new CliUsageError(`Usage: ${importing ? IMPORT_USAGE : EXPORT_USAGE}`);
|
|
68
74
|
}
|
|
69
|
-
|
|
75
|
+
if (overwrite && out === undefined)
|
|
76
|
+
throw new CliUsageError("--overwrite requires --out");
|
|
77
|
+
return { filters, out, confirmed, overwrite };
|
|
70
78
|
}
|
|
71
79
|
export async function runProfileImport(values, operations, apps, output, environment = {}, progress = NO_PROGRESS) {
|
|
72
80
|
const file = values[0];
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { writeFile } from "node:fs/promises";
|
|
1
|
+
import { lstat, writeFile } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { loadAppOwnedSkillFilter } from "./app-owned-skills.js";
|
|
4
4
|
import { sourceIdForUrl } from "./git-source.js";
|
|
5
5
|
import { bundleRoot, describeBundleInSource, describeCoordinates } from "./package-model.js";
|
|
6
|
+
import { writeFileAtomically } from "./atomic-file.js";
|
|
6
7
|
import { AGENT_DEPOT_PACKAGE_VERSION } from "./project-manifest.js";
|
|
7
8
|
import { parseProfile, PROFILE_FORMAT, PROFILE_FORMAT_V2, serializeProfile } from "./profile.js";
|
|
8
9
|
import { formatVersionPolicy } from "./skill-install.js";
|
|
@@ -219,9 +220,28 @@ export function requireProfileExportSelection(profile) {
|
|
|
219
220
|
throw new Error("Nothing selected; nothing written.");
|
|
220
221
|
}
|
|
221
222
|
}
|
|
222
|
-
/**
|
|
223
|
-
export async function
|
|
223
|
+
/** Rejects an existing destination that is not a regular file, such as a directory or symlink. */
|
|
224
|
+
export async function requireOverwritableProfileExport(file) {
|
|
225
|
+
try {
|
|
226
|
+
if (!(await lstat(file)).isFile())
|
|
227
|
+
throw new Error(`${file} is not a regular file; choose another path`);
|
|
228
|
+
}
|
|
229
|
+
catch (error) {
|
|
230
|
+
if (error.code !== "ENOENT")
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Exclusively create an export artifact unless `overwrite` explicitly allows atomically replacing a
|
|
236
|
+
* regular file. Neither path follows a symlink: `wx` refuses it and the atomic rename replaces the entry.
|
|
237
|
+
*/
|
|
238
|
+
export async function writeProfileExport(file, profile, options = {}) {
|
|
224
239
|
requireProfileExportSelection(profile);
|
|
240
|
+
if (options.overwrite) {
|
|
241
|
+
await requireOverwritableProfileExport(file);
|
|
242
|
+
await writeFileAtomically(file, serializeProfile(profile));
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
225
245
|
try {
|
|
226
246
|
await writeFile(file, serializeProfile(profile), { encoding: "utf8", flag: "wx" });
|
|
227
247
|
}
|