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.
- package/CHANGELOG.md +30 -2
- package/README.md +27 -2
- package/SECURITY.md +1 -1
- package/dist/cli.js +67 -1
- package/dist/services/agent-setup.js +11 -5
- package/dist/services/cli-project-discovery.js +97 -0
- package/dist/services/doctor-continuation.js +37 -0
- package/dist/services/doctor-model.js +77 -0
- package/dist/services/doctor.js +111 -0
- package/dist/services/managed-bundle.js +145 -0
- package/dist/services/managed-file.js +37 -0
- package/dist/services/project.js +98 -22
- package/dist/services/skill-installer.js +74 -56
- package/dist/services/upgrade-command.js +77 -0
- package/dist/services/upgrade-reconcile.js +41 -0
- package/dist/services/upgrade-runtime.js +157 -0
- package/dist/services/upgrade.js +232 -0
- package/docs/support.md +13 -13
- package/package.json +2 -2
- package/skills/structural-code-editing/SKILL.md +30 -160
- package/skills/structural-code-editing/references/operations.md +53 -0
- package/skills/structural-code-editing/releases.json +17 -3
|
@@ -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);
|
package/dist/services/project.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
353
|
+
lastAccessSequence,
|
|
339
354
|
symbolIndexPolicy,
|
|
340
355
|
};
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
if (
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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(
|
|
27
|
+
schema_version: z.literal(2),
|
|
18
28
|
algorithm: z.literal("sha256"),
|
|
19
|
-
current:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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 =
|
|
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
|
|
109
|
-
result.data.current
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
|
133
|
+
return manifest;
|
|
120
134
|
}
|
|
121
135
|
export async function planBundledSkillInstallation(options) {
|
|
122
136
|
const sourcePath = path.resolve(options.sourceSkillPath);
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const manifestPath = path.resolve(options.releaseManifestPath ?? path.join(
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
157
|
+
const destinationRoot = path.dirname(item.path);
|
|
158
|
+
if (statusesByRoot.has(destinationRoot))
|
|
135
159
|
continue;
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
+
}
|