@nggaigc/cli 0.1.53 → 0.1.55

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.
@@ -0,0 +1,365 @@
1
+ /** A finite root transaction. It shares the signed installer's lock and keeps verified backups. */
2
+ import { randomBytes } from "node:crypto";
3
+ import { lstat, mkdir, open, readFile, rmdir, rename, rm, readdir, } from "node:fs/promises";
4
+ import { createRequire } from "node:module";
5
+ import path from "node:path";
6
+ import { CliError } from "./errors.js";
7
+ import { extractBundle, verifyExtractedBundle, verifyInstalledSkill, } from "./bundle.js";
8
+ import { exists, knownBundle, privateDirectory, rootVendor, saveProof, syncDirectory, trustedRoot, } from "./root-store.js";
9
+ const journalName = ".nggaigc-root-upgrade.json";
10
+ const noncePattern = /^[a-f0-9]{32}$/;
11
+ const supported = ["1.1.49", "1.1.50"];
12
+ function fail() {
13
+ throw new CliError("ROOT_RECOVERY_REQUIRED");
14
+ }
15
+ async function durableJson(file, value) {
16
+ const fd = await open(file, "wx", 0o600);
17
+ try {
18
+ await fd.writeFile(JSON.stringify(value) + "\n");
19
+ await fd.sync();
20
+ }
21
+ finally {
22
+ await fd.close();
23
+ }
24
+ await syncDirectory(path.dirname(file));
25
+ }
26
+ const acquisitionGateName = ".nggaigc-install-acquire";
27
+ /** No stale-gate reclamation: an interrupted acquisition requires explicit readback. */
28
+ async function acquisitionGate(skills, own) {
29
+ const directory = path.join(skills, acquisitionGateName);
30
+ const ownerFile = path.join(directory, "owner.json");
31
+ try {
32
+ await mkdir(directory, { mode: 0o700 });
33
+ }
34
+ catch (error) {
35
+ if (error.code !== "EEXIST")
36
+ throw error;
37
+ try {
38
+ const facts = await lstat(directory);
39
+ const ownerFacts = await lstat(ownerFile);
40
+ if (!facts.isDirectory() ||
41
+ facts.isSymbolicLink() ||
42
+ !ownerFacts.isFile() ||
43
+ ownerFacts.isSymbolicLink())
44
+ fail();
45
+ const prior = JSON.parse(await readFile(ownerFile, "utf8"));
46
+ if (Object.keys(prior).sort().join(",") !== "nonce,pid,schemaVersion" ||
47
+ prior.schemaVersion !== "nggaigc-install-acquire/v1" ||
48
+ !Number.isSafeInteger(prior.pid) ||
49
+ prior.pid < 1 ||
50
+ typeof prior.nonce !== "string" ||
51
+ !noncePattern.test(prior.nonce))
52
+ fail();
53
+ try {
54
+ process.kill(prior.pid, 0);
55
+ }
56
+ catch (signal) {
57
+ if (signal.code === "ESRCH")
58
+ fail();
59
+ }
60
+ }
61
+ catch (cause) {
62
+ if (cause instanceof CliError)
63
+ throw cause;
64
+ fail();
65
+ }
66
+ throw new CliError("INSTALL_BUSY");
67
+ }
68
+ const identity = await lstat(directory);
69
+ // A failure before this write completes leaves the gate in place, never guessed away.
70
+ await durableJson(ownerFile, {
71
+ ...own,
72
+ schemaVersion: "nggaigc-install-acquire/v1",
73
+ });
74
+ await syncDirectory(skills);
75
+ return async () => {
76
+ const facts = await lstat(directory);
77
+ const ownerFacts = await lstat(ownerFile);
78
+ const owner = JSON.parse(await readFile(ownerFile, "utf8"));
79
+ if (!facts.isDirectory() ||
80
+ facts.isSymbolicLink() ||
81
+ facts.ino !== identity.ino ||
82
+ !ownerFacts.isFile() ||
83
+ ownerFacts.isSymbolicLink() ||
84
+ owner.schemaVersion !== "nggaigc-install-acquire/v1" ||
85
+ owner.pid !== own.pid ||
86
+ owner.nonce !== own.nonce ||
87
+ (await readdir(directory)).join(",") !== "owner.json")
88
+ fail();
89
+ await rm(ownerFile);
90
+ await rmdir(directory);
91
+ await syncDirectory(skills);
92
+ };
93
+ }
94
+ export async function withRootLock(skills, action) {
95
+ await privateDirectory(skills);
96
+ const lock = path.join(skills, ".nggaigc-install.lock");
97
+ const own = {
98
+ schemaVersion: "nggaigc-install-lock/v2",
99
+ pid: process.pid,
100
+ nonce: randomBytes(16).toString("hex"),
101
+ };
102
+ // Every 055 creator/reclaimer takes this atomic gate before touching the main
103
+ // lock. A delayed stale snapshot cannot unlink another 055 owner's live lock.
104
+ const releaseGate = await acquisitionGate(skills, own);
105
+ let fd;
106
+ try {
107
+ try {
108
+ try {
109
+ fd = await open(lock, "wx", 0o600);
110
+ }
111
+ catch (e) {
112
+ if (e.code !== "EEXIST")
113
+ throw e;
114
+ const before = await lstat(lock);
115
+ if (!before.isFile() || before.isSymbolicLink())
116
+ throw new CliError("INSTALL_BUSY");
117
+ let prior;
118
+ try {
119
+ prior = JSON.parse(await readFile(lock, "utf8"));
120
+ }
121
+ catch {
122
+ throw new CliError("INSTALL_BUSY");
123
+ }
124
+ if (Object.keys(prior).sort().join(",") !== "nonce,pid,schemaVersion" ||
125
+ !["nggaigc-install-lock/v1", own.schemaVersion].includes(prior.schemaVersion) ||
126
+ !Number.isSafeInteger(prior.pid) ||
127
+ prior.pid < 1 ||
128
+ typeof prior.nonce !== "string" ||
129
+ !noncePattern.test(prior.nonce))
130
+ throw new CliError("INSTALL_BUSY");
131
+ try {
132
+ process.kill(prior.pid, 0);
133
+ throw new CliError("INSTALL_BUSY");
134
+ }
135
+ catch (signal) {
136
+ if (signal.code !== "ESRCH")
137
+ throw new CliError("INSTALL_BUSY");
138
+ }
139
+ // A v1 observer may already hold a stale snapshot and ignores our gate.
140
+ // Never replace a dead v1 lock automatically; require quiescent recovery.
141
+ if (prior.schemaVersion !== own.schemaVersion)
142
+ fail();
143
+ const again = await lstat(lock);
144
+ if (again.ino !== before.ino ||
145
+ again.size !== before.size ||
146
+ again.mtimeMs !== before.mtimeMs)
147
+ throw new CliError("INSTALL_BUSY");
148
+ await rm(lock);
149
+ try {
150
+ fd = await open(lock, "wx", 0o600);
151
+ }
152
+ catch {
153
+ throw new CliError("INSTALL_BUSY");
154
+ }
155
+ }
156
+ await fd.writeFile(JSON.stringify(own) + "\n");
157
+ await fd.sync();
158
+ await syncDirectory(skills);
159
+ }
160
+ finally {
161
+ // Release only the short acquisition gate. The main lock covers action.
162
+ // SIGKILL during action therefore leaves the original recoverable lock.
163
+ await releaseGate();
164
+ }
165
+ }
166
+ catch (error) {
167
+ await fd?.close();
168
+ throw error;
169
+ }
170
+ try {
171
+ return await action();
172
+ }
173
+ finally {
174
+ await fd.close();
175
+ try {
176
+ const v = JSON.parse(await readFile(lock, "utf8"));
177
+ if (v.pid === own.pid && v.nonce === own.nonce) {
178
+ await rm(lock);
179
+ await syncDirectory(skills);
180
+ }
181
+ }
182
+ catch {
183
+ /* Never remove an untrusted replacement. */
184
+ }
185
+ }
186
+ }
187
+ function paths(skills, j) {
188
+ return {
189
+ target: path.join(skills, "nggaigc"),
190
+ stage: path.join(skills, `.nggaigc-verified-stage-${j.nonce}`),
191
+ backup: path.join(skills, `.nggaigc-verified-backup-${j.nonce}`),
192
+ rejected: path.join(skills, `.nggaigc-verified-rejected-${j.nonce}`),
193
+ };
194
+ }
195
+ async function valid(directory, bundle) {
196
+ try {
197
+ await verifyInstalledSkill(directory, bundle.files, bundle.executableFiles);
198
+ return true;
199
+ }
200
+ catch {
201
+ return false;
202
+ }
203
+ }
204
+ async function finish(skills, j, outcome) {
205
+ const done = path.join(skills, `.nggaigc-root-${outcome}-${j.nonce}.json`);
206
+ if (await exists(done))
207
+ fail();
208
+ await rename(path.join(skills, journalName), done);
209
+ await syncDirectory(skills);
210
+ }
211
+ /** Call only while holding withRootLock. Recovery observes the original journal, never starts a new transaction. */
212
+ export async function recoverRoot(skills, vendor = rootVendor) {
213
+ const file = path.join(skills, journalName);
214
+ if (!(await exists(file)))
215
+ return;
216
+ const info = await lstat(file);
217
+ if (!info.isFile() || info.isSymbolicLink())
218
+ fail();
219
+ let j;
220
+ try {
221
+ j = JSON.parse(await readFile(file, "utf8"));
222
+ }
223
+ catch {
224
+ fail();
225
+ }
226
+ if (Object.keys(j).sort().join(",") !==
227
+ "archiveSha256,from,nonce,schemaVersion,to" ||
228
+ j.schemaVersion !== "nggaigc-root-upgrade/v1" ||
229
+ !noncePattern.test(j.nonce) ||
230
+ !supported.includes(j.to) ||
231
+ (j.from !== null && !supported.includes(j.from)) ||
232
+ j.from === j.to ||
233
+ !/^[a-f0-9]{64}$/.test(j.archiveSha256))
234
+ fail();
235
+ const p = paths(skills, j), next = await knownBundle(skills, j.to, vendor);
236
+ if (next.sha256 !== j.archiveSha256)
237
+ fail();
238
+ if (await valid(p.target, next)) {
239
+ await finish(skills, j, "committed");
240
+ return "committed";
241
+ }
242
+ if (j.from !== null) {
243
+ const previous = await knownBundle(skills, j.from, vendor);
244
+ if (await valid(p.target, previous)) {
245
+ await finish(skills, j, "restored");
246
+ return "restored";
247
+ }
248
+ if (!(await valid(p.backup, previous)))
249
+ fail();
250
+ if (await exists(p.target)) {
251
+ if (await exists(p.rejected))
252
+ fail();
253
+ await rename(p.target, p.rejected);
254
+ await syncDirectory(skills);
255
+ }
256
+ await rename(p.backup, p.target);
257
+ await syncDirectory(skills);
258
+ await verifyInstalledSkill(p.target, previous.files, previous.executableFiles);
259
+ await finish(skills, j, "restored");
260
+ return "restored";
261
+ }
262
+ // A first installation has no previous state. Only its fully verified stage may be committed.
263
+ if ((await exists(p.target)) || !(await valid(p.stage, next)))
264
+ fail();
265
+ await rename(p.stage, p.target);
266
+ await syncDirectory(skills);
267
+ await verifyInstalledSkill(p.target, next.files, next.executableFiles);
268
+ await finish(skills, j, "committed");
269
+ return "committed";
270
+ }
271
+ export async function recoverRootForUse(skills, vendor = rootVendor) {
272
+ if ((await recoverRoot(skills, vendor)) === "restored")
273
+ throw new CliError("ROOT_UPGRADE_RESTORED");
274
+ }
275
+ /** Caller holds the shared lock. No signed source, credential or run state is changed. */
276
+ export async function installVerifiedRootLocked(skills, bundle, options = {}) {
277
+ const vendor = options.vendor ?? rootVendor;
278
+ await recoverRootForUse(skills, vendor);
279
+ for (const name of await readdir(skills))
280
+ if (/^\.nggaigc-pair(?:-v[23])?-(?:transaction|stage)/.test(name))
281
+ throw new CliError("SKILL_PAIR_RECOVERY_REQUIRED");
282
+ const target = path.join(skills, "nggaigc");
283
+ const old = (await exists(target)) ? await trustedRoot(skills, vendor) : null;
284
+ if (old?.version === bundle.version)
285
+ return "unchanged";
286
+ if (!supported.includes(bundle.version) ||
287
+ (old && !supported.includes(old.version)))
288
+ throw new CliError("ROOT_UNAVAILABLE");
289
+ if (old)
290
+ await saveProof(skills, old.bundle, vendor);
291
+ await saveProof(skills, bundle, vendor);
292
+ const j = {
293
+ schemaVersion: "nggaigc-root-upgrade/v1",
294
+ nonce: randomBytes(16).toString("hex"),
295
+ from: old?.version ?? null,
296
+ to: bundle.version,
297
+ archiveSha256: bundle.sha256,
298
+ };
299
+ const p = paths(skills, j), extracted = path.join(skills, `.nggaigc-verified-source-${j.nonce}`);
300
+ try {
301
+ await extractBundle(bundle.archive, extracted, bundle.sha256);
302
+ await verifyExtractedBundle(extracted, bundle.files);
303
+ const require = createRequire(import.meta.url);
304
+ const installer = require(path.join(extracted, "installer/install.cjs"));
305
+ if (typeof installer.copyTree !== "function")
306
+ throw new CliError("PACKAGE_VERIFICATION_FAILED");
307
+ await mkdir(p.stage, { mode: 0o700 });
308
+ await installer.copyTree(path.join(extracted, "skills/nggaigc"), p.stage);
309
+ await durableJson(path.join(p.stage, ".nggaigc-install.json"), {
310
+ schemaVersion: "nggaigc-local-install/v1",
311
+ version: bundle.version,
312
+ installedAt: new Date().toISOString(),
313
+ });
314
+ await options.step?.("stage-copied", p);
315
+ await verifyInstalledSkill(p.stage, bundle.files, bundle.executableFiles);
316
+ // Flush all payloads before recording intent; directory renames then survive process interruption.
317
+ for (const name of Object.keys(bundle.files).filter((x) => x.startsWith("skills/nggaigc/"))) {
318
+ const fd = await open(path.join(p.stage, name.slice(15)), "r");
319
+ try {
320
+ await fd.sync();
321
+ }
322
+ finally {
323
+ await fd.close();
324
+ }
325
+ }
326
+ const syncTree = async (directory) => {
327
+ for (const entry of await readdir(directory, { withFileTypes: true }))
328
+ if (entry.isDirectory())
329
+ await syncTree(path.join(directory, entry.name));
330
+ await syncDirectory(directory);
331
+ };
332
+ await syncTree(p.stage);
333
+ await durableJson(path.join(skills, journalName), j);
334
+ if (old) {
335
+ await verifyInstalledSkill(target, old.bundle.files, old.bundle.executableFiles);
336
+ await rename(target, p.backup);
337
+ await syncDirectory(skills);
338
+ await options.step?.("old-renamed", p);
339
+ }
340
+ await rename(p.stage, target);
341
+ await syncDirectory(skills);
342
+ await options.step?.("new-renamed", p);
343
+ await verifyInstalledSkill(target, bundle.files, bundle.executableFiles);
344
+ await options.step?.("post-verified", p);
345
+ await finish(skills, j, "committed");
346
+ return old ? "updated" : "installed";
347
+ }
348
+ catch (error) {
349
+ if (await exists(path.join(skills, journalName))) {
350
+ try {
351
+ await recoverRoot(skills, vendor);
352
+ }
353
+ catch {
354
+ throw new CliError("ROOT_RECOVERY_REQUIRED");
355
+ }
356
+ }
357
+ throw error;
358
+ }
359
+ finally {
360
+ await rm(extracted, { recursive: true, force: true });
361
+ }
362
+ }
363
+ export async function ensureCurrentRootLocked(skills, vendor = rootVendor) {
364
+ await installVerifiedRootLocked(skills, await knownBundle(skills, "1.1.50", vendor), { vendor });
365
+ }
@@ -4,30 +4,32 @@ import { createRequire } from "node:module";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { resolveSkillLocation, } from "./skill-location.js";
7
- import { trustedKeyId, trustedKeySpkiSha256, verifyBundle, verifyInstalledSkill, } from "./bundle.js";
7
+ import { CliError } from "./errors.js";
8
+ import { trustedKeyId, trustedKeySpkiSha256 } from "./bundle.js";
9
+ import { trustedRoot, trustedRuntime, exists } from "./root-store.js";
10
+ import { withRootLock, recoverRootForUse, ensureCurrentRootLocked, } from "./root-upgrade.js";
11
+ import { canonical, resolveStartAlias } from "./start-compatibility.js";
8
12
  import { IndependentSkillError, projectActiveSkillCatalog, startIndependentSkill, } from "./independent-skill.js";
13
+ function compare(a, b) {
14
+ const x = a.split(".").map(Number), y = b.split(".").map(Number);
15
+ for (let i = 0; i < 3; i++) {
16
+ const d = x[i] - y[i];
17
+ if (d)
18
+ return d;
19
+ }
20
+ return 0;
21
+ }
9
22
  function fail(code) {
10
23
  throw new IndependentSkillError(code);
11
24
  }
12
25
  function record(value) {
13
26
  return value !== null && typeof value === "object" && !Array.isArray(value);
14
27
  }
15
- export async function productionDependencies(skillsDirectory) {
28
+ async function productionDependenciesLocked(skillsDirectory, verifiedLauncher, runtimeVersion, createRuntime = true) {
16
29
  const vendor = fileURLToPath(new URL("../vendor/", import.meta.url));
17
- const bundle = await verifyBundle({
18
- archive: path.join(vendor, "nggaigc-plugin-1.1.49.zip"),
19
- manifest: path.join(vendor, "manifest.json"),
20
- trust: path.join(vendor, "trusted-keys.json"),
21
- });
22
- const root = path.join(skillsDirectory, "nggaigc");
23
- await verifyInstalledSkill(root, bundle.files);
24
- const source = JSON.parse(await readFile(path.join(root, "references", "source.json"), "utf8"));
25
- if (!record(source) ||
26
- source.id !== "skill.nggaigc" ||
27
- typeof source.version !== "string" ||
28
- !/^\d+\.\d+\.\d+$/.test(source.version) ||
29
- source.clientProtocol !== 1)
30
- fail("ROOT_UNAVAILABLE");
30
+ const current = await trustedRoot(skillsDirectory);
31
+ const installed = await trustedRuntime(skillsDirectory, runtimeVersion ?? current.version, undefined, createRuntime);
32
+ const root = installed.directory;
31
33
  const keys = JSON.parse(await readFile(path.join(vendor, "trusted-keys.json"), "utf8"));
32
34
  if (!record(keys) ||
33
35
  keys.schemaVersion !== "ngg-trusted-package-keys/v1" ||
@@ -45,30 +47,108 @@ export async function productionDependencies(skillsDirectory) {
45
47
  if (fingerprint !== trustedKeySpkiSha256)
46
48
  fail("TRUST_UNAVAILABLE");
47
49
  const require = createRequire(import.meta.url);
50
+ for (const cached of Object.keys(require.cache))
51
+ if (cached.startsWith(root + path.sep))
52
+ delete require.cache[cached];
48
53
  const launcher = require(path.join(root, "scripts", "launch-ngg-client.cjs"));
49
54
  if (!record(launcher) || typeof launcher.main !== "function")
50
55
  fail("ROOT_UNAVAILABLE");
51
56
  return {
52
- launcher: launcher,
57
+ launcher: verifiedLauncher?.({ version: installed.version, directory: root }) ??
58
+ launcher,
53
59
  trust: {
54
60
  keyId: trustedKeyId,
55
61
  publicKeyPem: match.publicKeyPem,
56
62
  spkiSha256: fingerprint,
57
63
  },
58
- rootSkillVersion: source.version,
64
+ rootSkillVersion: installed.version,
65
+ rootLauncherPath: path.join(root, "scripts/launch-ngg-client.cjs"),
59
66
  };
60
67
  }
68
+ /** Read-only fast path for existing immutable runtimes; only recovery/cache creation locks. */
69
+ export async function productionDependencies(skillsDirectory, verifiedLauncher, runtimeVersion) {
70
+ if (!(await exists(path.join(skillsDirectory, ".nggaigc-root-upgrade.json")))) {
71
+ try {
72
+ return await productionDependenciesLocked(skillsDirectory, verifiedLauncher, runtimeVersion, false);
73
+ }
74
+ catch (error) {
75
+ if (!(error instanceof CliError) ||
76
+ error.code !== "ROOT_RUNTIME_UNAVAILABLE")
77
+ throw error;
78
+ }
79
+ }
80
+ return withRootLock(skillsDirectory, async () => {
81
+ await recoverRootForUse(skillsDirectory);
82
+ return productionDependenciesLocked(skillsDirectory, verifiedLauncher, runtimeVersion, true);
83
+ });
84
+ }
85
+ async function upgradeRoot(skillsDirectory) {
86
+ await withRootLock(skillsDirectory, () => ensureCurrentRootLocked(skillsDirectory));
87
+ }
88
+ function catalogRootVersion(snapshot) {
89
+ if (!record(snapshot) || !Array.isArray(snapshot.entries))
90
+ fail("CATALOG_INVALID");
91
+ const roots = snapshot.entries.filter((e) => record(e) &&
92
+ e.id === "skill.nggaigc" &&
93
+ record(e.manifest) &&
94
+ e.manifest.role === "root_skill" &&
95
+ record(e.manifest.lifecycle) &&
96
+ e.manifest.lifecycle.status === "published");
97
+ if (roots.length !== 1 ||
98
+ !["1.1.49", "1.1.50"].includes(String(roots[0].version)))
99
+ fail("ROOT_UNAVAILABLE");
100
+ return String(roots[0].version);
101
+ }
61
102
  /** Generic bridge only: the verified launcher owns command and API contracts. */
62
103
  export async function runClientCommand(args, options = {}, launcherOptions = {}) {
63
104
  if (args.length === 0)
64
105
  fail("INVALID_INPUT");
65
106
  const { skillsDirectory } = resolveSkillLocation(options);
66
- const { launcher } = await productionDependencies(skillsDirectory);
67
- return launcher.main(args, launcherOptions);
107
+ const execute = async () => {
108
+ let { launcher, rootSkillVersion } = await productionDependencies(skillsDirectory, options.verifiedLauncher);
109
+ if ([
110
+ "preflight",
111
+ "connect",
112
+ "reconnect",
113
+ "login",
114
+ "relogin",
115
+ "status",
116
+ "disconnect",
117
+ ].includes(args[0]))
118
+ return launcher.main(args, launcherOptions);
119
+ // Catalog read is a generic bootstrap operation. Login/offline diagnostics may
120
+ // continue when it is unavailable; all remote actions retain server admission.
121
+ let snapshot;
122
+ try {
123
+ snapshot = await launcher.main(["catalog-snapshot"], launcherOptions);
124
+ }
125
+ catch {
126
+ /* Keep the existing connection/recovery contract. */
127
+ }
128
+ if (snapshot !== undefined) {
129
+ projectActiveSkillCatalog(snapshot);
130
+ if (!record(snapshot) || !Array.isArray(snapshot.entries))
131
+ fail("CATALOG_INVALID");
132
+ const target = catalogRootVersion(snapshot);
133
+ if (target !== rootSkillVersion) {
134
+ if (compare(target, rootSkillVersion) > 0)
135
+ await upgradeRoot(skillsDirectory);
136
+ const updated = await productionDependencies(skillsDirectory, options.verifiedLauncher, target);
137
+ launcher = updated.launcher;
138
+ const fresh = await launcher.main(["catalog-snapshot"], launcherOptions);
139
+ projectActiveSkillCatalog(fresh);
140
+ if (canonical(fresh) !== canonical(snapshot))
141
+ fail("CATALOG_CHANGED");
142
+ }
143
+ }
144
+ return launcher.main(args, launcherOptions);
145
+ };
146
+ return execute();
68
147
  }
69
148
  /** Candidate npm entry: active catalog → server admission → verified local Skill path. */
70
149
  export async function runSkillStartCommand(skillId, skillVersion, options = {}) {
71
- if (!/^skill\.[a-z0-9][a-z0-9.-]{0,79}$/.test(skillId) ||
150
+ if ((options.exactVersion && skillVersion === undefined) ||
151
+ !/^skill\.[a-z0-9][a-z0-9.-]{0,79}$/.test(skillId) ||
72
152
  (skillVersion !== undefined && !/^\d+\.\d+\.\d+$/.test(skillVersion)))
73
153
  fail("INVALID_INPUT");
74
154
  const configuredDirectory = resolveSkillLocation(options).skillsDirectory;
@@ -77,53 +157,78 @@ export async function runSkillStartCommand(skillId, skillVersion, options = {})
77
157
  ? configuredDirectory
78
158
  : fail("INSTALL_PATH_UNSAFE"))
79
159
  : configuredDirectory;
80
- const production = options.launcher && options.trust && options.rootSkillVersion
81
- ? null
82
- : await productionDependencies(skillsDirectory);
83
- const launcher = options.launcher ?? production.launcher;
84
- const trust = options.trust ?? production.trust;
85
- const rootSkillVersion = options.rootSkillVersion ?? production.rootSkillVersion;
86
- const platformVersion = options.platformVersion ??
87
- JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version;
88
- let snapshot;
89
- try {
90
- snapshot = await launcher.main(["catalog-snapshot"]);
91
- }
92
- catch {
93
- fail("CATALOG_UNAVAILABLE");
94
- }
95
- const index = projectActiveSkillCatalog(snapshot);
96
- const matches = index.entries.filter((entry) => entry.id === skillId &&
97
- entry.state === "active" &&
98
- entry.sourceManifest?.role === "independent_skill" &&
99
- (skillVersion === undefined || entry.version === skillVersion));
100
- if (matches.length !== 1)
101
- fail("SKILL_UNAVAILABLE");
102
- const selected = matches[0];
103
- const result = await startIndependentSkill({
104
- index,
105
- skillId: selected.id,
106
- skillVersion: selected.version,
107
- rootSkillVersion,
108
- platformVersion,
109
- skillsDirectory,
110
- trust,
111
- fetcher: options.fetcher,
112
- admit: async (identity) => {
113
- const answer = await launcher.main([
114
- "skill-admit",
115
- identity.kind,
116
- identity.id,
117
- identity.version,
118
- identity.manifestSha256,
119
- identity.releaseId,
120
- ]);
121
- return answer;
122
- },
123
- });
124
- return {
125
- ...result,
126
- skillsDirectory,
127
- rootLauncherPath: path.join(skillsDirectory, "nggaigc", "scripts", "launch-ngg-client.cjs"),
160
+ const execute = async () => {
161
+ const production = options.launcher && options.trust && options.rootSkillVersion
162
+ ? null
163
+ : await productionDependencies(skillsDirectory, options.verifiedLauncher);
164
+ let launcher = options.launcher ?? production.launcher;
165
+ const trust = options.trust ?? production.trust;
166
+ let rootSkillVersion = options.rootSkillVersion ?? production.rootSkillVersion;
167
+ const platformVersion = options.platformVersion ??
168
+ JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8")).version;
169
+ let snapshot;
170
+ try {
171
+ snapshot = await launcher.main(["catalog-snapshot"], options.launcherOptions);
172
+ }
173
+ catch {
174
+ fail("CATALOG_UNAVAILABLE");
175
+ }
176
+ const index = projectActiveSkillCatalog(snapshot);
177
+ const migration = await resolveStartAlias(snapshot, index, skillId, skillVersion, options.migrationTrust ?? trust, options);
178
+ const resolvedVersion = migration?.toVersion ?? skillVersion;
179
+ const matches = index.entries.filter((entry) => entry.id === skillId &&
180
+ entry.state === "active" &&
181
+ entry.sourceManifest?.role === "independent_skill" &&
182
+ (resolvedVersion === undefined || entry.version === resolvedVersion));
183
+ if (matches.length !== 1)
184
+ fail("SKILL_UNAVAILABLE");
185
+ const selected = matches[0];
186
+ let rootLauncherPath = production?.rootLauncherPath ??
187
+ path.join(skillsDirectory, "nggaigc/scripts/launch-ngg-client.cjs");
188
+ if (production) {
189
+ const target = catalogRootVersion(snapshot);
190
+ if (target !== rootSkillVersion) {
191
+ if (compare(target, rootSkillVersion) > 0)
192
+ await upgradeRoot(skillsDirectory);
193
+ const updated = await productionDependencies(skillsDirectory, options.verifiedLauncher, target);
194
+ launcher = updated.launcher;
195
+ rootSkillVersion = updated.rootSkillVersion;
196
+ rootLauncherPath = updated.rootLauncherPath;
197
+ const fresh = await launcher.main(["catalog-snapshot"], options.launcherOptions);
198
+ projectActiveSkillCatalog(fresh);
199
+ if (canonical(fresh) !== canonical(snapshot))
200
+ fail("CATALOG_CHANGED");
201
+ }
202
+ }
203
+ const result = await startIndependentSkill({
204
+ index,
205
+ skillId: selected.id,
206
+ skillVersion: selected.version,
207
+ rootSkillVersion,
208
+ platformVersion,
209
+ skillsDirectory,
210
+ trust,
211
+ fetcher: options.fetcher,
212
+ admit: async (identity) => {
213
+ const answer = await launcher.main([
214
+ "skill-admit",
215
+ identity.kind,
216
+ identity.id,
217
+ identity.version,
218
+ identity.manifestSha256,
219
+ identity.releaseId,
220
+ ], options.launcherOptions);
221
+ return answer;
222
+ },
223
+ });
224
+ return {
225
+ ...result,
226
+ ...(skillVersion
227
+ ? { requestedVersion: skillVersion, resolvedVersion: selected.version }
228
+ : {}),
229
+ skillsDirectory,
230
+ rootLauncherPath,
231
+ };
128
232
  };
233
+ return execute();
129
234
  }