ast-mcp-server 0.10.0 → 0.11.1

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,145 @@
1
+ import path from "node:path";
2
+ import { applyManagedFilePlan, canonicalizeFuturePath, captureManagedFilePreimage, captureManagedFileSnapshot, createManagedFileApplyContext, hashBytes, ManagedFileApplyError, rollbackManagedFilePlan, snapshotsEqual, verifyManagedFilePostimage, } from "./managed-file.js";
3
+ export class ManagedBundleConflictError extends Error {
4
+ destination;
5
+ constructor(destination) {
6
+ super(`Managed bundle destination already exists with different content: ${destination}`);
7
+ this.destination = destination;
8
+ this.name = "ManagedBundleConflictError";
9
+ }
10
+ }
11
+ function validateRelease(release) {
12
+ const paths = new Set();
13
+ for (const file of release.files) {
14
+ if (file.path.includes("\\") ||
15
+ path.posix.isAbsolute(file.path) ||
16
+ path.win32.isAbsolute(file.path) ||
17
+ path.posix.normalize(file.path) !== file.path ||
18
+ file.path.startsWith("../") ||
19
+ file.path === ".") {
20
+ throw new Error(`Managed bundle contains an unsafe path: ${file.path}`);
21
+ }
22
+ if (paths.has(file.path))
23
+ throw new Error(`Managed bundle contains a duplicate path: ${file.path}`);
24
+ paths.add(file.path);
25
+ }
26
+ }
27
+ export async function planManagedBundle(options) {
28
+ validateRelease(options.current);
29
+ for (const predecessor of options.predecessors)
30
+ validateRelease(predecessor);
31
+ const signatures = [options.current, ...options.predecessors].map((release) => release.files
32
+ .map((file) => `${file.path}:${file.sha256}`)
33
+ .sort()
34
+ .join("\n"));
35
+ if (new Set(signatures).size !== signatures.length) {
36
+ throw new Error("Managed bundle contains a duplicate release digest.");
37
+ }
38
+ const currentPaths = new Set(options.current.files.map((file) => file.path));
39
+ if (options.predecessors.some((release) => release.files.some((file) => !currentPaths.has(file.path)))) {
40
+ throw new Error("Managed bundle predecessor contains an unmanaged current path.");
41
+ }
42
+ for (const file of options.current.files) {
43
+ if (hashBytes(file.content) !== file.sha256) {
44
+ throw new Error(`Managed bundle digest does not match source asset: ${file.path}`);
45
+ }
46
+ }
47
+ const canonicalRoot = path.dirname(await canonicalizeFuturePath(path.join(options.destinationRoot, ".managed-bundle-root")));
48
+ const destinations = await Promise.all(options.current.files.map(async (file) => ({
49
+ file,
50
+ destination: await canonicalizeFuturePath(path.join(options.destinationRoot, file.path)),
51
+ })));
52
+ for (const { destination } of destinations) {
53
+ const relative = path.relative(canonicalRoot, destination);
54
+ if (relative === "" ||
55
+ relative === ".." ||
56
+ relative.startsWith(`..${path.sep}`) ||
57
+ path.isAbsolute(relative)) {
58
+ throw new Error(`Managed bundle destination is outside the bundle root: ${destination}`);
59
+ }
60
+ }
61
+ if (new Set(destinations.map((entry) => entry.destination)).size !== destinations.length) {
62
+ throw new Error("Managed bundle contains a duplicate canonical destination.");
63
+ }
64
+ const entries = await Promise.all(destinations.map(async ({ file, destination }) => ({
65
+ file,
66
+ destination,
67
+ snapshot: await captureManagedFileSnapshot(destination),
68
+ })));
69
+ const exactCurrent = entries.every(({ file, snapshot }) => snapshot.exists && snapshot.sha256 === file.sha256);
70
+ const empty = entries.every(({ snapshot }) => !snapshot.exists);
71
+ const exactPredecessor = options.predecessors.some((release) => entries.every(({ file, snapshot }) => {
72
+ const predecessor = release.files.find((candidate) => candidate.path === file.path);
73
+ return predecessor === undefined
74
+ ? !snapshot.exists
75
+ : snapshot.exists && snapshot.sha256 === predecessor.sha256;
76
+ }));
77
+ if (!exactCurrent && !empty && !exactPredecessor && options.force !== true) {
78
+ throw new ManagedBundleConflictError(entries.find(({ snapshot }) => snapshot.exists)?.destination ?? options.destinationRoot);
79
+ }
80
+ return {
81
+ status: exactCurrent ? "unchanged" : empty ? "installed" : "updated",
82
+ files: entries.map(({ file, destination, snapshot }) => ({
83
+ path: destination,
84
+ snapshot,
85
+ postimage: file.content,
86
+ postimageSha256: file.sha256,
87
+ status: snapshot.exists && snapshot.sha256 === file.sha256
88
+ ? "unchanged"
89
+ : snapshot.exists
90
+ ? "updated"
91
+ : "installed",
92
+ })),
93
+ };
94
+ }
95
+ export async function applyManagedBundle(plan, onApplied, context = createManagedFileApplyContext(), hooks = {}) {
96
+ const preimages = new Map();
97
+ for (const file of plan.files) {
98
+ const preimage = await captureManagedFilePreimage(file.path);
99
+ if (!snapshotsEqual(preimage.snapshot, file.snapshot)) {
100
+ throw new Error(`Managed file changed after preflight: ${file.path}`);
101
+ }
102
+ preimages.set(file, preimage);
103
+ }
104
+ const authenticated = [];
105
+ const mutated = [];
106
+ let active;
107
+ try {
108
+ for (const file of plan.files) {
109
+ active = file;
110
+ for (const current of authenticated)
111
+ await verifyManagedFilePostimage(current, context);
112
+ await applyManagedFilePlan(file, context, hooks);
113
+ authenticated.push(file);
114
+ if (file.status !== "unchanged")
115
+ mutated.push(file);
116
+ await onApplied?.(file);
117
+ active = undefined;
118
+ }
119
+ for (const current of authenticated)
120
+ await verifyManagedFilePostimage(current, context);
121
+ }
122
+ catch (error) {
123
+ if (active !== undefined &&
124
+ active.status !== "unchanged" &&
125
+ error instanceof ManagedFileApplyError &&
126
+ !mutated.includes(active)) {
127
+ mutated.push(active);
128
+ }
129
+ const failures = [];
130
+ for (const file of mutated.reverse()) {
131
+ try {
132
+ await rollbackManagedFilePlan(file, preimages.get(file), context);
133
+ }
134
+ catch (rollbackError) {
135
+ failures.push(rollbackError);
136
+ }
137
+ }
138
+ if (failures.length) {
139
+ throw new AggregateError([error, ...failures], "Managed bundle rollback was incomplete.", {
140
+ cause: error,
141
+ });
142
+ }
143
+ throw error;
144
+ }
145
+ }
@@ -499,6 +499,43 @@ export async function verifyManagedFilePostimage(plan, context) {
499
499
  await directory.close();
500
500
  }
501
501
  }
502
+ export async function rollbackManagedFilePlan(plan, preimage, context) {
503
+ await verifyManagedFilePostimage(plan, context);
504
+ if (preimage.snapshot.exists) {
505
+ if (preimage.content === undefined) {
506
+ throw new Error(`Managed file rollback preimage is unavailable: ${plan.path}`);
507
+ }
508
+ const current = await captureManagedFileSnapshot(plan.path);
509
+ await applyManagedFilePlan({
510
+ path: plan.path,
511
+ snapshot: current,
512
+ postimage: preimage.content,
513
+ postimageSha256: preimage.snapshot.sha256,
514
+ status: "updated",
515
+ }, createManagedFileApplyContext());
516
+ return;
517
+ }
518
+ const directory = await openBoundDirectory(plan.snapshot.directory, false, context);
519
+ try {
520
+ const key = managedFileKey(plan);
521
+ let expected = context.authenticatedFiles.get(key);
522
+ if (expected === undefined) {
523
+ const current = await captureManagedFileSnapshot(plan.path);
524
+ if (!current.exists || current.sha256 !== plan.postimageSha256) {
525
+ throw new Error(`Managed installed file rollback postimage is unavailable: ${plan.path}`);
526
+ }
527
+ expected = { dev: current.dev, ino: current.ino };
528
+ context.authenticatedFiles.set(key, expected);
529
+ }
530
+ if ((await removePathIfOwned(directory, path.basename(plan.path), expected)) !== "removed") {
531
+ throw new Error(`Managed installed file could not be rolled back safely: ${plan.path}`);
532
+ }
533
+ context.authenticatedFiles.delete(key);
534
+ }
535
+ finally {
536
+ await directory.close();
537
+ }
538
+ }
502
539
  export async function applyManagedFilePlan(plan, context = createManagedFileApplyContext(), hooks = {}) {
503
540
  if (plan.status === "unchanged") {
504
541
  await revalidateManagedFilePlan(plan);
@@ -237,11 +237,36 @@ function createSessionWatcher(getSession, projectRoot, directories) {
237
237
  directories,
238
238
  onChange: (files) => {
239
239
  const session = getSession();
240
+ const cachePath = symbolIndexCachePath(session.symbolIndexPolicy, session.context.status.project);
241
+ let sourceFiles = files;
242
+ if (cachePath) {
243
+ const relativeCachePath = path
244
+ .relative(session.context.projectRoot, cachePath)
245
+ .split(path.sep)
246
+ .join("/");
247
+ if (!relativeCachePath.startsWith("../") && !path.isAbsolute(relativeCachePath)) {
248
+ const relativeCacheDirectory = path.posix.dirname(relativeCachePath);
249
+ const quarantinePrefix = `${relativeCachePath}.corrupt-`;
250
+ sourceFiles = files.filter((file) => {
251
+ if (relativeCacheDirectory !== "." && file === relativeCacheDirectory)
252
+ return false;
253
+ if (file === relativeCachePath ||
254
+ file === `${relativeCachePath}-wal` ||
255
+ file === `${relativeCachePath}-shm`) {
256
+ return false;
257
+ }
258
+ return !(file.startsWith(quarantinePrefix) &&
259
+ /^\d+-\d+$/u.test(file.slice(quarantinePrefix.length)));
260
+ });
261
+ }
262
+ }
263
+ if (sourceFiles.length === 0)
264
+ return;
240
265
  session.context = {
241
266
  ...session.context,
242
267
  status: transitionProjectStatus(session.context.status, {
243
268
  type: "source_changed",
244
- files,
269
+ files: sourceFiles,
245
270
  }),
246
271
  };
247
272
  },
@@ -302,32 +327,22 @@ function assertProjectRuntimeAdmissionOpen() {
302
327
  throw new ProjectOperationSchedulerError("SERVER_SHUTTING_DOWN");
303
328
  }
304
329
  }
305
- function getOrCreateSession(projectRoot, requestContext = NO_REQUEST_CONTEXT) {
306
- assertProjectRuntimeAdmissionOpen();
330
+ function createProjectSession(projectRoot, runtimePolicy, symbolIndexPolicy, requestContext, lastAccessSequence) {
307
331
  requestContext.checkpoint();
308
332
  const tsConfigFilePath = resolveTsConfigPath(projectRoot);
309
- const existing = projectSessions.get(tsConfigFilePath);
310
- if (existing)
311
- return existing;
312
- const runtimePolicy = getProjectRuntimePolicy();
313
- ensureProjectSessionCapacity(runtimePolicy.maxProjectSessions);
314
- const symbolIndexPolicy = readSymbolIndexPersistencePolicy();
315
- requestContext.checkpoint();
316
333
  const baseContext = buildProjectContext(tsConfigFilePath);
317
334
  const context = {
318
335
  ...baseContext,
319
336
  symbolIndexObservability: createInitialSymbolIndexRuntimeObservability(symbolIndexPolicy),
320
337
  };
321
338
  const watchDirectories = selectProjectWatchDirectories(context.projectRoot, context.project.getSourceFiles().map((sourceFile) => sourceFile.getFilePath()));
322
- const watcher = createSessionWatcher(() => session, context.projectRoot, watchDirectories);
339
+ const owner = {};
340
+ const watcher = createSessionWatcher(() => owner.session, context.projectRoot, watchDirectories);
323
341
  const session = {
324
342
  context,
325
343
  watcher,
326
344
  watchDirectories,
327
- configDigest: (() => {
328
- requestContext.checkpoint();
329
- return createConfigSnapshot(tsConfigFilePath).digest;
330
- })(),
345
+ configDigest: createConfigSnapshot(tsConfigFilePath).digest,
331
346
  fingerprints: new Map(),
332
347
  compilerStateUntrusted: false,
333
348
  scheduler: new ProjectOperationScheduler({
@@ -335,12 +350,12 @@ function getOrCreateSession(projectRoot, requestContext = NO_REQUEST_CONTEXT) {
335
350
  queueWaitTimeoutMs: runtimePolicy.queueWaitTimeoutMs,
336
351
  operationDeadlineMs: runtimePolicy.operationDeadlineMs,
337
352
  }),
338
- lastAccessSequence: nextProjectSessionAccessSequence(),
353
+ lastAccessSequence,
339
354
  symbolIndexPolicy,
340
355
  };
341
- projectSessions.set(tsConfigFilePath, session);
342
- session.watcher.start();
343
- if (session.watcher.snapshot().state === "ready") {
356
+ owner.session = session;
357
+ watcher.start();
358
+ if (watcher.snapshot().state === "ready") {
344
359
  session.context = {
345
360
  ...session.context,
346
361
  status: transitionProjectStatus(session.context.status, { type: "watcher_recovered" }),
@@ -348,6 +363,20 @@ function getOrCreateSession(projectRoot, requestContext = NO_REQUEST_CONTEXT) {
348
363
  }
349
364
  return session;
350
365
  }
366
+ function getOrCreateSession(projectRoot, requestContext = NO_REQUEST_CONTEXT) {
367
+ assertProjectRuntimeAdmissionOpen();
368
+ requestContext.checkpoint();
369
+ const tsConfigFilePath = resolveTsConfigPath(projectRoot);
370
+ const existing = projectSessions.get(tsConfigFilePath);
371
+ if (existing)
372
+ return existing;
373
+ const runtimePolicy = getProjectRuntimePolicy();
374
+ ensureProjectSessionCapacity(runtimePolicy.maxProjectSessions);
375
+ const symbolIndexPolicy = readSymbolIndexPersistencePolicy();
376
+ const session = createProjectSession(projectRoot, runtimePolicy, symbolIndexPolicy, requestContext, nextProjectSessionAccessSequence());
377
+ projectSessions.set(tsConfigFilePath, session);
378
+ return session;
379
+ }
351
380
  function getQueueStatus(session) {
352
381
  const snapshot = session.scheduler.snapshot();
353
382
  return projectOperationQueueToProjection(snapshot, snapshot.queue_capacity);
@@ -651,9 +680,7 @@ export async function withProjectOperation(projectRoot, operation, requestContex
651
680
  return operation(operationContext);
652
681
  }, { signal: requestContext.signal });
653
682
  }
654
- export async function getProjectStatus(projectRoot, requestContext = NO_REQUEST_CONTEXT) {
655
- requestContext.checkpoint();
656
- const session = getOrCreateSession(projectRoot, requestContext);
683
+ function readProjectStatus(session, requestContext) {
657
684
  return runSessionWithSyncPolicy(session, async (context, operationContext) => {
658
685
  let effectiveContext = context;
659
686
  let indexedCount = 0;
@@ -681,6 +708,55 @@ export async function getProjectStatus(projectRoot, requestContext = NO_REQUEST_
681
708
  };
682
709
  }, true, requestContext);
683
710
  }
711
+ function peekSessionStatus(session) {
712
+ const context = session.context;
713
+ return {
714
+ ...projectStatusToProjection(context.status),
715
+ index: { state: context.symbolIndexObservability.state },
716
+ indexed_count: 0,
717
+ last_successful_index_at: context.symbolIndexObservability.last_successful_persistence_at,
718
+ index_observability: { ...context.symbolIndexObservability },
719
+ operation_queue: getQueueStatus(session),
720
+ };
721
+ }
722
+ export function peekRegisteredProjectStatus(projectRoot) {
723
+ const session = projectSessions.get(resolveTsConfigPath(projectRoot));
724
+ return session ? peekSessionStatus(session) : undefined;
725
+ }
726
+ export async function getProjectStatus(projectRoot, requestContext = NO_REQUEST_CONTEXT) {
727
+ requestContext.checkpoint();
728
+ return readProjectStatus(getOrCreateSession(projectRoot, requestContext), requestContext);
729
+ }
730
+ export async function getDiagnosticProjectStatus(projectRoot, requestContext = NO_REQUEST_CONTEXT) {
731
+ requestContext.checkpoint();
732
+ const runtimePolicy = getProjectRuntimePolicy();
733
+ const registered = peekRegisteredProjectStatus(projectRoot);
734
+ if (registered)
735
+ return {
736
+ authority: "registered",
737
+ runtime_admission: projectRuntimeAdmission,
738
+ session_capacity: runtimePolicy.maxProjectSessions,
739
+ status: registered,
740
+ };
741
+ if (projectRuntimeAdmission === "closed")
742
+ return {
743
+ authority: "unavailable",
744
+ runtime_admission: "closed",
745
+ session_capacity: runtimePolicy.maxProjectSessions,
746
+ };
747
+ const session = createProjectSession(projectRoot, runtimePolicy, readSymbolIndexPersistencePolicy({ AST_SYMBOL_INDEX_PERSISTENCE: "disabled" }), requestContext, 0n);
748
+ try {
749
+ return {
750
+ authority: "isolated",
751
+ runtime_admission: projectRuntimeAdmission,
752
+ session_capacity: runtimePolicy.maxProjectSessions,
753
+ status: await readProjectStatus(session, requestContext),
754
+ };
755
+ }
756
+ finally {
757
+ closeProjectSession(session);
758
+ }
759
+ }
684
760
  // Compatibility API for internal scripts. MCP tools should use withProject().
685
761
  export function getProject(projectRoot) {
686
762
  return createFreshProject(projectRoot).project;
@@ -2,7 +2,8 @@ import { readFile, realpath, stat } from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { z } from "zod";
5
- import { applyManagedFilePlan, canonicalizeFuturePath, captureManagedFileSnapshot, createManagedFileApplyContext, hashBytes, verifyManagedFilePostimage, } from "./managed-file.js";
5
+ import { canonicalizeFuturePath, createManagedFileApplyContext, } from "./managed-file.js";
6
+ import { applyManagedBundle, ManagedBundleConflictError, planManagedBundle, } from "./managed-bundle.js";
6
7
  const SKILL_NAME = "structural-code-editing";
7
8
  const SHA256_PATTERN = /^[0-9a-f]{64}$/;
8
9
  const ReleaseRecordSchema = z
@@ -12,17 +13,24 @@ const ReleaseRecordSchema = z
12
13
  npm_versions: z.array(z.string().min(1)).min(1),
13
14
  })
14
15
  .strict();
15
- const SkillReleaseManifestSchema = z
16
+ const SkillFileSchema = z
17
+ .object({ path: z.string().min(1), sha256: z.string().regex(SHA256_PATTERN) })
18
+ .strict();
19
+ const BundleReleaseSchema = z
20
+ .object({
21
+ version: z.string().min(1),
22
+ files: z.array(SkillFileSchema).min(1),
23
+ })
24
+ .strict();
25
+ const BundleSkillReleaseManifestSchema = z
16
26
  .object({
17
- schema_version: z.literal(1),
27
+ schema_version: z.literal(2),
18
28
  algorithm: z.literal("sha256"),
19
- current: z
20
- .object({
21
- version: z.string().min(1),
22
- sha256: z.string().regex(SHA256_PATTERN),
23
- })
24
- .strict(),
25
- predecessors: z.array(ReleaseRecordSchema),
29
+ current: BundleReleaseSchema,
30
+ predecessors: z.array(z.union([
31
+ ReleaseRecordSchema,
32
+ BundleReleaseSchema.extend({ npm_versions: z.array(z.string().min(1)).min(1) }),
33
+ ])),
26
34
  })
27
35
  .strict();
28
36
  export class SkillConflictError extends Error {
@@ -101,77 +109,87 @@ async function readReleaseManifest(manifestPath) {
101
109
  catch (error) {
102
110
  throw new Error(`Skill release manifest is unreadable or invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
103
111
  }
104
- const result = SkillReleaseManifestSchema.safeParse(parsed);
112
+ const result = BundleSkillReleaseManifestSchema.safeParse(parsed);
105
113
  if (!result.success) {
106
114
  throw new Error(`Skill release manifest is invalid: ${result.error.issues[0]?.message ?? "unknown error"}`);
107
115
  }
108
- const digests = [
109
- result.data.current.sha256,
110
- ...result.data.predecessors.map((item) => item.sha256),
111
- ];
112
- if (new Set(digests).size !== digests.length) {
113
- throw new Error("Skill release manifest contains a duplicate digest.");
116
+ const manifest = {
117
+ current: result.data.current,
118
+ predecessors: result.data.predecessors.map((item) => "files" in item
119
+ ? item
120
+ : {
121
+ version: item.version,
122
+ npm_versions: item.npm_versions,
123
+ files: [{ path: "SKILL.md", sha256: item.sha256 }],
124
+ }),
125
+ };
126
+ if (!manifest.current.files.some((file) => file.path === "SKILL.md")) {
127
+ throw new Error("Skill release manifest current bundle must include SKILL.md.");
114
128
  }
115
129
  const predecessorVersions = result.data.predecessors.flatMap((item) => item.npm_versions);
116
130
  if (new Set(predecessorVersions).size !== predecessorVersions.length) {
117
131
  throw new Error("Skill release manifest contains a duplicate npm version provenance entry.");
118
132
  }
119
- return result.data;
133
+ return manifest;
120
134
  }
121
135
  export async function planBundledSkillInstallation(options) {
122
136
  const sourcePath = path.resolve(options.sourceSkillPath);
123
- const source = await readFile(sourcePath);
124
- const sourceDigest = hashBytes(source);
125
- const manifestPath = path.resolve(options.releaseManifestPath ?? path.join(path.dirname(sourcePath), "releases.json"));
137
+ const sourceRoot = path.dirname(sourcePath);
138
+ const canonicalSourceRoot = await realpath(sourceRoot);
139
+ const manifestPath = path.resolve(options.releaseManifestPath ?? path.join(sourceRoot, "releases.json"));
126
140
  const manifest = await readReleaseManifest(manifestPath);
127
- if (sourceDigest !== manifest.current.sha256) {
128
- throw new Error(`Skill release manifest current digest does not match source skill bytes: ${sourcePath}`);
141
+ const sources = [];
142
+ for (const file of manifest.current.files) {
143
+ const assetPath = path.resolve(sourceRoot, file.path);
144
+ const assetRealPath = await realpath(assetPath).catch(() => undefined);
145
+ if (assetRealPath === undefined ||
146
+ (assetRealPath !== canonicalSourceRoot &&
147
+ !assetRealPath.startsWith(`${canonicalSourceRoot}${path.sep}`)) ||
148
+ !(await stat(assetPath)).isFile()) {
149
+ throw new Error(`Skill release manifest asset is missing or escapes the bundle: ${file.path}`);
150
+ }
151
+ sources.push({ ...file, content: await readFile(assetPath) });
129
152
  }
130
- const admittedPredecessors = new Set(manifest.predecessors.map((item) => item.sha256));
131
153
  const logical = await destinations(options);
132
154
  const filesByPath = new Map();
155
+ const statusesByRoot = new Map();
133
156
  for (const item of logical) {
134
- if (filesByPath.has(item.path))
157
+ const destinationRoot = path.dirname(item.path);
158
+ if (statusesByRoot.has(destinationRoot))
135
159
  continue;
136
- const snapshot = await captureManagedFileSnapshot(item.path);
137
- let status;
138
- if (!snapshot.exists)
139
- status = "installed";
140
- else if (snapshot.sha256 === sourceDigest)
141
- status = "unchanged";
142
- else if (admittedPredecessors.has(snapshot.sha256) || options.force === true)
143
- status = "updated";
144
- else
145
- throw new SkillConflictError(item.path);
146
- filesByPath.set(item.path, {
147
- path: item.path,
148
- snapshot,
149
- postimage: source,
150
- postimageSha256: sourceDigest,
151
- status,
152
- });
160
+ let plan;
161
+ try {
162
+ plan = await planManagedBundle({
163
+ destinationRoot,
164
+ current: { files: sources },
165
+ predecessors: manifest.predecessors,
166
+ force: options.force,
167
+ });
168
+ }
169
+ catch (error) {
170
+ if (error instanceof ManagedBundleConflictError)
171
+ throw new SkillConflictError(error.destination);
172
+ if (error instanceof Error && error.message.startsWith("Managed bundle")) {
173
+ throw new Error(error.message.replace("Managed bundle", "Skill release manifest"), {
174
+ cause: error,
175
+ });
176
+ }
177
+ throw error;
178
+ }
179
+ statusesByRoot.set(destinationRoot, plan.status);
180
+ for (const file of plan.files)
181
+ filesByPath.set(file.path, file);
153
182
  }
154
183
  return {
155
184
  installations: logical.map((item) => ({
156
185
  ...item,
157
- status: filesByPath.get(item.path).status,
186
+ status: statusesByRoot.get(path.dirname(item.path)),
158
187
  })),
159
188
  files: [...filesByPath.values()],
160
189
  };
161
190
  }
162
- export async function applySkillInstallationPlan(plan, onApplied, context = createManagedFileApplyContext()) {
163
- const authenticated = [];
164
- for (const file of plan.files) {
165
- for (const current of authenticated) {
166
- await verifyManagedFilePostimage(current, context);
167
- }
168
- await applyManagedFilePlan(file, context);
169
- authenticated.push(file);
170
- await onApplied?.(file);
171
- }
172
- for (const current of authenticated) {
173
- await verifyManagedFilePostimage(current, context);
174
- }
191
+ export async function applySkillInstallationPlan(plan, onApplied, context = createManagedFileApplyContext(), hooks = {}) {
192
+ await applyManagedBundle(plan, onApplied, context, hooks);
175
193
  }
176
194
  export async function installBundledSkill(options) {
177
195
  const plan = await planBundledSkillInstallation(options);
@@ -0,0 +1,77 @@
1
+ import { inspectUpgrade, performUpgrade, UpgradeError } from "./upgrade.js";
2
+ import { ReconciliationError } from "./upgrade-reconcile.js";
3
+ import { UpgradeCleanupError } from "./upgrade-runtime.js";
4
+ export class UpgradeCommandError extends Error {
5
+ code;
6
+ constructor(code = "UPGRADE_USAGE") {
7
+ super("Usage: ast-tool upgrade [--check]");
8
+ this.code = code;
9
+ this.name = "UpgradeCommandError";
10
+ }
11
+ }
12
+ export async function runUpgradeCommand(args, runtime, reconcile) {
13
+ if (args.length > 1 || (args.length === 1 && args[0] !== "--check")) {
14
+ throw new UpgradeCommandError();
15
+ }
16
+ if (args[0] === "--check")
17
+ return inspectUpgrade(runtime);
18
+ const updated = await performUpgrade(runtime);
19
+ const { new_cli: newCli, ...result } = updated;
20
+ if (updated.status === "current")
21
+ return { ...result, reconciliation: "not_required" };
22
+ try {
23
+ await reconcile(newCli);
24
+ return { ...result, reconciliation: "completed" };
25
+ }
26
+ catch (error) {
27
+ const skillConflict = error instanceof ReconciliationError && error.code === "AGENT_SKILL_CONFLICT";
28
+ return {
29
+ ...result,
30
+ status: "partial",
31
+ package_status: "updated",
32
+ reconciliation: "required",
33
+ continuation: skillConflict
34
+ ? "ast-tool setup --agents all --yes --force-skill"
35
+ : "ast-tool setup --agents all --yes",
36
+ restart_required: true,
37
+ };
38
+ }
39
+ }
40
+ function primaryCode(error) {
41
+ return error instanceof UpgradeError || error instanceof UpgradeCommandError
42
+ ? error.code
43
+ : "UPGRADE_ERROR";
44
+ }
45
+ export async function runUpgradeLease(args, lease, reconcile) {
46
+ let result;
47
+ let primary;
48
+ try {
49
+ result = await runUpgradeCommand(args, lease.runtime, reconcile);
50
+ }
51
+ catch (error) {
52
+ primary = error;
53
+ }
54
+ try {
55
+ await lease.dispose();
56
+ }
57
+ catch {
58
+ if (primary)
59
+ throw new UpgradeCleanupError(primaryCode(primary));
60
+ const record = result;
61
+ if (record.status === "updated" || record.package_status === "updated") {
62
+ return {
63
+ ...record,
64
+ status: "partial",
65
+ package_status: "updated",
66
+ cleanup: "required",
67
+ cleanup_code: "UPGRADE_CLEANUP_FAILED",
68
+ continuation: record.continuation ?? "ast-tool upgrade --check",
69
+ restart_required: true,
70
+ };
71
+ }
72
+ throw new UpgradeCleanupError();
73
+ }
74
+ if (primary)
75
+ throw primary;
76
+ return result;
77
+ }
@@ -0,0 +1,41 @@
1
+ import { execFile } from "node:child_process";
2
+ import process from "node:process";
3
+ import { promisify } from "node:util";
4
+ const executeFile = promisify(execFile);
5
+ export class ReconciliationError extends Error {
6
+ code;
7
+ constructor(code) {
8
+ super("The updated package requires managed setup reconciliation.");
9
+ this.code = code;
10
+ this.name = "ReconciliationError";
11
+ }
12
+ }
13
+ export async function reconcileUpgrade(executable, options = {}) {
14
+ const execute = options.execute ?? executeFile;
15
+ try {
16
+ await execute(options.nodeExecutable ?? process.execPath, [executable, "setup", "--agents", "all", "--yes"], {
17
+ encoding: "utf8",
18
+ windowsHide: true,
19
+ env: options.environment ?? process.env,
20
+ });
21
+ }
22
+ catch (error) {
23
+ const stderr = typeof error === "object" && error !== null && "stderr" in error
24
+ ? String(error.stderr ?? "")
25
+ : "";
26
+ try {
27
+ const failure = JSON.parse(stderr);
28
+ if (typeof failure === "object" &&
29
+ failure !== null &&
30
+ "code" in failure &&
31
+ failure.code === "AGENT_SKILL_CONFLICT") {
32
+ throw new ReconciliationError("AGENT_SKILL_CONFLICT");
33
+ }
34
+ }
35
+ catch (parsed) {
36
+ if (parsed instanceof ReconciliationError)
37
+ throw parsed;
38
+ }
39
+ throw new ReconciliationError("UPGRADE_RECONCILIATION_FAILED");
40
+ }
41
+ }