@multileaf/ai-workflow 0.1.0 → 0.1.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 +20 -1
- package/README.md +322 -13
- package/dist/adapter.d.ts +1 -0
- package/dist/adapter.js +37 -1
- package/dist/bootstrap-resources.d.ts +9 -0
- package/dist/bootstrap-resources.js +29 -0
- package/dist/constants.js +1 -0
- package/dist/files.d.ts +10 -0
- package/dist/files.js +216 -7
- package/dist/interpreter.js +47 -6
- package/dist/lockfile.d.ts +1 -1
- package/dist/lockfile.js +6 -2
- package/dist/migration.d.ts +21 -2
- package/dist/migration.js +162 -21
- package/dist/ownership.d.ts +14 -0
- package/dist/ownership.js +83 -0
- package/dist/package-audit.d.ts +1 -0
- package/dist/package-audit.js +7 -3
- package/dist/package-contract.d.ts +1 -0
- package/dist/package-contract.js +136 -74
- package/dist/package-integrity.d.ts +5 -1
- package/dist/package-integrity.js +56 -1
- package/dist/providers.d.ts +2 -0
- package/dist/providers.js +27 -5
- package/dist/scanner.js +28 -26
- package/dist/types.d.ts +2 -0
- package/dist/workflow.js +588 -136
- package/docs/adapter-model.md +3 -1
- package/docs/backlog.md +2 -2
- package/docs/package-model.md +3 -3
- package/docs/project-intelligence.md +3 -1
- package/docs/release-backlog.md +1 -1
- package/docs/security.md +4 -0
- package/package.json +9 -5
- package/resources/package.yaml +37 -0
package/dist/workflow.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
1
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
2
2
|
import { BASE_DIRECTORIES, WORKFLOW_DIRECTORY } from "./constants.js";
|
|
3
|
-
import { detectTarget,
|
|
3
|
+
import { detectTarget, isTarget, renderAiInit, renderAiInitPath, renderResourcePath, } from "./adapter.js";
|
|
4
|
+
import { loadBootstrapResources } from "./bootstrap-resources.js";
|
|
5
|
+
import { assertSafeProjectPath, withSafeProjectWrites, writeProjectFilesAtomically, } from "./files.js";
|
|
4
6
|
import { parseProjectProfile, profileProject } from "./profile.js";
|
|
5
7
|
import { serializeOverrides } from "./confirmation.js";
|
|
6
8
|
import { applyOverrides, parseOverrides } from "./confirmation.js";
|
|
@@ -12,16 +14,17 @@ import { createInterface } from "node:readline/promises";
|
|
|
12
14
|
import { stdin, stdout } from "node:process";
|
|
13
15
|
import { generateProjectResources } from "./generator.js";
|
|
14
16
|
import { detectDrift } from "./drift.js";
|
|
15
|
-
import { parseManifest } from "./manifest.js";
|
|
17
|
+
import { parseManifest, serializeManifest } from "./manifest.js";
|
|
18
|
+
import { createOwnedFile, parseOwnership, serializeOwnership, } from "./ownership.js";
|
|
16
19
|
import { validatePackageContract } from "./package-contract.js";
|
|
17
20
|
import { parseLock, resolvePackage, serializeLock } from "./lockfile.js";
|
|
18
21
|
import { executeVercelSkills, installVercelSkill, nodeCommandExecutor } from "./vercel-skills.js";
|
|
19
22
|
import { scanProject } from "./scanner.js";
|
|
20
|
-
import { assertKnownPermissions, assertPackagePermissions, serializePermissionReview, } from "./package-audit.js";
|
|
23
|
+
import { assertKnownPermissions, assertPermissions, assertPackagePermissions, serializePermissionReview, } from "./package-audit.js";
|
|
21
24
|
import { planPackageUpdate } from "./package-updates.js";
|
|
22
25
|
import { searchRegistry, serializeRegistry } from "./registry.js";
|
|
23
|
-
import { checksumPackage, verifyPackageProvenance } from "./package-integrity.js";
|
|
24
|
-
import { nodePackageSourceLoader, resolveProvider } from "./providers.js";
|
|
26
|
+
import { checksumPackage, checksumPackageSnapshot, verifyPackageProvenance, } from "./package-integrity.js";
|
|
27
|
+
import { nodePackageSourceLoader, requiresExternalNetwork, resolveProvider } from "./providers.js";
|
|
25
28
|
import { serializeSelfValidationEvidence, validateTicketId } from "./self-validation.js";
|
|
26
29
|
import { enforceOrganizationPolicy, parseOrganizationPolicy, serializeOrganizationPolicy, } from "./organization-policy.js";
|
|
27
30
|
import { composeContext, CONTEXT_LAYERS, readContextStore, writeContextLayer, } from "./context-store.js";
|
|
@@ -32,7 +35,7 @@ import { measureContextQuality, serializeContextQuality } from "./context-qualit
|
|
|
32
35
|
import { serializeAdapterCapabilities } from "./adapter-contract.js";
|
|
33
36
|
import { buildTraceabilityGraph, serializeTraceabilityGraph } from "./traceability.js";
|
|
34
37
|
import { evaluateQualityGate } from "./quality-gates.js";
|
|
35
|
-
import {
|
|
38
|
+
import { parseMigrationCheckpoint, planNeutralResourceMigration, readResourceBytes, serializeMigrationPreview, serializeMigrationCheckpoint, validateNeutralResourceDocument, writeResourceBytes, } from "./migration.js";
|
|
36
39
|
import { parsePrivateRegistries, parseTeamPreset, serializePrivateRegistries, serializeTeamPreset, fetchPrivateRegistryClient, } from "./team-configuration.js";
|
|
37
40
|
import { enforcePolicyGate, gitPolicyTracking } from "./policy-gate.js";
|
|
38
41
|
import { DEFAULT_TELEMETRY_CONFIG, parseTelemetryConfig, serializeTelemetryConfig, } from "./telemetry.js";
|
|
@@ -41,6 +44,7 @@ import { executeOrchestrationPlan, parseOrchestrationPlan, serializeOrchestratio
|
|
|
41
44
|
export async function runCommand(args, root, fs, services = {}) {
|
|
42
45
|
const aiw = join(root, WORKFLOW_DIRECTORY);
|
|
43
46
|
try {
|
|
47
|
+
fs = withSafeProjectWrites(fs, root);
|
|
44
48
|
const command = args[0] ?? "help";
|
|
45
49
|
if (command === "ui") {
|
|
46
50
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
@@ -110,23 +114,101 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
110
114
|
const target = requestedTarget || detectTarget(root, fs) || "codex";
|
|
111
115
|
if (!isTarget(target))
|
|
112
116
|
throw new Error(`Unsupported target: ${target}`);
|
|
113
|
-
|
|
114
|
-
|
|
117
|
+
const manifestPath = join(aiw, "manifest.yml");
|
|
118
|
+
assertSafeProjectPath(fs, root, manifestPath);
|
|
119
|
+
if (fs.pathType?.(manifestPath) === "file") {
|
|
120
|
+
const manifest = fs.read(manifestPath);
|
|
115
121
|
const active = manifest.match(/^\s*active:\s*(.+)$/m)?.[1]?.trim() ?? "unknown";
|
|
116
122
|
return {
|
|
117
123
|
exitCode: 0,
|
|
118
124
|
output: `AI Workflow is already installed for target: ${active}. Existing state preserved.`,
|
|
119
125
|
};
|
|
120
126
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
127
|
+
const bootstrapResources = loadBootstrapResources(target);
|
|
128
|
+
const desiredResources = new Map();
|
|
129
|
+
const ownershipMetadata = new Map();
|
|
130
|
+
bootstrapResources.forEach(({ type, id, path, content }) => {
|
|
131
|
+
const targetPath = join(root, path);
|
|
132
|
+
const neutralPath = join(root, renderResourcePath("universal", type, id));
|
|
133
|
+
desiredResources.set(targetPath, content);
|
|
134
|
+
desiredResources.set(neutralPath, content);
|
|
135
|
+
ownershipMetadata.set(neutralPath, {
|
|
136
|
+
kind: "neutral-resource",
|
|
137
|
+
target: "universal",
|
|
138
|
+
resourceType: type,
|
|
139
|
+
resourceId: id,
|
|
140
|
+
});
|
|
141
|
+
if (targetPath !== neutralPath)
|
|
142
|
+
ownershipMetadata.set(targetPath, {
|
|
143
|
+
kind: "target-resource",
|
|
144
|
+
target,
|
|
145
|
+
resourceType: type,
|
|
146
|
+
resourceId: id,
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
const conflicts = [];
|
|
150
|
+
const pendingWrites = new Map();
|
|
151
|
+
const inspectDesiredFile = (path, content, allowExisting = false) => {
|
|
152
|
+
assertSafeProjectPath(fs, root, path);
|
|
153
|
+
const type = fs.pathType?.(path);
|
|
154
|
+
if (type === "missing") {
|
|
155
|
+
pendingWrites.set(path, content);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (type === "file" && (allowExisting || fs.read(path) === content))
|
|
159
|
+
return;
|
|
160
|
+
conflicts.push(path);
|
|
161
|
+
};
|
|
162
|
+
for (const [path, content] of desiredResources)
|
|
163
|
+
inspectDesiredFile(path, content);
|
|
164
|
+
if (conflicts.length)
|
|
165
|
+
return {
|
|
166
|
+
exitCode: 1,
|
|
167
|
+
error: `Installation found existing target resources: ${conflicts.join(", ")}. Review and move them before retrying.`,
|
|
168
|
+
};
|
|
169
|
+
const aiInitPath = join(root, renderAiInitPath(target));
|
|
170
|
+
ownershipMetadata.set(aiInitPath, {
|
|
171
|
+
kind: "ai-init",
|
|
172
|
+
target,
|
|
173
|
+
resourceType: "skills",
|
|
174
|
+
resourceId: "ai-init",
|
|
175
|
+
});
|
|
176
|
+
inspectDesiredFile(aiInitPath, renderAiInit(target));
|
|
177
|
+
if (conflicts.length)
|
|
178
|
+
return {
|
|
179
|
+
exitCode: 1,
|
|
180
|
+
error: `Installation would overwrite an existing file: ${conflicts.join(", ")}. Move it or merge its contents, then retry.`,
|
|
181
|
+
};
|
|
182
|
+
inspectDesiredFile(join(aiw, "profile.yml"), "schema: 1\nstatus: pending-scan\n");
|
|
183
|
+
inspectDesiredFile(join(aiw, "lock.yml"), "schema: 1\npackages:\n");
|
|
184
|
+
inspectDesiredFile(manifestPath, "");
|
|
185
|
+
const adrIndex = join(root, ".context/adrs/INDEX.md");
|
|
186
|
+
assertSafeProjectPath(fs, root, adrIndex);
|
|
187
|
+
if (fs.pathType?.(adrIndex) === "missing")
|
|
188
|
+
pendingWrites.set(adrIndex, "# Architecture Decision Records\n\n");
|
|
189
|
+
const ownershipPath = join(aiw, "ownership.yml");
|
|
190
|
+
assertSafeProjectPath(fs, root, ownershipPath);
|
|
191
|
+
if (fs.exists(ownershipPath))
|
|
192
|
+
conflicts.push(ownershipPath);
|
|
193
|
+
if (conflicts.length)
|
|
194
|
+
return {
|
|
195
|
+
exitCode: 1,
|
|
196
|
+
error: `Installation found conflicting project state: ${conflicts.join(", ")}. Review and move it before retrying.`,
|
|
197
|
+
};
|
|
198
|
+
const ownedPaths = new Set([...desiredResources.keys(), aiInitPath]);
|
|
199
|
+
if (pendingWrites.has(adrIndex))
|
|
200
|
+
ownedPaths.add(adrIndex);
|
|
201
|
+
const ownedFiles = [...ownedPaths]
|
|
202
|
+
.filter((path) => pendingWrites.has(path))
|
|
203
|
+
.map((path) => createOwnedFile(relative(root, path).split(sep).join("/"), pendingWrites.get(path), ownershipMetadata.get(path) ?? { kind: "supporting-file" }));
|
|
204
|
+
pendingWrites.set(ownershipPath, serializeOwnership(ownedFiles));
|
|
205
|
+
pendingWrites.delete(manifestPath);
|
|
206
|
+
pendingWrites.set(manifestPath, `schema: 1\nproject:\n name: ${root.split("/").pop()}\ntarget:\n active: ${target}\npolicies:\n artifact_language: en\n`);
|
|
207
|
+
writeProjectFilesAtomically(fs, root, [...pendingWrites].map(([path, content]) => ({ path, content })), [...BASE_DIRECTORIES.map((directory) => join(aiw, directory)), join(root, ".context/adrs")]);
|
|
208
|
+
return {
|
|
209
|
+
exitCode: 0,
|
|
210
|
+
output: `AI Workflow installed for target: ${target}; activated ${bootstrapResources.length} bundled workflow resources. Run the ai-init skill to scan the project, review recommendations, and begin the SDD gates.`,
|
|
211
|
+
};
|
|
130
212
|
}
|
|
131
213
|
if (command === "telemetry") {
|
|
132
214
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
@@ -232,131 +314,329 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
232
314
|
return { exitCode: 1, error: "Usage: aiw target <target>" };
|
|
233
315
|
if (!isTarget(target))
|
|
234
316
|
return { exitCode: 1, error: `Unsupported target: ${target}` };
|
|
235
|
-
|
|
317
|
+
const manifestPath = join(aiw, "manifest.yml");
|
|
318
|
+
if (!fs.exists(manifestPath))
|
|
236
319
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
237
320
|
const dryRun = args.includes("--dry-run");
|
|
238
321
|
const targetPath = join(root, renderAiInitPath(target));
|
|
239
322
|
const neutralAiInit = join(aiw, "resources/skills/ai-init.md");
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
? join(root, renderAiInitPath(previousTarget))
|
|
247
|
-
: undefined;
|
|
248
|
-
const universalConflict = target === "universal" &&
|
|
249
|
-
previousTargetPath !== undefined &&
|
|
250
|
-
fs.exists(previousTargetPath) &&
|
|
251
|
-
fs.exists(neutralAiInit) &&
|
|
252
|
-
fs.read(previousTargetPath) !== fs.read(neutralAiInit);
|
|
253
|
-
const resourceMigrations = planNeutralResourceMigration(join(aiw, "resources"), fs.exists(join(aiw, "resources")) ? (fs.listFiles?.(join(aiw, "resources")) ?? []) : [], target);
|
|
254
|
-
const resourceConflicts = resourceMigrations.filter(({ source, destination }) => {
|
|
255
|
-
const absoluteDestination = join(root, destination);
|
|
256
|
-
return (source !== absoluteDestination &&
|
|
257
|
-
fs.exists(absoluteDestination) &&
|
|
258
|
-
Buffer.compare(Buffer.from(readResourceBytes(fs, source)), Buffer.from(readResourceBytes(fs, absoluteDestination))) !== 0);
|
|
259
|
-
});
|
|
260
|
-
if (dryRun) {
|
|
261
|
-
const existing = resourceMigrations
|
|
262
|
-
.filter(({ destination }) => fs.exists(join(root, destination)))
|
|
263
|
-
.map(({ destination }) => destination);
|
|
264
|
-
const conflictPaths = resourceConflicts.map(({ destination }) => destination);
|
|
265
|
-
if (universalConflict)
|
|
266
|
-
conflictPaths.push(renderAiInitPath(target));
|
|
323
|
+
assertSafeProjectPath(fs, root, manifestPath);
|
|
324
|
+
const manifest = parseManifest(fs.read(manifestPath));
|
|
325
|
+
const previousTarget = manifest.target.active;
|
|
326
|
+
const previousTargetPath = join(root, renderAiInitPath(previousTarget));
|
|
327
|
+
const inventoryPath = join(aiw, "ownership.yml");
|
|
328
|
+
if (!fs.exists(inventoryPath))
|
|
267
329
|
return {
|
|
268
|
-
exitCode:
|
|
269
|
-
|
|
330
|
+
exitCode: 1,
|
|
331
|
+
error: "AIW ownership inventory is required for safe target migration.",
|
|
270
332
|
};
|
|
333
|
+
assertSafeProjectPath(fs, root, inventoryPath);
|
|
334
|
+
if (fs.pathType?.(inventoryPath) !== "file")
|
|
335
|
+
return { exitCode: 1, error: "AIW ownership inventory is not a regular project file." };
|
|
336
|
+
const inventory = parseOwnership(fs.read(inventoryPath));
|
|
337
|
+
const ownedByPath = new Map(inventory.map((entry) => [entry.path, entry]));
|
|
338
|
+
const resourceRoot = join(aiw, "resources");
|
|
339
|
+
if (fs.exists(resourceRoot))
|
|
340
|
+
assertSafeProjectPath(fs, root, resourceRoot);
|
|
341
|
+
const relativeFiles = fs.exists(resourceRoot)
|
|
342
|
+
? (fs.listFiles?.(resourceRoot) ?? []).filter((path) => path !== "skills/ai-init.md")
|
|
343
|
+
: [];
|
|
344
|
+
const plan = planNeutralResourceMigration(resourceRoot, relativeFiles ?? [], target);
|
|
345
|
+
const diagnostics = [...plan.diagnostics];
|
|
346
|
+
if (fs.exists(resourceRoot) && !fs.listFiles)
|
|
347
|
+
diagnostics.push({
|
|
348
|
+
path: ".aiw/resources",
|
|
349
|
+
reason: "filesystem cannot enumerate neutral resources safely",
|
|
350
|
+
});
|
|
351
|
+
const migrations = [];
|
|
352
|
+
const migrationBytes = new Map();
|
|
353
|
+
for (const migration of plan.migrations) {
|
|
354
|
+
const relativeSource = relative(resourceRoot, migration.source).split(sep).join("/");
|
|
355
|
+
try {
|
|
356
|
+
assertSafeProjectPath(fs, root, migration.source);
|
|
357
|
+
if (!fs.exists(migration.source) || fs.pathType?.(migration.source) !== "file") {
|
|
358
|
+
diagnostics.push({
|
|
359
|
+
path: relativeSource,
|
|
360
|
+
reason: "resource is missing or is not a regular file",
|
|
361
|
+
});
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const bytes = readResourceBytes(fs, migration.source);
|
|
365
|
+
const reason = validateNeutralResourceDocument(relativeSource, bytes);
|
|
366
|
+
if (reason) {
|
|
367
|
+
diagnostics.push({ path: relativeSource, reason });
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
migrations.push(migration);
|
|
371
|
+
migrationBytes.set(migration.source, bytes);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
diagnostics.push({ path: relativeSource, reason: "resource cannot be read safely" });
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const conflicts = new Set();
|
|
378
|
+
const removals = new Set();
|
|
379
|
+
const fileChanges = new Map();
|
|
380
|
+
const resourceDestinations = new Set();
|
|
381
|
+
for (const migration of migrations) {
|
|
382
|
+
const destination = join(root, migration.destination);
|
|
383
|
+
resourceDestinations.add(destination);
|
|
384
|
+
const desiredBytes = migrationBytes.get(migration.source);
|
|
385
|
+
inspectMigrationDestination(destination, desiredBytes);
|
|
386
|
+
}
|
|
387
|
+
const hasNeutralAiInit = fs.exists(neutralAiInit);
|
|
388
|
+
let aiInitBytes = Buffer.from(renderAiInit(target), "utf8");
|
|
389
|
+
if (hasNeutralAiInit) {
|
|
390
|
+
try {
|
|
391
|
+
assertSafeProjectPath(fs, root, neutralAiInit);
|
|
392
|
+
if (fs.pathType?.(neutralAiInit) !== "file")
|
|
393
|
+
diagnostics.push({
|
|
394
|
+
path: ".aiw/resources/skills/ai-init.md",
|
|
395
|
+
reason: "resource is not a regular file",
|
|
396
|
+
});
|
|
397
|
+
else
|
|
398
|
+
aiInitBytes = readResourceBytes(fs, neutralAiInit);
|
|
399
|
+
}
|
|
400
|
+
catch {
|
|
401
|
+
diagnostics.push({
|
|
402
|
+
path: ".aiw/resources/skills/ai-init.md",
|
|
403
|
+
reason: "resource cannot be read safely",
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
const aiInitDiagnostic = validateNeutralResourceDocument("skills/ai-init/SKILL.md", aiInitBytes);
|
|
408
|
+
if (hasNeutralAiInit && aiInitDiagnostic)
|
|
409
|
+
diagnostics.push({ path: ".aiw/resources/skills/ai-init.md", reason: aiInitDiagnostic });
|
|
410
|
+
const targetAiPath = targetPath;
|
|
411
|
+
resourceDestinations.add(targetAiPath);
|
|
412
|
+
inspectMigrationDestination(targetAiPath, aiInitBytes);
|
|
413
|
+
const previousTargetEntries = previousTarget === "universal"
|
|
414
|
+
? []
|
|
415
|
+
: inventory.filter((entry) => (entry.kind === "target-resource" || entry.kind === "ai-init") &&
|
|
416
|
+
entry.target === previousTarget);
|
|
417
|
+
const oldTargetPaths = new Set(previousTargetEntries.map(({ path }) => resolve(root, path)));
|
|
418
|
+
if (previousTarget !== "universal") {
|
|
419
|
+
for (const migration of migrations) {
|
|
420
|
+
const oldPath = join(root, renderResourcePath(previousTarget, migration.type, migration.id));
|
|
421
|
+
oldTargetPaths.add(oldPath);
|
|
422
|
+
if (oldPath !== join(root, migration.destination))
|
|
423
|
+
inspectOwnedRemoval(oldPath);
|
|
424
|
+
}
|
|
425
|
+
if (previousTargetPath !== targetPath)
|
|
426
|
+
inspectOwnedRemoval(previousTargetPath);
|
|
427
|
+
}
|
|
428
|
+
for (const oldPath of oldTargetPaths) {
|
|
429
|
+
if (resourceDestinations.has(oldPath))
|
|
430
|
+
continue;
|
|
431
|
+
if (fs.exists(oldPath))
|
|
432
|
+
inspectOwnedRemoval(oldPath);
|
|
433
|
+
else
|
|
434
|
+
removals.add(relative(root, oldPath).split(sep).join("/"));
|
|
271
435
|
}
|
|
272
|
-
|
|
273
|
-
(
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
436
|
+
const existingDestinations = [...resourceDestinations]
|
|
437
|
+
.filter((path) => fs.exists(path))
|
|
438
|
+
.map((path) => relative(root, path).split(sep).join("/"));
|
|
439
|
+
const conflictPaths = [...conflicts].map((path) => relative(root, path).split(sep).join("/"));
|
|
440
|
+
const removalPaths = [...removals];
|
|
441
|
+
const previewActions = [
|
|
442
|
+
target === "universal" && hasNeutralAiInit
|
|
443
|
+
? "keep: .aiw/resources/skills/ai-init.md (neutral source)"
|
|
444
|
+
: `${fs.exists(targetAiPath) ? "update" : "add"}: ${relative(root, targetAiPath).split(sep).join("/")}`,
|
|
445
|
+
...(hasNeutralAiInit && target !== "universal"
|
|
446
|
+
? ["keep: .aiw/resources/skills/ai-init.md (neutral source)"]
|
|
447
|
+
: []),
|
|
448
|
+
];
|
|
449
|
+
const preview = serializeMigrationPreview(target, migrations, existingDestinations, removalPaths, diagnostics, conflictPaths, previewActions);
|
|
450
|
+
if (dryRun)
|
|
451
|
+
return { exitCode: diagnostics.length || conflicts.size ? 1 : 0, output: preview };
|
|
452
|
+
if (diagnostics.length || conflicts.size)
|
|
278
453
|
return {
|
|
279
454
|
exitCode: 1,
|
|
280
|
-
error: `Migration
|
|
281
|
-
};
|
|
282
|
-
const removePrevious = previousTarget !== "universal" &&
|
|
283
|
-
previousTargetPath !== undefined &&
|
|
284
|
-
previousTargetPath !== targetPath &&
|
|
285
|
-
fs.exists(previousTargetPath);
|
|
286
|
-
if (removePrevious && !fs.remove)
|
|
287
|
-
return { exitCode: 1, error: "Filesystem cannot remove the previous target resource." };
|
|
288
|
-
const destinationExisted = fs.exists(targetPath);
|
|
289
|
-
const resourceSnapshots = resourceMigrations
|
|
290
|
-
.filter(({ source, destination }) => source !== join(root, destination))
|
|
291
|
-
.map(({ destination }) => {
|
|
292
|
-
const path = join(root, destination);
|
|
293
|
-
return {
|
|
294
|
-
path,
|
|
295
|
-
existed: fs.exists(path),
|
|
296
|
-
contentBase64: fs.exists(path)
|
|
297
|
-
? Buffer.from(readResourceBytes(fs, path)).toString("base64")
|
|
298
|
-
: "",
|
|
455
|
+
error: `Migration blocked. Review the dry-run diagnostics:\n${preview}`,
|
|
299
456
|
};
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
457
|
+
if ([...fileChanges.values()].some((value) => value === undefined) && !fs.remove)
|
|
458
|
+
return { exitCode: 1, error: "Filesystem cannot remove obsolete target resources." };
|
|
459
|
+
const nextInventory = new Map(inventory.map((entry) => [entry.path, entry]));
|
|
460
|
+
for (const relativePath of removals)
|
|
461
|
+
nextInventory.delete(relativePath);
|
|
462
|
+
for (const [path, bytes] of fileChanges) {
|
|
463
|
+
if (bytes === undefined)
|
|
464
|
+
continue;
|
|
465
|
+
const relativePath = relative(root, path).split(sep).join("/");
|
|
466
|
+
const matchingMigration = migrations.find((entry) => join(root, entry.destination) === path);
|
|
467
|
+
const metadata = path === targetAiPath
|
|
468
|
+
? {
|
|
469
|
+
kind: "ai-init",
|
|
470
|
+
target,
|
|
471
|
+
resourceType: "skills",
|
|
472
|
+
resourceId: "ai-init",
|
|
473
|
+
}
|
|
474
|
+
: matchingMigration
|
|
475
|
+
? {
|
|
476
|
+
kind: "target-resource",
|
|
477
|
+
target,
|
|
478
|
+
resourceType: matchingMigration.type,
|
|
479
|
+
resourceId: matchingMigration.id,
|
|
480
|
+
}
|
|
481
|
+
: {};
|
|
482
|
+
nextInventory.set(relativePath, createOwnedFile(relativePath, bytes, metadata));
|
|
483
|
+
}
|
|
484
|
+
const inventoryBytes = Buffer.from(serializeOwnership([...nextInventory.values()]), "utf8");
|
|
485
|
+
fileChanges.set(inventoryPath, inventoryBytes);
|
|
486
|
+
fileChanges.set(manifestPath, Buffer.from(serializeManifest({ ...manifest, target: { active: target } }), "utf8"));
|
|
487
|
+
const changes = [...fileChanges].map(([path, content]) => ({ path, content }));
|
|
488
|
+
const snapshots = snapshotProjectFiles(fs, root, changes.map(({ path }) => path));
|
|
489
|
+
const backupPath = join(aiw, "checkpoints/migration-backup.yml");
|
|
490
|
+
assertSafeProjectPath(fs, root, backupPath);
|
|
491
|
+
const previousBackup = fs.exists(backupPath) ? readResourceBytes(fs, backupPath) : undefined;
|
|
492
|
+
const postMigrationSnapshots = expectedMigrationSnapshots(root, changes);
|
|
493
|
+
const backupContent = serializeMigrationCheckpoint(snapshots, postMigrationSnapshots);
|
|
494
|
+
fs.write(backupPath, backupContent);
|
|
495
|
+
try {
|
|
496
|
+
applyProjectFileChanges(fs, root, changes);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
if (error instanceof Error && error.message.includes("automatic rollback was incomplete"))
|
|
500
|
+
throw error;
|
|
501
|
+
try {
|
|
502
|
+
if (previousBackup)
|
|
503
|
+
writeResourceBytes(fs, backupPath, previousBackup);
|
|
504
|
+
else if (fs.exists(backupPath))
|
|
505
|
+
fs.remove?.(backupPath);
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
throw new Error("Migration failed and its prior checkpoint could not be restored.", {
|
|
509
|
+
cause: error,
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
throw error;
|
|
310
513
|
}
|
|
311
|
-
if (removePrevious && previousTargetPath)
|
|
312
|
-
fs.remove?.(previousTargetPath);
|
|
313
|
-
fs.write(manifestPath, fs.read(manifestPath).replace(/active: .*\n/, `active: ${target}\n`));
|
|
314
514
|
return {
|
|
315
515
|
exitCode: 0,
|
|
316
|
-
output: `Target changed to ${target}; rendered ${
|
|
516
|
+
output: `Target changed to ${target}; rendered ${migrations.length} neutral resources and removed ${removals.size} obsolete resources.`,
|
|
317
517
|
};
|
|
518
|
+
function inspectMigrationDestination(destination, desired) {
|
|
519
|
+
try {
|
|
520
|
+
assertSafeProjectPath(fs, root, destination);
|
|
521
|
+
if (!fs.exists(destination)) {
|
|
522
|
+
fileChanges.set(destination, desired);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
if (fs.pathType?.(destination) !== "file") {
|
|
526
|
+
conflicts.add(destination);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const currentBytes = readResourceBytes(fs, destination);
|
|
530
|
+
const currentChecksum = `sha256-${checksumPackage(currentBytes)}`;
|
|
531
|
+
const relativeDestination = relative(root, destination).split(sep).join("/");
|
|
532
|
+
const owner = ownedByPath.get(relativeDestination);
|
|
533
|
+
if (Buffer.compare(Buffer.from(currentBytes), Buffer.from(desired)) === 0) {
|
|
534
|
+
if (owner && owner.checksum !== currentChecksum)
|
|
535
|
+
conflicts.add(destination);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (!owner || owner.checksum !== currentChecksum) {
|
|
539
|
+
conflicts.add(destination);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
fileChanges.set(destination, desired);
|
|
543
|
+
}
|
|
544
|
+
catch {
|
|
545
|
+
conflicts.add(destination);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function inspectOwnedRemoval(path) {
|
|
549
|
+
try {
|
|
550
|
+
assertSafeProjectPath(fs, root, path);
|
|
551
|
+
if (!fs.exists(path)) {
|
|
552
|
+
removals.add(relative(root, path).split(sep).join("/"));
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (fs.pathType?.(path) !== "file") {
|
|
556
|
+
conflicts.add(path);
|
|
557
|
+
return;
|
|
558
|
+
}
|
|
559
|
+
const relativePath = relative(root, path).split(sep).join("/");
|
|
560
|
+
const owner = ownedByPath.get(relativePath);
|
|
561
|
+
const actual = `sha256-${checksumPackage(readResourceBytes(fs, path))}`;
|
|
562
|
+
if (!owner || owner.checksum !== actual) {
|
|
563
|
+
conflicts.add(path);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
removals.add(relativePath);
|
|
567
|
+
fileChanges.set(path, undefined);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
conflicts.add(path);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
318
573
|
}
|
|
319
574
|
if (command === "rollback") {
|
|
320
575
|
const backup = join(aiw, "checkpoints/migration-backup.yml");
|
|
576
|
+
assertSafeProjectPath(fs, root, backup);
|
|
321
577
|
if (!fs.exists(backup))
|
|
322
578
|
return { exitCode: 1, error: "No migration backup is available." };
|
|
323
579
|
const content = fs.read(backup);
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
const
|
|
328
|
-
const
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (!
|
|
332
|
-
return {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
580
|
+
if (fs.pathType?.(backup) !== "file")
|
|
581
|
+
return { exitCode: 1, error: "Migration backup is not a regular project file." };
|
|
582
|
+
const checkpoint = parseMigrationCheckpoint(content);
|
|
583
|
+
const { snapshots, postMigrationSnapshots } = checkpoint;
|
|
584
|
+
const backupRelative = relative(root, backup).split(sep).join("/");
|
|
585
|
+
if (snapshots.some(({ path }) => path === backupRelative))
|
|
586
|
+
return { exitCode: 1, error: "Migration backup cannot contain itself as a snapshot." };
|
|
587
|
+
if (!postMigrationSnapshots)
|
|
588
|
+
return {
|
|
589
|
+
exitCode: 1,
|
|
590
|
+
error: "Migration backup does not include validated post-migration state; refusing unsafe rollback.",
|
|
591
|
+
};
|
|
592
|
+
if (postMigrationSnapshots.length !== snapshots.length ||
|
|
593
|
+
postMigrationSnapshots.some((snapshot, index) => snapshot.path !== snapshots[index]?.path))
|
|
594
|
+
return { exitCode: 1, error: "Migration backup post-migration state is invalid." };
|
|
595
|
+
const conflicts = [];
|
|
596
|
+
const restorable = [];
|
|
597
|
+
for (const [index, snapshot] of snapshots.entries()) {
|
|
598
|
+
const path = resolve(root, snapshot.path);
|
|
599
|
+
try {
|
|
600
|
+
assertSafeProjectPath(fs, root, path);
|
|
601
|
+
const expected = postMigrationSnapshots?.[index];
|
|
602
|
+
if (!expected || migrationSnapshotMatches(fs, path, expected))
|
|
603
|
+
restorable.push(snapshot);
|
|
604
|
+
else
|
|
605
|
+
conflicts.push(snapshot.path);
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
conflicts.push(snapshot.path);
|
|
353
609
|
}
|
|
354
610
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
611
|
+
if (restorable.some((snapshot) => !snapshot.existed && fs.exists(resolve(root, snapshot.path))) &&
|
|
612
|
+
!fs.remove)
|
|
613
|
+
return { exitCode: 1, error: "Filesystem cannot remove obsolete migration resources." };
|
|
614
|
+
const rollbackChanges = restorable.map((snapshot) => ({
|
|
615
|
+
path: resolve(root, snapshot.path),
|
|
616
|
+
content: snapshot.existed ? Buffer.from(snapshot.contentBase64, "base64") : undefined,
|
|
617
|
+
}));
|
|
618
|
+
if (conflicts.length) {
|
|
619
|
+
const remaining = snapshots.filter((snapshot) => conflicts.includes(snapshot.path));
|
|
620
|
+
const remainingPostMigration = postMigrationSnapshots.filter((snapshot) => conflicts.includes(snapshot.path));
|
|
621
|
+
rollbackChanges.push({
|
|
622
|
+
path: backup,
|
|
623
|
+
content: Buffer.from(serializeMigrationCheckpoint(remaining, remainingPostMigration), "utf8"),
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
else {
|
|
627
|
+
rollbackChanges.push({ path: backup, content: undefined });
|
|
628
|
+
}
|
|
629
|
+
applyProjectFileChanges(fs, root, rollbackChanges);
|
|
630
|
+
if (conflicts.length) {
|
|
631
|
+
return {
|
|
632
|
+
exitCode: 1,
|
|
633
|
+
error: `Migration rollback preserved modified/conflicting files: ${[...new Set(conflicts)].sort().join(", ")}`,
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
exitCode: 0,
|
|
638
|
+
output: "Migration rolled back; prior resources, ownership, and target state were restored.",
|
|
639
|
+
};
|
|
360
640
|
}
|
|
361
641
|
if (command === "confirm") {
|
|
362
642
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
@@ -520,12 +800,13 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
520
800
|
}
|
|
521
801
|
}
|
|
522
802
|
if (command === "doctor") {
|
|
523
|
-
const required = ["manifest.yml", "profile.yml", "lock.yml"];
|
|
803
|
+
const required = ["manifest.yml", "profile.yml", "lock.yml", "ownership.yml"];
|
|
524
804
|
const missing = required.filter((file) => !fs.exists(join(aiw, file)));
|
|
525
805
|
if (missing.length)
|
|
526
806
|
return { exitCode: 1, error: `Missing AI Workflow files: ${missing.join(", ")}` };
|
|
527
807
|
try {
|
|
528
808
|
parseManifest(fs.read(join(aiw, "manifest.yml")));
|
|
809
|
+
parseOwnership(fs.read(join(aiw, "ownership.yml")));
|
|
529
810
|
if (fs.exists(join(aiw, "team-preset.yml")))
|
|
530
811
|
parseTeamPreset(fs.read(join(aiw, "team-preset.yml")));
|
|
531
812
|
if (fs.exists(join(aiw, "registries.yml")))
|
|
@@ -547,9 +828,21 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
547
828
|
return { exitCode: 0, output: "AI Workflow state repaired." };
|
|
548
829
|
}
|
|
549
830
|
if (command === "uninstall") {
|
|
550
|
-
|
|
831
|
+
const manifestPath = join(aiw, "manifest.yml");
|
|
832
|
+
assertSafeProjectPath(fs, root, manifestPath);
|
|
833
|
+
if (!fs.exists(manifestPath))
|
|
551
834
|
return { exitCode: 1, error: "AI Workflow is not installed." };
|
|
552
|
-
const
|
|
835
|
+
const ownershipPath = join(aiw, "ownership.yml");
|
|
836
|
+
assertSafeProjectPath(fs, root, ownershipPath);
|
|
837
|
+
if (!fs.exists(ownershipPath))
|
|
838
|
+
return {
|
|
839
|
+
exitCode: 1,
|
|
840
|
+
error: "AIW ownership inventory is missing; refusing to remove resources whose ownership cannot be verified.",
|
|
841
|
+
};
|
|
842
|
+
if (fs.pathType?.(ownershipPath) !== "file")
|
|
843
|
+
throw new Error("AIW ownership inventory is not a regular in-project file.");
|
|
844
|
+
const ownedFiles = parseOwnership(fs.read(ownershipPath));
|
|
845
|
+
const stateFiles = [
|
|
553
846
|
"manifest.yml",
|
|
554
847
|
"profile.yml",
|
|
555
848
|
"lock.yml",
|
|
@@ -558,17 +851,71 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
558
851
|
"team-preset.yml",
|
|
559
852
|
"registries.yml",
|
|
560
853
|
"telemetry.yml",
|
|
854
|
+
"checkpoints/migration-backup.yml",
|
|
561
855
|
];
|
|
562
856
|
const dryRun = args.includes("--dry-run");
|
|
563
857
|
if (!dryRun && !fs.remove)
|
|
564
858
|
return { exitCode: 1, error: "Filesystem cannot remove AIW-owned files." };
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
859
|
+
const removable = [];
|
|
860
|
+
const conflicts = [];
|
|
861
|
+
for (const file of ownedFiles) {
|
|
862
|
+
const path = resolve(root, file.path);
|
|
863
|
+
try {
|
|
864
|
+
assertSafeProjectPath(fs, root, path);
|
|
865
|
+
if (!fs.exists(path))
|
|
866
|
+
continue;
|
|
867
|
+
if (fs.pathType?.(path) !== "file") {
|
|
868
|
+
conflicts.push(file.path);
|
|
869
|
+
continue;
|
|
870
|
+
}
|
|
871
|
+
const actual = `sha256-${checksumPackage(fs.readBytes?.(path) ?? fs.read(path))}`;
|
|
872
|
+
if (actual === file.checksum)
|
|
873
|
+
removable.push(path);
|
|
874
|
+
else
|
|
875
|
+
conflicts.push(file.path);
|
|
876
|
+
}
|
|
877
|
+
catch {
|
|
878
|
+
conflicts.push(file.path);
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
const statePaths = [];
|
|
882
|
+
for (const file of stateFiles) {
|
|
883
|
+
const path = join(aiw, file);
|
|
884
|
+
if (!fs.exists(path))
|
|
885
|
+
continue;
|
|
886
|
+
assertSafeProjectPath(fs, root, path);
|
|
887
|
+
if (fs.pathType?.(path) !== "file")
|
|
888
|
+
conflicts.push(`${WORKFLOW_DIRECTORY}/${file}`);
|
|
889
|
+
else
|
|
890
|
+
statePaths.push(path);
|
|
891
|
+
}
|
|
892
|
+
const uniqueConflicts = [...new Set(conflicts)].sort();
|
|
893
|
+
const allRemovable = [...new Set([...removable, ...statePaths, ownershipPath])];
|
|
894
|
+
const relativeRemovable = allRemovable
|
|
895
|
+
.map((path) => relative(root, path).split(sep).join("/"))
|
|
896
|
+
.sort();
|
|
897
|
+
if (dryRun) {
|
|
898
|
+
const report = [`Would remove: ${relativeRemovable.join(", ") || "none"}`];
|
|
899
|
+
if (uniqueConflicts.length)
|
|
900
|
+
report.push(`Would preserve modified/conflicting files: ${uniqueConflicts.join(", ")}`);
|
|
901
|
+
return { exitCode: uniqueConflicts.length ? 1 : 0, output: report.join("\n") };
|
|
902
|
+
}
|
|
903
|
+
removable.forEach((path) => fs.remove?.(path));
|
|
904
|
+
statePaths
|
|
905
|
+
.filter((path) => !path.endsWith(`${sep}manifest.yml`))
|
|
906
|
+
.forEach((path) => fs.remove?.(path));
|
|
907
|
+
if (statePaths.includes(manifestPath))
|
|
908
|
+
fs.remove?.(manifestPath);
|
|
909
|
+
fs.remove?.(ownershipPath);
|
|
910
|
+
const removed = relativeRemovable.filter((path) => !uniqueConflicts.includes(path));
|
|
569
911
|
return {
|
|
570
|
-
exitCode: 0,
|
|
571
|
-
output:
|
|
912
|
+
exitCode: uniqueConflicts.length ? 1 : 0,
|
|
913
|
+
output: [
|
|
914
|
+
`AI Workflow-owned files removed: ${removed.join(", ") || "none"}.`,
|
|
915
|
+
...(uniqueConflicts.length
|
|
916
|
+
? [`Preserved modified/conflicting files: ${uniqueConflicts.join(", ")}`]
|
|
917
|
+
: []),
|
|
918
|
+
].join("\n"),
|
|
572
919
|
};
|
|
573
920
|
}
|
|
574
921
|
if (command === "brainstorm") {
|
|
@@ -707,15 +1054,27 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
707
1054
|
const sourceArg = args.find((arg) => arg.startsWith("--source="));
|
|
708
1055
|
if (!packageArg && !sourceArg)
|
|
709
1056
|
return { exitCode: 1, error: "Usage: aiw resolve --package=path | --source=source" };
|
|
1057
|
+
const approvedPermissions = parseApprovedPermissions(args);
|
|
710
1058
|
if (sourceArg) {
|
|
711
1059
|
const source = resolveProvider(sourceArg.slice(9), root);
|
|
712
|
-
|
|
1060
|
+
const networkPermissions = requiresExternalNetwork(source) ? ["network:external"] : [];
|
|
1061
|
+
enforceConfiguredOrganizationPolicy(fs, aiw, {
|
|
1062
|
+
...source,
|
|
1063
|
+
permissions: networkPermissions,
|
|
1064
|
+
});
|
|
1065
|
+
assertPermissions(networkPermissions, approvedPermissions);
|
|
713
1066
|
}
|
|
714
1067
|
const loaded = sourceArg
|
|
715
1068
|
? (services.packageSources ?? nodePackageSourceLoader).load(sourceArg.slice(9), root)
|
|
716
1069
|
: undefined;
|
|
717
1070
|
try {
|
|
718
|
-
const
|
|
1071
|
+
const manifestPath = packageArg ? resolve(root, packageArg.slice(10)) : undefined;
|
|
1072
|
+
const directManifestBytes = loaded ? undefined : fs.readBytes?.(manifestPath);
|
|
1073
|
+
const manifestContent = loaded?.manifest ??
|
|
1074
|
+
(directManifestBytes
|
|
1075
|
+
? Buffer.from(directManifestBytes).toString("utf8")
|
|
1076
|
+
: fs.read(manifestPath));
|
|
1077
|
+
const pkg = validatePackageContract(manifestContent, loaded ? (path) => fs.exists(join(loaded.root, path)) : undefined);
|
|
719
1078
|
const resolved = loaded
|
|
720
1079
|
? {
|
|
721
1080
|
...pkg,
|
|
@@ -725,8 +1084,18 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
725
1084
|
}
|
|
726
1085
|
: pkg;
|
|
727
1086
|
enforceConfiguredOrganizationPolicy(fs, aiw, resolved);
|
|
728
|
-
assertPackagePermissions(resolved,
|
|
729
|
-
|
|
1087
|
+
assertPackagePermissions(resolved, approvedPermissions);
|
|
1088
|
+
const packageRoot = loaded?.root ?? dirname(manifestPath);
|
|
1089
|
+
const manifestBytes = loaded
|
|
1090
|
+
? (loaded.manifestBytes ?? Buffer.from(loaded.manifest, "utf8"))
|
|
1091
|
+
: (directManifestBytes ?? Buffer.from(manifestContent, "utf8"));
|
|
1092
|
+
const integrity = checksumPackageSnapshot(manifestBytes, resolved, packageRoot, fs, loaded ? "package.yaml" : basename(manifestPath));
|
|
1093
|
+
const lockPath = join(aiw, "lock.yml");
|
|
1094
|
+
const locked = fs.exists(lockPath) ? parseLock(fs.read(lockPath)) : [];
|
|
1095
|
+
fs.write(lockPath, serializeLock([
|
|
1096
|
+
...locked.filter(({ id }) => id !== resolved.id),
|
|
1097
|
+
resolvePackage(resolved, integrity),
|
|
1098
|
+
]));
|
|
730
1099
|
return { exitCode: 0, output: `Package resolved: ${resolved.id}@${resolved.version}` };
|
|
731
1100
|
}
|
|
732
1101
|
finally {
|
|
@@ -816,7 +1185,12 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
816
1185
|
const packageArg = args.find((arg) => arg.startsWith("--package="));
|
|
817
1186
|
if (!packageArg)
|
|
818
1187
|
return { exitCode: 1, error: "Usage: aiw update --package=path --target=target" };
|
|
819
|
-
const
|
|
1188
|
+
const manifestPath = resolve(root, packageArg.slice(10));
|
|
1189
|
+
const manifestBytes = fs.readBytes?.(manifestPath);
|
|
1190
|
+
const manifestContent = manifestBytes
|
|
1191
|
+
? Buffer.from(manifestBytes).toString("utf8")
|
|
1192
|
+
: fs.read(manifestPath);
|
|
1193
|
+
const pkg = validatePackageContract(manifestContent);
|
|
820
1194
|
enforceConfiguredOrganizationPolicy(fs, aiw, pkg);
|
|
821
1195
|
assertPackagePermissions(pkg, parseApprovedPermissions(args));
|
|
822
1196
|
const target = args.find((arg) => arg.startsWith("--target="))?.slice(9) ?? "universal";
|
|
@@ -825,11 +1199,14 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
825
1199
|
const lock = fs.exists(join(aiw, "lock.yml")) ? fs.read(join(aiw, "lock.yml")) : "";
|
|
826
1200
|
const lockedPackages = lock ? parseLock(lock) : [];
|
|
827
1201
|
const current = lockedPackages.find(({ id }) => id === pkg.id);
|
|
1202
|
+
const integrity = checksumPackageSnapshot(manifestBytes ?? Buffer.from(manifestContent, "utf8"), pkg, dirname(manifestPath), fs, basename(manifestPath));
|
|
1203
|
+
if (current?.version === pkg.version && current.integrity !== integrity)
|
|
1204
|
+
throw new Error(`Package integrity mismatch for ${pkg.id}@${pkg.version}; package bytes changed.`);
|
|
828
1205
|
const plan = planPackageUpdate(current ? { id: pkg.id, version: current.version } : undefined, pkg, target, new Map(lockedPackages.filter(({ id }) => id !== pkg.id).map(({ id, version }) => [id, version])));
|
|
829
1206
|
if (plan.status !== "update")
|
|
830
1207
|
return { exitCode: 1, error: plan.reason ?? `Update blocked: ${plan.status}.` };
|
|
831
1208
|
const existingPackages = lockedPackages.filter(({ id }) => id !== pkg.id);
|
|
832
|
-
fs.write(join(aiw, "lock.yml"), serializeLock([...existingPackages, resolvePackage(pkg)]));
|
|
1209
|
+
fs.write(join(aiw, "lock.yml"), serializeLock([...existingPackages, resolvePackage(pkg, integrity)]));
|
|
833
1210
|
return { exitCode: 0, output: `Package updated: ${pkg.id}@${pkg.version}` };
|
|
834
1211
|
}
|
|
835
1212
|
if (command === "registry") {
|
|
@@ -1043,6 +1420,81 @@ function isInsidePath(root, destination) {
|
|
|
1043
1420
|
const path = relative(root, destination);
|
|
1044
1421
|
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
1045
1422
|
}
|
|
1423
|
+
function snapshotProjectFiles(fs, root, paths) {
|
|
1424
|
+
const snapshots = [];
|
|
1425
|
+
for (const path of [...new Set(paths)]) {
|
|
1426
|
+
assertSafeProjectPath(fs, root, path);
|
|
1427
|
+
const type = fs.pathType?.(path);
|
|
1428
|
+
if (type === "missing") {
|
|
1429
|
+
snapshots.push({
|
|
1430
|
+
path: relative(root, path).split(sep).join("/"),
|
|
1431
|
+
existed: false,
|
|
1432
|
+
contentBase64: "",
|
|
1433
|
+
});
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
if (type !== "file")
|
|
1437
|
+
throw new Error(`Migration path is not a regular project file: ${relative(root, path)}`);
|
|
1438
|
+
snapshots.push({
|
|
1439
|
+
path: relative(root, path).split(sep).join("/"),
|
|
1440
|
+
existed: true,
|
|
1441
|
+
contentBase64: Buffer.from(readResourceBytes(fs, path)).toString("base64"),
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
return snapshots;
|
|
1445
|
+
}
|
|
1446
|
+
function expectedMigrationSnapshots(root, changes) {
|
|
1447
|
+
return [...new Map(changes.map((change) => [change.path, change])).values()].map(({ path, content }) => ({
|
|
1448
|
+
path: relative(root, path).split(sep).join("/"),
|
|
1449
|
+
existed: content !== undefined,
|
|
1450
|
+
contentBase64: content ? Buffer.from(content).toString("base64") : "",
|
|
1451
|
+
}));
|
|
1452
|
+
}
|
|
1453
|
+
function migrationSnapshotMatches(fs, path, expected) {
|
|
1454
|
+
const type = fs.pathType?.(path);
|
|
1455
|
+
if (!expected.existed)
|
|
1456
|
+
return type === "missing";
|
|
1457
|
+
if (type !== "file")
|
|
1458
|
+
return false;
|
|
1459
|
+
return Buffer.from(readResourceBytes(fs, path)).equals(Buffer.from(expected.contentBase64, "base64"));
|
|
1460
|
+
}
|
|
1461
|
+
function applyProjectFileChanges(fs, root, changes) {
|
|
1462
|
+
const ordered = [...new Map(changes.map((change) => [change.path, change])).values()];
|
|
1463
|
+
if (ordered.some(({ content }) => content === undefined) && !fs.remove)
|
|
1464
|
+
throw new Error("Filesystem cannot remove obsolete migration resources.");
|
|
1465
|
+
const snapshots = snapshotProjectFiles(fs, root, ordered.map(({ path }) => path));
|
|
1466
|
+
try {
|
|
1467
|
+
for (const change of ordered) {
|
|
1468
|
+
if (change.content === undefined) {
|
|
1469
|
+
if (fs.exists(change.path))
|
|
1470
|
+
fs.remove?.(change.path);
|
|
1471
|
+
}
|
|
1472
|
+
else {
|
|
1473
|
+
writeResourceBytes(fs, change.path, change.content);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
catch (error) {
|
|
1478
|
+
let rollbackFailed = false;
|
|
1479
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
1480
|
+
const path = resolve(root, snapshot.path);
|
|
1481
|
+
try {
|
|
1482
|
+
if (snapshot.existed)
|
|
1483
|
+
writeResourceBytes(fs, path, Buffer.from(snapshot.contentBase64, "base64"));
|
|
1484
|
+
else if (fs.exists(path))
|
|
1485
|
+
fs.remove?.(path);
|
|
1486
|
+
}
|
|
1487
|
+
catch {
|
|
1488
|
+
rollbackFailed = true;
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
if (rollbackFailed)
|
|
1492
|
+
throw new Error("Migration failed and automatic rollback was incomplete; use aiw rollback.", {
|
|
1493
|
+
cause: error,
|
|
1494
|
+
});
|
|
1495
|
+
throw error;
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1046
1498
|
function parsePrivateRegistryPackages(payload, registryUrl) {
|
|
1047
1499
|
if (!Array.isArray(payload))
|
|
1048
1500
|
throw new Error("Private registry response must be an array.");
|