@iseoane/agent-depot 0.6.12 → 0.6.14
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 +45 -0
- package/README.md +2 -1
- package/dist/src/flow-progress.js +6 -0
- package/dist/src/git-source.js +18 -6
- package/dist/src/preview-model.js +34 -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/self-update.js +9 -2
- 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 +54 -45
- package/dist/src/tui/app.js +22 -4
- package/dist/src/tui/bulk-actions.js +42 -9
- 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-panel.js +12 -4
- package/dist/src/tui/installations-rows.js +1 -1
- package/dist/src/tui/installations-view.js +16 -7
- package/dist/src/tui/keys.js +27 -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 +2 -1
- package/dist/src/tui/panel-parts.js +45 -36
- package/dist/src/tui/preview-render.js +93 -0
- package/dist/src/tui/preview-window.js +141 -10
- package/dist/src/tui/profile-view.js +49 -40
- package/dist/src/tui/render.js +11 -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 +86 -39
- 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 +8 -10
- 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,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.6.14] - 2026-11-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- The TUI names the item being installed or updated, with its position in the
|
|
15
|
+
batch (for example `Updating Skill tdd (2/5)…`); the elapsed time keeps running
|
|
16
|
+
across items and manual App steps (#104).
|
|
17
|
+
- The Sources view marks each Source as not refreshed, refreshing, refreshed or
|
|
18
|
+
failed. On start the TUI refreshes registered Git Sources in the background
|
|
19
|
+
(no prompts, 60 s limit), and previews reuse a successful refresh from the last
|
|
20
|
+
hour; `r` always refreshes again (#105).
|
|
21
|
+
- Exporting a Profile to an existing file offers to overwrite it after an explicit
|
|
22
|
+
confirmation; the CLI needs `profile export --out <file> --overwrite` (#107).
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Every TUI confirmation uses a shared footer that shows reading progress and what
|
|
27
|
+
`y` will do; `y` is ignored until the preview has been read to the end
|
|
28
|
+
(#106, #111, #112, #113, #114).
|
|
29
|
+
- The update batch preview starts with a summary and the collected warnings, then
|
|
30
|
+
groups Skills, Apps and Packages with version arrows and `$ command` lines (#108).
|
|
31
|
+
- App recipe reviews and App confirmations show a header and one block per step
|
|
32
|
+
(#109, #110).
|
|
33
|
+
|
|
34
|
+
### Security
|
|
35
|
+
|
|
36
|
+
- A `y` that arrives together with other keys (pasted text, held arrows or Page
|
|
37
|
+
Down) never confirms; it must be pressed on its own (#115).
|
|
38
|
+
- The TUI escapes control and format characters from recipes, Sources, Packages and
|
|
39
|
+
Host output in every view, so a remote `SKILL.md` or recipe can no longer send
|
|
40
|
+
terminal escape sequences or disguise a command (#116, #117).
|
|
41
|
+
|
|
42
|
+
## [0.6.13] - 2026-11-11
|
|
43
|
+
|
|
44
|
+
### Fixed
|
|
45
|
+
|
|
46
|
+
- `self update` accepts a global npm install when Node is managed by mise (for
|
|
47
|
+
example on Omarchy), where `npm` is a wrapper script instead of a link to
|
|
48
|
+
`npm-cli.js`. The npm next to the wrapper must be verifiable and npm's global
|
|
49
|
+
directory must still contain this exact installation (#102).
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- Development only: release checks pass on Windows without touching real state (#101).
|
|
54
|
+
|
|
10
55
|
## [0.6.12] - 2026-11-09
|
|
11
56
|
|
|
12
57
|
### Changed
|
package/README.md
CHANGED
|
@@ -971,7 +971,8 @@ 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.
|
|
975
976
|
Profiles contain canonical Git URLs with discovery inclusion, individual Skill
|
|
976
977
|
selections with recorded version policies, Hosts and user methods, complete App
|
|
977
978
|
recipes (including recipes for other platforms), and Package selections as
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
const KIND_LABEL = { skill: "Skill", app: "App", package: "Package" };
|
|
2
|
+
/** `Updating Skill tdd (2/5)…`; a batch of one has no position. */
|
|
3
|
+
export function formatProgressLabel(event) {
|
|
4
|
+
const position = event.total > 1 ? ` (${event.position}/${event.total})` : "";
|
|
5
|
+
return `${event.verb} ${KIND_LABEL[event.kind]} ${event.name}${position}…`;
|
|
6
|
+
}
|
package/dist/src/git-source.js
CHANGED
|
@@ -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,6 +85,11 @@ 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;
|
|
82
94
|
/**
|
|
83
95
|
* Clones each Source into a local mirror and refreshes it from the registered
|
|
@@ -90,7 +102,7 @@ export class GitSourceAccessAdapter {
|
|
|
90
102
|
this.cachePath = options.cachePath ?? defaultGitSourceCachePath();
|
|
91
103
|
this.runner = options.runner ?? new NodeGitCommandRunner();
|
|
92
104
|
}
|
|
93
|
-
async refresh(source) {
|
|
105
|
+
async refresh(source, options = {}) {
|
|
94
106
|
const expectedId = sourceIdForUrl(source.url);
|
|
95
107
|
if (source.kind !== "git" || source.id !== expectedId) {
|
|
96
108
|
throw new GitSourceAccessError(source, "the Source identity does not match its registered URL");
|
|
@@ -102,7 +114,7 @@ export class GitSourceAccessAdapter {
|
|
|
102
114
|
await assertNoSymlinkPath(this.cachePath, "cache path");
|
|
103
115
|
const release = await acquireSourceLock(`${destination}.lock`);
|
|
104
116
|
try {
|
|
105
|
-
await this.refreshLocked(source, destination);
|
|
117
|
+
await this.refreshLocked(source, destination, options.background ? BACKGROUND_REFRESH_OPTIONS : undefined);
|
|
106
118
|
}
|
|
107
119
|
finally {
|
|
108
120
|
await release();
|
|
@@ -116,7 +128,7 @@ export class GitSourceAccessAdapter {
|
|
|
116
128
|
throw new GitSourceAccessError(source, reason);
|
|
117
129
|
}
|
|
118
130
|
}
|
|
119
|
-
async refreshLocked(source, destination) {
|
|
131
|
+
async refreshLocked(source, destination, runOptions) {
|
|
120
132
|
const existing = await pathType(destination);
|
|
121
133
|
if (existing === "symlink") {
|
|
122
134
|
throw new Error("the Source cache path is a symbolic link");
|
|
@@ -136,12 +148,12 @@ export class GitSourceAccessAdapter {
|
|
|
136
148
|
"--force",
|
|
137
149
|
gitSourceLocation(source.url).repositoryUrl,
|
|
138
150
|
"+refs/*:refs/*",
|
|
139
|
-
]);
|
|
151
|
+
], runOptions);
|
|
140
152
|
return;
|
|
141
153
|
}
|
|
142
154
|
const temporaryDestination = `${destination}.${process.pid}.${randomUUID()}.tmp`;
|
|
143
155
|
try {
|
|
144
|
-
await this.runner.run("git", ["clone", "--mirror", gitSourceLocation(source.url).repositoryUrl, temporaryDestination]);
|
|
156
|
+
await this.runner.run("git", ["clone", "--mirror", gitSourceLocation(source.url).repositoryUrl, temporaryDestination], runOptions);
|
|
145
157
|
await assertNoSymlinkPath(temporaryDestination, "cache path");
|
|
146
158
|
if ((await pathType(temporaryDestination)) !== "directory") {
|
|
147
159
|
throw new Error("Git clone did not create a directory mirror");
|
|
@@ -0,0 +1,34 @@
|
|
|
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).replace(NOT_ESCAPED_BY_TUI, escapeCharacter);
|
|
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
|
+
/** `text` in double quotes with quotes and backslashes escaped; control characters stay as they are for the TUI to escape. */
|
|
20
|
+
export function quoteText(text) {
|
|
21
|
+
return `"${text.replace(/[\\"]/gu, "\\$&")}"`;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The argv as one shell-like line: arguments with spaces, quotes, shell metacharacters or invisible characters are JSON-quoted.
|
|
25
|
+
* With `terminal`, control and format characters are left for the TUI's own escaping.
|
|
26
|
+
*/
|
|
27
|
+
export function commandLine(argv, options = {}) {
|
|
28
|
+
const terminal = options.terminal === true;
|
|
29
|
+
return argv.map((argument) => PLAIN_ARGUMENT.test(argument) ? argument : quoted(argument, terminal)).join(" ");
|
|
30
|
+
}
|
|
31
|
+
/** Every warning row of the item, in order. */
|
|
32
|
+
export function itemWarnings(item) {
|
|
33
|
+
return item.rows.flatMap((row) => row.kind === "warning" ? [row.text] : []);
|
|
34
|
+
}
|
|
@@ -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
|
}
|
package/dist/src/self-update.js
CHANGED
|
@@ -66,6 +66,9 @@ function successfulStdout(result) {
|
|
|
66
66
|
return undefined;
|
|
67
67
|
return result.stdout.toString("utf8").trim();
|
|
68
68
|
}
|
|
69
|
+
function isNpmCli(target) {
|
|
70
|
+
return target !== undefined && path.basename(target) === "npm-cli.js" && path.basename(path.dirname(target)) === "bin" && path.basename(path.dirname(path.dirname(target))) === "npm";
|
|
71
|
+
}
|
|
69
72
|
export function createSelfUpdateOperations(options) {
|
|
70
73
|
const resolveExecutable = options.resolveExecutable ?? createPortableExecutableResolver();
|
|
71
74
|
const platform = options.platform ?? process.platform;
|
|
@@ -79,8 +82,12 @@ export function createSelfUpdateOperations(options) {
|
|
|
79
82
|
const verifiedFile = platform === "win32" && path.basename(npm.executable).toLowerCase() === "npm.cmd"
|
|
80
83
|
? path.join(path.dirname(npm.executable), "node_modules", "npm", "bin", "npm-cli.js")
|
|
81
84
|
: npm.executable;
|
|
82
|
-
|
|
83
|
-
|
|
85
|
+
let target = await realpath(verifiedFile).catch(() => undefined);
|
|
86
|
+
// mise replaces `<prefix>/bin/npm` with a script that runs `<prefix>/lib/node_modules/npm/bin/npm-cli.js`.
|
|
87
|
+
if (!isNpmCli(target) && platform !== "win32" && target !== undefined && path.basename(npm.executable) === "npm") {
|
|
88
|
+
target = await realpath(path.join(path.dirname(target), "..", "lib", "node_modules", "npm", "bin", "npm-cli.js")).catch(() => undefined);
|
|
89
|
+
}
|
|
90
|
+
if (!isNpmCli(target))
|
|
84
91
|
return undefined;
|
|
85
92
|
let rootResult;
|
|
86
93
|
try {
|
package/dist/src/skill-update.js
CHANGED
|
@@ -128,6 +128,7 @@ export async function applyUpdateBatch(selected, options) {
|
|
|
128
128
|
const items = "updateable" in selected ? selected.updateable : selected;
|
|
129
129
|
const results = [];
|
|
130
130
|
for (const item of items) {
|
|
131
|
+
options.onItem?.(item, results.length + 1, items.length);
|
|
131
132
|
try {
|
|
132
133
|
const result = await applySkillUpdate(item, options);
|
|
133
134
|
results.push(Object.freeze({ id: item.id, selection: item.selection, status: "updated", result }));
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { projectSourceOf, resolveProjectSource } from "./sources.js";
|
|
2
|
+
/** ADR 0004 (amended): how long a refresh made by this process stands in for the one a preview needs. */
|
|
3
|
+
const FRESHNESS_WINDOW_MS = 60 * 60 * 1000;
|
|
4
|
+
const BACKGROUND_CONCURRENCY = 2;
|
|
5
|
+
export function createSourceRefreshSession(operations, options = {}) {
|
|
6
|
+
const now = options.now ?? Date.now;
|
|
7
|
+
let states = new Map();
|
|
8
|
+
const inFlight = new Map();
|
|
9
|
+
const listeners = new Set();
|
|
10
|
+
const queue = [];
|
|
11
|
+
let backgroundRunning = 0;
|
|
12
|
+
const setState = (sourceId, state) => {
|
|
13
|
+
states = new Map(states).set(sourceId, state);
|
|
14
|
+
for (const listener of listeners)
|
|
15
|
+
listener();
|
|
16
|
+
};
|
|
17
|
+
// The project refresh carries the Source's own URL, so it also covers Sources missing from the registry.
|
|
18
|
+
const refreshReally = (source, background) => operations.refreshProjectSource
|
|
19
|
+
? operations.refreshProjectSource(projectSourceOf(source), { background })
|
|
20
|
+
: operations.refreshSource(source.id, { background });
|
|
21
|
+
const start = (source, background) => {
|
|
22
|
+
const startedAt = now();
|
|
23
|
+
setState(source.id, { status: "refreshing", startedAt });
|
|
24
|
+
const running = refreshReally(source, background).then(() => {
|
|
25
|
+
inFlight.delete(source.id);
|
|
26
|
+
setState(source.id, { status: "refreshed", startedAt, finishedAt: now() });
|
|
27
|
+
}, (error) => {
|
|
28
|
+
inFlight.delete(source.id);
|
|
29
|
+
setState(source.id, { status: "failed", startedAt, finishedAt: now(), error: error instanceof Error ? error.message : String(error) });
|
|
30
|
+
throw error;
|
|
31
|
+
});
|
|
32
|
+
inFlight.set(source.id, running);
|
|
33
|
+
return running;
|
|
34
|
+
};
|
|
35
|
+
const isFresh = (sourceId) => {
|
|
36
|
+
const state = states.get(sourceId);
|
|
37
|
+
return state?.status === "refreshed" && state.finishedAt !== undefined && now() - state.finishedAt < FRESHNESS_WINDOW_MS;
|
|
38
|
+
};
|
|
39
|
+
// Background refreshes cannot prompt and are time-limited (D2), so a user call never joins one:
|
|
40
|
+
// it waits for it to settle, then reuses its success (unless forced) or refreshes in the foreground.
|
|
41
|
+
const backgroundRuns = new Set();
|
|
42
|
+
const userRuns = new Map();
|
|
43
|
+
const refreshInBackground = (source) => {
|
|
44
|
+
const running = inFlight.get(source.id);
|
|
45
|
+
if (running)
|
|
46
|
+
return running;
|
|
47
|
+
if (isFresh(source.id))
|
|
48
|
+
return Promise.resolve();
|
|
49
|
+
const started = start(source, true);
|
|
50
|
+
backgroundRuns.add(started);
|
|
51
|
+
const forget = () => backgroundRuns.delete(started);
|
|
52
|
+
started.then(forget, forget);
|
|
53
|
+
return started;
|
|
54
|
+
};
|
|
55
|
+
const refreshForUser = (source, force) => {
|
|
56
|
+
const pending = userRuns.get(source.id);
|
|
57
|
+
// An unforced call may share any pending run; a forced one only another forced run.
|
|
58
|
+
if (pending && (pending.force || !force))
|
|
59
|
+
return pending.run;
|
|
60
|
+
const run = (async () => {
|
|
61
|
+
if (pending)
|
|
62
|
+
await pending.run.catch(() => undefined);
|
|
63
|
+
// A background refresh may start while this one waits, so wait until none is running.
|
|
64
|
+
for (let running = inFlight.get(source.id); running; running = inFlight.get(source.id)) {
|
|
65
|
+
await running.catch(() => undefined);
|
|
66
|
+
}
|
|
67
|
+
if (!force && isFresh(source.id))
|
|
68
|
+
return;
|
|
69
|
+
await start(source, false);
|
|
70
|
+
})();
|
|
71
|
+
const entry = { run, force };
|
|
72
|
+
userRuns.set(source.id, entry);
|
|
73
|
+
const forget = () => {
|
|
74
|
+
if (userRuns.get(source.id) === entry)
|
|
75
|
+
userRuns.delete(source.id);
|
|
76
|
+
};
|
|
77
|
+
run.then(forget, forget);
|
|
78
|
+
return run;
|
|
79
|
+
};
|
|
80
|
+
const drain = () => {
|
|
81
|
+
while (backgroundRunning < BACKGROUND_CONCURRENCY && queue.length > 0) {
|
|
82
|
+
const source = queue.shift();
|
|
83
|
+
backgroundRunning += 1;
|
|
84
|
+
void refreshInBackground(source)
|
|
85
|
+
.catch(() => undefined)
|
|
86
|
+
.finally(() => {
|
|
87
|
+
backgroundRunning -= 1;
|
|
88
|
+
drain();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
return {
|
|
93
|
+
refresh: (source, refreshOptions = {}) => refreshForUser(source, refreshOptions.force === true),
|
|
94
|
+
refreshInBackground(sources) {
|
|
95
|
+
queue.push(...sources);
|
|
96
|
+
drain();
|
|
97
|
+
},
|
|
98
|
+
snapshot: () => states,
|
|
99
|
+
subscribe(listener) {
|
|
100
|
+
listeners.add(listener);
|
|
101
|
+
return () => listeners.delete(listener);
|
|
102
|
+
},
|
|
103
|
+
now,
|
|
104
|
+
stop() {
|
|
105
|
+
queue.length = 0;
|
|
106
|
+
},
|
|
107
|
+
async settled() {
|
|
108
|
+
while (inFlight.size > 0 || userRuns.size > 0) {
|
|
109
|
+
await Promise.allSettled([...inFlight.values(), ...[...userRuns.values()].map((entry) => entry.run)]);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Routes `refreshSource` and `refreshProjectSource` through the session, so previews, Updates and
|
|
116
|
+
* Packages share and reuse refreshes. Built-in and unregistered ids keep the plain behavior and errors.
|
|
117
|
+
*/
|
|
118
|
+
export function withRefreshSession(operations, session) {
|
|
119
|
+
const refreshProjectSource = operations.refreshProjectSource;
|
|
120
|
+
return {
|
|
121
|
+
...operations,
|
|
122
|
+
async refreshSource(sourceId, options) {
|
|
123
|
+
const source = (await operations.listSources()).find((candidate) => candidate.kind === "git" && candidate.id === sourceId);
|
|
124
|
+
if (!source)
|
|
125
|
+
return operations.refreshSource(sourceId, options);
|
|
126
|
+
await session.refresh(source, options);
|
|
127
|
+
return source;
|
|
128
|
+
},
|
|
129
|
+
...(refreshProjectSource === undefined ? {} : {
|
|
130
|
+
async refreshProjectSource(projectSource, options) {
|
|
131
|
+
const source = resolveProjectSource(projectSource);
|
|
132
|
+
if (source.kind !== "git")
|
|
133
|
+
return refreshProjectSource(projectSource, options);
|
|
134
|
+
await session.refresh(source, options);
|
|
135
|
+
return source;
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
};
|
|
139
|
+
}
|
package/dist/src/sources.js
CHANGED
|
@@ -72,7 +72,7 @@ export function createSourceOperations(options = {}) {
|
|
|
72
72
|
const state = await stateStore.load();
|
|
73
73
|
return Object.freeze([BUILT_IN_SOURCE, ...state.gitSources]);
|
|
74
74
|
},
|
|
75
|
-
async refreshSource(sourceId) {
|
|
75
|
+
async refreshSource(sourceId, options) {
|
|
76
76
|
if (sourceId === BUILT_IN_SOURCE.id) {
|
|
77
77
|
throw new BuiltInSourceError();
|
|
78
78
|
}
|
|
@@ -81,7 +81,7 @@ export function createSourceOperations(options = {}) {
|
|
|
81
81
|
if (!source) {
|
|
82
82
|
throw new SourceNotFoundError(sourceId);
|
|
83
83
|
}
|
|
84
|
-
await gitAccess.refresh(source);
|
|
84
|
+
await gitAccess.refresh(source, options);
|
|
85
85
|
return source;
|
|
86
86
|
},
|
|
87
87
|
async removeGitSource(sourceId, installations = []) {
|
|
@@ -185,12 +185,12 @@ export function createSourceOperations(options = {}) {
|
|
|
185
185
|
async resolveProjectSource(projectSource) {
|
|
186
186
|
return resolveProjectSource(projectSource);
|
|
187
187
|
},
|
|
188
|
-
async refreshProjectSource(projectSource) {
|
|
188
|
+
async refreshProjectSource(projectSource, options) {
|
|
189
189
|
const source = resolveProjectSource(projectSource);
|
|
190
190
|
if (source.kind === "builtin") {
|
|
191
191
|
throw new BuiltInSourceError();
|
|
192
192
|
}
|
|
193
|
-
await gitAccess.refresh(source);
|
|
193
|
+
await gitAccess.refresh(source, options);
|
|
194
194
|
return source;
|
|
195
195
|
},
|
|
196
196
|
async readInstallationMethod(source, skillPath, previewTree) {
|
|
@@ -2,7 +2,7 @@ import { HOST_CHOICES } from "./catalog-actions.js";
|
|
|
2
2
|
import { runInstall } from "./catalog-installs.js";
|
|
3
3
|
import { errorText } from "./batch.js";
|
|
4
4
|
import { defaultAdoptionHosts, findAdoptionCandidates, prepareAdoption } from "./installations.js";
|
|
5
|
-
import { answerYesNo, applyChecklistOutcome, hostChecklistKey, moveCursor, versionInputKey } from "./mode-keys.js";
|
|
5
|
+
import { answerYesNo, applyChecklistOutcome, hostChecklistKey, moveCursor, movePreviewCursor, versionInputKey } from "./mode-keys.js";
|
|
6
6
|
import { busy } from "./working-model.js";
|
|
7
7
|
function cancel(context) {
|
|
8
8
|
context.setMessage({ kind: "ok", text: "Adoption cancelled" });
|
|
@@ -49,7 +49,7 @@ async function prepare(context, mode, version) {
|
|
|
49
49
|
const adoption = await prepareAdoption(context.operations, context.env, mode.entry, mode.skill, { hosts: mode.hosts, version });
|
|
50
50
|
if (!context.isMounted())
|
|
51
51
|
return;
|
|
52
|
-
context.setMode({ kind: "confirm", entry: mode.entry, skill: mode.skill, adoption });
|
|
52
|
+
context.setMode({ kind: "confirm", entry: mode.entry, skill: mode.skill, adoption, cursor: 0 });
|
|
53
53
|
}
|
|
54
54
|
catch (error) {
|
|
55
55
|
fail(context, error);
|
|
@@ -105,13 +105,32 @@ function handleFixedKey(context, mode, input, key) {
|
|
|
105
105
|
else if (outcome.kind === "edit")
|
|
106
106
|
context.setMode({ ...mode, value: outcome.value });
|
|
107
107
|
}
|
|
108
|
+
/** What an adoptable preview shows, so the key handler and the panel page the same rows. */
|
|
109
|
+
export function adoptionPreview(mode) {
|
|
110
|
+
const { prepared } = mode.adoption;
|
|
111
|
+
const name = mode.entry.name;
|
|
112
|
+
return {
|
|
113
|
+
lines: [...prepared.preview, ...(prepared.runsExternalCommand ? ["This install runs an external command (see above)."] : [])],
|
|
114
|
+
chrome: { title: `Adopt ${name}`, action: `Adopt ${name}`, consequence: `Adopt ${name}? y/n` },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/** Pages the preview like the other footer confirmations: `y` answers only once it has all been displayed. */
|
|
108
118
|
function handleConfirmKey(context, mode, input, key) {
|
|
109
119
|
if (!mode.adoption.verdict.adoptable) {
|
|
110
120
|
if (key.escape || key.return || input === "n")
|
|
111
121
|
cancel(context);
|
|
112
122
|
return;
|
|
113
123
|
}
|
|
124
|
+
const { lines, chrome } = adoptionPreview(mode);
|
|
125
|
+
const viewport = context.previewGeometry(lines, mode.cursor, chrome);
|
|
126
|
+
const cursor = movePreviewCursor(mode.cursor, viewport.total, input, key, viewport.height);
|
|
127
|
+
if (cursor !== undefined) {
|
|
128
|
+
context.setMode({ ...mode, cursor });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
114
131
|
answerYesNo(input, key, () => {
|
|
132
|
+
if (!viewport.reachedEnd)
|
|
133
|
+
return;
|
|
115
134
|
if (mode.adoption.prepared.additionalHostExposure)
|
|
116
135
|
context.setMode({ ...mode, kind: "confirm-exposure" });
|
|
117
136
|
else
|