@multileaf/ai-workflow 0.1.0 → 0.1.2
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 +29 -1
- package/README.md +316 -13
- package/dist/adapter.d.ts +1 -0
- package/dist/adapter.js +37 -1
- package/dist/bundled-resources.d.ts +9 -0
- package/dist/bundled-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/generator.js +13 -3
- 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/recommendations.d.ts +7 -0
- package/dist/recommendations.js +141 -10
- package/dist/scanner.js +28 -26
- package/dist/types.d.ts +2 -0
- package/dist/workflow.js +729 -147
- package/docs/adapter-model.md +3 -1
- package/docs/backlog.md +2 -2
- package/docs/package-model.md +5 -3
- package/docs/product-requirements.md +2 -0
- 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,27 +1,30 @@
|
|
|
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 { loadBundledResources } from "./bundled-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";
|
|
7
9
|
import { mergeFacts } from "./intelligence.js";
|
|
8
10
|
import { createInterpretationRequest } from "./interpreter.js";
|
|
9
|
-
import { recommendCapabilities, serializeRecommendations } from "./recommendations.js";
|
|
11
|
+
import { recommendCapabilities, resourcesForCapabilities, serializeRecommendations, } from "./recommendations.js";
|
|
10
12
|
import { selectRecommendations } from "./selection.js";
|
|
11
13
|
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,86 @@ 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 aiInitPath = join(root, renderAiInitPath(target));
|
|
128
|
+
const neutralAiInitPath = join(aiw, "resources/skills/ai-init.md");
|
|
129
|
+
const aiInitContent = renderAiInit(target);
|
|
130
|
+
const desiredResources = new Map([[aiInitPath, aiInitContent]]);
|
|
131
|
+
const ownershipMetadata = new Map();
|
|
132
|
+
ownershipMetadata.set(aiInitPath, {
|
|
133
|
+
kind: "ai-init",
|
|
134
|
+
target,
|
|
135
|
+
resourceType: "skills",
|
|
136
|
+
resourceId: "ai-init",
|
|
137
|
+
});
|
|
138
|
+
if (neutralAiInitPath !== aiInitPath) {
|
|
139
|
+
desiredResources.set(neutralAiInitPath, aiInitContent);
|
|
140
|
+
ownershipMetadata.set(neutralAiInitPath, {
|
|
141
|
+
kind: "neutral-resource",
|
|
142
|
+
target: "universal",
|
|
143
|
+
resourceType: "skills",
|
|
144
|
+
resourceId: "ai-init",
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const conflicts = [];
|
|
148
|
+
const pendingWrites = new Map();
|
|
149
|
+
const inspectDesiredFile = (path, content, allowExisting = false) => {
|
|
150
|
+
assertSafeProjectPath(fs, root, path);
|
|
151
|
+
const type = fs.pathType?.(path);
|
|
152
|
+
if (type === "missing") {
|
|
153
|
+
pendingWrites.set(path, content);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (type === "file" && (allowExisting || fs.read(path) === content))
|
|
157
|
+
return;
|
|
158
|
+
conflicts.push(path);
|
|
159
|
+
};
|
|
160
|
+
for (const [path, content] of desiredResources)
|
|
161
|
+
inspectDesiredFile(path, content);
|
|
162
|
+
if (conflicts.length)
|
|
163
|
+
return {
|
|
164
|
+
exitCode: 1,
|
|
165
|
+
error: `Installation would overwrite an existing file: ${conflicts.join(", ")}. Move it or merge its contents, then retry.`,
|
|
166
|
+
};
|
|
167
|
+
inspectDesiredFile(join(aiw, "profile.yml"), "schema: 1\nstatus: pending-scan\n");
|
|
168
|
+
inspectDesiredFile(join(aiw, "lock.yml"), "schema: 1\npackages:\n");
|
|
169
|
+
inspectDesiredFile(manifestPath, "");
|
|
170
|
+
const adrIndex = join(root, ".context/adrs/INDEX.md");
|
|
171
|
+
assertSafeProjectPath(fs, root, adrIndex);
|
|
172
|
+
if (fs.pathType?.(adrIndex) === "missing")
|
|
173
|
+
pendingWrites.set(adrIndex, "# Architecture Decision Records\n\n");
|
|
174
|
+
const ownershipPath = join(aiw, "ownership.yml");
|
|
175
|
+
assertSafeProjectPath(fs, root, ownershipPath);
|
|
176
|
+
if (fs.exists(ownershipPath))
|
|
177
|
+
conflicts.push(ownershipPath);
|
|
178
|
+
if (conflicts.length)
|
|
179
|
+
return {
|
|
180
|
+
exitCode: 1,
|
|
181
|
+
error: `Installation found conflicting project state: ${conflicts.join(", ")}. Review and move it before retrying.`,
|
|
182
|
+
};
|
|
183
|
+
const ownedPaths = new Set(desiredResources.keys());
|
|
184
|
+
if (pendingWrites.has(adrIndex))
|
|
185
|
+
ownedPaths.add(adrIndex);
|
|
186
|
+
const ownedFiles = [...ownedPaths]
|
|
187
|
+
.filter((path) => pendingWrites.has(path))
|
|
188
|
+
.map((path) => createOwnedFile(relative(root, path).split(sep).join("/"), pendingWrites.get(path), ownershipMetadata.get(path) ?? { kind: "supporting-file" }));
|
|
189
|
+
pendingWrites.set(ownershipPath, serializeOwnership(ownedFiles));
|
|
190
|
+
pendingWrites.delete(manifestPath);
|
|
191
|
+
pendingWrites.set(manifestPath, `schema: 1\nproject:\n name: ${root.split("/").pop()}\ntarget:\n active: ${target}\npolicies:\n artifact_language: en\n`);
|
|
192
|
+
writeProjectFilesAtomically(fs, root, [...pendingWrites].map(([path, content]) => ({ path, content })), [...BASE_DIRECTORIES.map((directory) => join(aiw, directory)), join(root, ".context/adrs")]);
|
|
193
|
+
return {
|
|
194
|
+
exitCode: 0,
|
|
195
|
+
output: `AI Workflow initialized for target: ${target} with only the ai-init skill. Run /ai-init to scan the project and choose which resources to install.`,
|
|
196
|
+
};
|
|
130
197
|
}
|
|
131
198
|
if (command === "telemetry") {
|
|
132
199
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
@@ -232,131 +299,332 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
232
299
|
return { exitCode: 1, error: "Usage: aiw target <target>" };
|
|
233
300
|
if (!isTarget(target))
|
|
234
301
|
return { exitCode: 1, error: `Unsupported target: ${target}` };
|
|
235
|
-
|
|
302
|
+
const manifestPath = join(aiw, "manifest.yml");
|
|
303
|
+
if (!fs.exists(manifestPath))
|
|
236
304
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
237
305
|
const dryRun = args.includes("--dry-run");
|
|
238
306
|
const targetPath = join(root, renderAiInitPath(target));
|
|
239
307
|
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));
|
|
308
|
+
assertSafeProjectPath(fs, root, manifestPath);
|
|
309
|
+
const manifest = parseManifest(fs.read(manifestPath));
|
|
310
|
+
const previousTarget = manifest.target.active;
|
|
311
|
+
const previousTargetPath = join(root, renderAiInitPath(previousTarget));
|
|
312
|
+
const inventoryPath = join(aiw, "ownership.yml");
|
|
313
|
+
if (!fs.exists(inventoryPath))
|
|
267
314
|
return {
|
|
268
|
-
exitCode:
|
|
269
|
-
|
|
315
|
+
exitCode: 1,
|
|
316
|
+
error: "AIW ownership inventory is required for safe target migration.",
|
|
270
317
|
};
|
|
318
|
+
assertSafeProjectPath(fs, root, inventoryPath);
|
|
319
|
+
if (fs.pathType?.(inventoryPath) !== "file")
|
|
320
|
+
return { exitCode: 1, error: "AIW ownership inventory is not a regular project file." };
|
|
321
|
+
const inventory = parseOwnership(fs.read(inventoryPath));
|
|
322
|
+
const ownedByPath = new Map(inventory.map((entry) => [entry.path, entry]));
|
|
323
|
+
const resourceRoot = join(aiw, "resources");
|
|
324
|
+
if (fs.exists(resourceRoot))
|
|
325
|
+
assertSafeProjectPath(fs, root, resourceRoot);
|
|
326
|
+
const relativeFiles = fs.exists(resourceRoot)
|
|
327
|
+
? (fs.listFiles?.(resourceRoot) ?? []).filter((path) => path !== "skills/ai-init.md")
|
|
328
|
+
: [];
|
|
329
|
+
const plan = planNeutralResourceMigration(resourceRoot, relativeFiles ?? [], target);
|
|
330
|
+
const diagnostics = [...plan.diagnostics];
|
|
331
|
+
if (fs.exists(resourceRoot) && !fs.listFiles)
|
|
332
|
+
diagnostics.push({
|
|
333
|
+
path: ".aiw/resources",
|
|
334
|
+
reason: "filesystem cannot enumerate neutral resources safely",
|
|
335
|
+
});
|
|
336
|
+
const migrations = [];
|
|
337
|
+
const migrationBytes = new Map();
|
|
338
|
+
for (const migration of plan.migrations) {
|
|
339
|
+
const relativeSource = relative(resourceRoot, migration.source).split(sep).join("/");
|
|
340
|
+
try {
|
|
341
|
+
assertSafeProjectPath(fs, root, migration.source);
|
|
342
|
+
if (!fs.exists(migration.source) || fs.pathType?.(migration.source) !== "file") {
|
|
343
|
+
diagnostics.push({
|
|
344
|
+
path: relativeSource,
|
|
345
|
+
reason: "resource is missing or is not a regular file",
|
|
346
|
+
});
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
const bytes = readResourceBytes(fs, migration.source);
|
|
350
|
+
const reason = validateNeutralResourceDocument(relativeSource, bytes);
|
|
351
|
+
if (reason) {
|
|
352
|
+
diagnostics.push({ path: relativeSource, reason });
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
migrations.push(migration);
|
|
356
|
+
migrationBytes.set(migration.source, bytes);
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
diagnostics.push({ path: relativeSource, reason: "resource cannot be read safely" });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const conflicts = new Set();
|
|
363
|
+
const removals = new Set();
|
|
364
|
+
const fileChanges = new Map();
|
|
365
|
+
const resourceDestinations = new Set();
|
|
366
|
+
for (const migration of migrations) {
|
|
367
|
+
const destination = join(root, migration.destination);
|
|
368
|
+
resourceDestinations.add(destination);
|
|
369
|
+
const desiredBytes = migrationBytes.get(migration.source);
|
|
370
|
+
inspectMigrationDestination(destination, desiredBytes);
|
|
371
|
+
}
|
|
372
|
+
const hasNeutralAiInit = fs.exists(neutralAiInit);
|
|
373
|
+
let aiInitBytes = Buffer.from(renderAiInit(target), "utf8");
|
|
374
|
+
if (hasNeutralAiInit) {
|
|
375
|
+
try {
|
|
376
|
+
assertSafeProjectPath(fs, root, neutralAiInit);
|
|
377
|
+
if (fs.pathType?.(neutralAiInit) !== "file")
|
|
378
|
+
diagnostics.push({
|
|
379
|
+
path: ".aiw/resources/skills/ai-init.md",
|
|
380
|
+
reason: "resource is not a regular file",
|
|
381
|
+
});
|
|
382
|
+
else
|
|
383
|
+
aiInitBytes = readResourceBytes(fs, neutralAiInit);
|
|
384
|
+
}
|
|
385
|
+
catch {
|
|
386
|
+
diagnostics.push({
|
|
387
|
+
path: ".aiw/resources/skills/ai-init.md",
|
|
388
|
+
reason: "resource cannot be read safely",
|
|
389
|
+
});
|
|
390
|
+
}
|
|
271
391
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
392
|
+
const aiInitDiagnostic = validateNeutralResourceDocument("skills/ai-init/SKILL.md", aiInitBytes);
|
|
393
|
+
if (hasNeutralAiInit && aiInitDiagnostic)
|
|
394
|
+
diagnostics.push({ path: ".aiw/resources/skills/ai-init.md", reason: aiInitDiagnostic });
|
|
395
|
+
const targetAiPath = targetPath;
|
|
396
|
+
resourceDestinations.add(targetAiPath);
|
|
397
|
+
if (targetAiPath === neutralAiInit)
|
|
398
|
+
fileChanges.set(targetAiPath, aiInitBytes);
|
|
399
|
+
else
|
|
400
|
+
inspectMigrationDestination(targetAiPath, aiInitBytes);
|
|
401
|
+
const previousTargetEntries = previousTarget === "universal"
|
|
402
|
+
? []
|
|
403
|
+
: inventory.filter((entry) => (entry.kind === "target-resource" || entry.kind === "ai-init") &&
|
|
404
|
+
entry.target === previousTarget);
|
|
405
|
+
const oldTargetPaths = new Set(previousTargetEntries.map(({ path }) => resolve(root, path)));
|
|
406
|
+
if (previousTarget !== "universal") {
|
|
407
|
+
for (const migration of migrations) {
|
|
408
|
+
const oldPath = join(root, renderResourcePath(previousTarget, migration.type, migration.id));
|
|
409
|
+
oldTargetPaths.add(oldPath);
|
|
410
|
+
if (oldPath !== join(root, migration.destination))
|
|
411
|
+
inspectOwnedRemoval(oldPath);
|
|
412
|
+
}
|
|
413
|
+
if (previousTargetPath !== targetPath)
|
|
414
|
+
inspectOwnedRemoval(previousTargetPath);
|
|
415
|
+
}
|
|
416
|
+
for (const oldPath of oldTargetPaths) {
|
|
417
|
+
if (resourceDestinations.has(oldPath))
|
|
418
|
+
continue;
|
|
419
|
+
if (fs.exists(oldPath))
|
|
420
|
+
inspectOwnedRemoval(oldPath);
|
|
421
|
+
else
|
|
422
|
+
removals.add(relative(root, oldPath).split(sep).join("/"));
|
|
423
|
+
}
|
|
424
|
+
const existingDestinations = [...resourceDestinations]
|
|
425
|
+
.filter((path) => fs.exists(path))
|
|
426
|
+
.map((path) => relative(root, path).split(sep).join("/"));
|
|
427
|
+
const conflictPaths = [...conflicts].map((path) => relative(root, path).split(sep).join("/"));
|
|
428
|
+
const removalPaths = [...removals];
|
|
429
|
+
const previewActions = [
|
|
430
|
+
target === "universal" && hasNeutralAiInit
|
|
431
|
+
? "keep: .aiw/resources/skills/ai-init.md (neutral source)"
|
|
432
|
+
: `${fs.exists(targetAiPath) ? "update" : "add"}: ${relative(root, targetAiPath).split(sep).join("/")}`,
|
|
433
|
+
...(hasNeutralAiInit && target !== "universal"
|
|
434
|
+
? ["keep: .aiw/resources/skills/ai-init.md (neutral source)"]
|
|
435
|
+
: []),
|
|
436
|
+
];
|
|
437
|
+
const preview = serializeMigrationPreview(target, migrations, existingDestinations, removalPaths, diagnostics, conflictPaths, previewActions);
|
|
438
|
+
if (dryRun)
|
|
439
|
+
return { exitCode: diagnostics.length || conflicts.size ? 1 : 0, output: preview };
|
|
440
|
+
if (diagnostics.length || conflicts.size)
|
|
278
441
|
return {
|
|
279
442
|
exitCode: 1,
|
|
280
|
-
error: `Migration
|
|
443
|
+
error: `Migration blocked. Review the dry-run diagnostics:\n${preview}`,
|
|
281
444
|
};
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
.
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
445
|
+
if ([...fileChanges.values()].some((value) => value === undefined) && !fs.remove)
|
|
446
|
+
return { exitCode: 1, error: "Filesystem cannot remove obsolete target resources." };
|
|
447
|
+
const nextInventory = new Map(inventory.map((entry) => [entry.path, entry]));
|
|
448
|
+
for (const relativePath of removals)
|
|
449
|
+
nextInventory.delete(relativePath);
|
|
450
|
+
for (const [path, bytes] of fileChanges) {
|
|
451
|
+
if (bytes === undefined)
|
|
452
|
+
continue;
|
|
453
|
+
const relativePath = relative(root, path).split(sep).join("/");
|
|
454
|
+
const matchingMigration = migrations.find((entry) => join(root, entry.destination) === path);
|
|
455
|
+
const metadata = path === targetAiPath
|
|
456
|
+
? {
|
|
457
|
+
kind: "ai-init",
|
|
458
|
+
target,
|
|
459
|
+
resourceType: "skills",
|
|
460
|
+
resourceId: "ai-init",
|
|
461
|
+
}
|
|
462
|
+
: matchingMigration
|
|
463
|
+
? {
|
|
464
|
+
kind: "target-resource",
|
|
465
|
+
target,
|
|
466
|
+
resourceType: matchingMigration.type,
|
|
467
|
+
resourceId: matchingMigration.id,
|
|
468
|
+
}
|
|
469
|
+
: {};
|
|
470
|
+
nextInventory.set(relativePath, createOwnedFile(relativePath, bytes, metadata));
|
|
471
|
+
}
|
|
472
|
+
const inventoryBytes = Buffer.from(serializeOwnership([...nextInventory.values()]), "utf8");
|
|
473
|
+
fileChanges.set(inventoryPath, inventoryBytes);
|
|
474
|
+
fileChanges.set(manifestPath, Buffer.from(serializeManifest({ ...manifest, target: { active: target } }), "utf8"));
|
|
475
|
+
const changes = [...fileChanges].map(([path, content]) => ({ path, content }));
|
|
476
|
+
const snapshots = snapshotProjectFiles(fs, root, changes.map(({ path }) => path));
|
|
477
|
+
const backupPath = join(aiw, "checkpoints/migration-backup.yml");
|
|
478
|
+
assertSafeProjectPath(fs, root, backupPath);
|
|
479
|
+
const previousBackup = fs.exists(backupPath) ? readResourceBytes(fs, backupPath) : undefined;
|
|
480
|
+
const postMigrationSnapshots = expectedMigrationSnapshots(root, changes);
|
|
481
|
+
const backupContent = serializeMigrationCheckpoint(snapshots, postMigrationSnapshots);
|
|
482
|
+
fs.write(backupPath, backupContent);
|
|
483
|
+
try {
|
|
484
|
+
applyProjectFileChanges(fs, root, changes);
|
|
485
|
+
}
|
|
486
|
+
catch (error) {
|
|
487
|
+
if (error instanceof Error && error.message.includes("automatic rollback was incomplete"))
|
|
488
|
+
throw error;
|
|
489
|
+
try {
|
|
490
|
+
if (previousBackup)
|
|
491
|
+
writeResourceBytes(fs, backupPath, previousBackup);
|
|
492
|
+
else if (fs.exists(backupPath))
|
|
493
|
+
fs.remove?.(backupPath);
|
|
494
|
+
}
|
|
495
|
+
catch {
|
|
496
|
+
throw new Error("Migration failed and its prior checkpoint could not be restored.", {
|
|
497
|
+
cause: error,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
throw error;
|
|
310
501
|
}
|
|
311
|
-
if (removePrevious && previousTargetPath)
|
|
312
|
-
fs.remove?.(previousTargetPath);
|
|
313
|
-
fs.write(manifestPath, fs.read(manifestPath).replace(/active: .*\n/, `active: ${target}\n`));
|
|
314
502
|
return {
|
|
315
503
|
exitCode: 0,
|
|
316
|
-
output: `Target changed to ${target}; rendered ${
|
|
504
|
+
output: `Target changed to ${target}; rendered ${migrations.length} neutral resources and removed ${removals.size} obsolete resources.`,
|
|
317
505
|
};
|
|
506
|
+
function inspectMigrationDestination(destination, desired) {
|
|
507
|
+
try {
|
|
508
|
+
assertSafeProjectPath(fs, root, destination);
|
|
509
|
+
if (!fs.exists(destination)) {
|
|
510
|
+
fileChanges.set(destination, desired);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
if (fs.pathType?.(destination) !== "file") {
|
|
514
|
+
conflicts.add(destination);
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const currentBytes = readResourceBytes(fs, destination);
|
|
518
|
+
const currentChecksum = `sha256-${checksumPackage(currentBytes)}`;
|
|
519
|
+
const relativeDestination = relative(root, destination).split(sep).join("/");
|
|
520
|
+
const owner = ownedByPath.get(relativeDestination);
|
|
521
|
+
if (Buffer.compare(Buffer.from(currentBytes), Buffer.from(desired)) === 0) {
|
|
522
|
+
if (owner && owner.checksum !== currentChecksum)
|
|
523
|
+
conflicts.add(destination);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
if (!owner || owner.checksum !== currentChecksum) {
|
|
527
|
+
conflicts.add(destination);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
fileChanges.set(destination, desired);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
conflicts.add(destination);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
function inspectOwnedRemoval(path) {
|
|
537
|
+
try {
|
|
538
|
+
assertSafeProjectPath(fs, root, path);
|
|
539
|
+
if (!fs.exists(path)) {
|
|
540
|
+
removals.add(relative(root, path).split(sep).join("/"));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
if (fs.pathType?.(path) !== "file") {
|
|
544
|
+
conflicts.add(path);
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const relativePath = relative(root, path).split(sep).join("/");
|
|
548
|
+
const owner = ownedByPath.get(relativePath);
|
|
549
|
+
const actual = `sha256-${checksumPackage(readResourceBytes(fs, path))}`;
|
|
550
|
+
if (!owner || owner.checksum !== actual) {
|
|
551
|
+
conflicts.add(path);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
removals.add(relativePath);
|
|
555
|
+
fileChanges.set(path, undefined);
|
|
556
|
+
}
|
|
557
|
+
catch {
|
|
558
|
+
conflicts.add(path);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
318
561
|
}
|
|
319
562
|
if (command === "rollback") {
|
|
320
563
|
const backup = join(aiw, "checkpoints/migration-backup.yml");
|
|
564
|
+
assertSafeProjectPath(fs, root, backup);
|
|
321
565
|
if (!fs.exists(backup))
|
|
322
566
|
return { exitCode: 1, error: "No migration backup is available." };
|
|
323
567
|
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
|
-
|
|
568
|
+
if (fs.pathType?.(backup) !== "file")
|
|
569
|
+
return { exitCode: 1, error: "Migration backup is not a regular project file." };
|
|
570
|
+
const checkpoint = parseMigrationCheckpoint(content);
|
|
571
|
+
const { snapshots, postMigrationSnapshots } = checkpoint;
|
|
572
|
+
const backupRelative = relative(root, backup).split(sep).join("/");
|
|
573
|
+
if (snapshots.some(({ path }) => path === backupRelative))
|
|
574
|
+
return { exitCode: 1, error: "Migration backup cannot contain itself as a snapshot." };
|
|
575
|
+
if (!postMigrationSnapshots)
|
|
576
|
+
return {
|
|
577
|
+
exitCode: 1,
|
|
578
|
+
error: "Migration backup does not include validated post-migration state; refusing unsafe rollback.",
|
|
579
|
+
};
|
|
580
|
+
if (postMigrationSnapshots.length !== snapshots.length ||
|
|
581
|
+
postMigrationSnapshots.some((snapshot, index) => snapshot.path !== snapshots[index]?.path))
|
|
582
|
+
return { exitCode: 1, error: "Migration backup post-migration state is invalid." };
|
|
583
|
+
const conflicts = [];
|
|
584
|
+
const restorable = [];
|
|
585
|
+
for (const [index, snapshot] of snapshots.entries()) {
|
|
586
|
+
const path = resolve(root, snapshot.path);
|
|
587
|
+
try {
|
|
588
|
+
assertSafeProjectPath(fs, root, path);
|
|
589
|
+
const expected = postMigrationSnapshots?.[index];
|
|
590
|
+
if (!expected || migrationSnapshotMatches(fs, path, expected))
|
|
591
|
+
restorable.push(snapshot);
|
|
592
|
+
else
|
|
593
|
+
conflicts.push(snapshot.path);
|
|
594
|
+
}
|
|
595
|
+
catch {
|
|
596
|
+
conflicts.push(snapshot.path);
|
|
353
597
|
}
|
|
354
598
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
599
|
+
if (restorable.some((snapshot) => !snapshot.existed && fs.exists(resolve(root, snapshot.path))) &&
|
|
600
|
+
!fs.remove)
|
|
601
|
+
return { exitCode: 1, error: "Filesystem cannot remove obsolete migration resources." };
|
|
602
|
+
const rollbackChanges = restorable.map((snapshot) => ({
|
|
603
|
+
path: resolve(root, snapshot.path),
|
|
604
|
+
content: snapshot.existed ? Buffer.from(snapshot.contentBase64, "base64") : undefined,
|
|
605
|
+
}));
|
|
606
|
+
if (conflicts.length) {
|
|
607
|
+
const remaining = snapshots.filter((snapshot) => conflicts.includes(snapshot.path));
|
|
608
|
+
const remainingPostMigration = postMigrationSnapshots.filter((snapshot) => conflicts.includes(snapshot.path));
|
|
609
|
+
rollbackChanges.push({
|
|
610
|
+
path: backup,
|
|
611
|
+
content: Buffer.from(serializeMigrationCheckpoint(remaining, remainingPostMigration), "utf8"),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
rollbackChanges.push({ path: backup, content: undefined });
|
|
616
|
+
}
|
|
617
|
+
applyProjectFileChanges(fs, root, rollbackChanges);
|
|
618
|
+
if (conflicts.length) {
|
|
619
|
+
return {
|
|
620
|
+
exitCode: 1,
|
|
621
|
+
error: `Migration rollback preserved modified/conflicting files: ${[...new Set(conflicts)].sort().join(", ")}`,
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
return {
|
|
625
|
+
exitCode: 0,
|
|
626
|
+
output: "Migration rolled back; prior resources, ownership, and target state were restored.",
|
|
627
|
+
};
|
|
360
628
|
}
|
|
361
629
|
if (command === "confirm") {
|
|
362
630
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
@@ -387,18 +655,25 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
387
655
|
return { exitCode: 1, error: "Run `aiw install` first." };
|
|
388
656
|
const profile = await (services.profiler?.profile(root) ?? profileProject(root));
|
|
389
657
|
const recommendations = services.recommender?.recommend(profile) ?? recommendCapabilities(profile);
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
658
|
+
const selectedOption = args.find((arg) => arg.startsWith("--select="));
|
|
659
|
+
const selectedArg = selectedOption?.slice("--select=".length) ?? "";
|
|
660
|
+
let selection;
|
|
661
|
+
if (selectedOption !== undefined)
|
|
662
|
+
selection = await selectRecommendations(recommendations, async () => "n", selectedArg ? selectedArg.split(",") : []);
|
|
663
|
+
else if (!stdin.isTTY)
|
|
664
|
+
selection = [];
|
|
665
|
+
else {
|
|
666
|
+
const terminal = createInterface({ input: stdin, output: stdout });
|
|
667
|
+
selection = await selectRecommendations(recommendations, (question) => terminal.question(question));
|
|
668
|
+
terminal.close();
|
|
396
669
|
}
|
|
397
|
-
const terminal = createInterface({ input: stdin, output: stdout });
|
|
398
|
-
const selection = await selectRecommendations(recommendations, (question) => terminal.question(question), selectedArg?.split(","));
|
|
399
|
-
terminal.close();
|
|
400
670
|
fs.write(join(aiw, "recommendations.yml"), serializeRecommendations(recommendations, selection));
|
|
401
|
-
return {
|
|
671
|
+
return {
|
|
672
|
+
exitCode: 0,
|
|
673
|
+
output: selectedOption === undefined && !stdin.isTTY
|
|
674
|
+
? `${recommendations.length} recommendations generated without selection; review .aiw/recommendations.yml, then record user choices with --select=id,id.`
|
|
675
|
+
: `${recommendations.length} recommendations generated.`,
|
|
676
|
+
};
|
|
402
677
|
}
|
|
403
678
|
if (command === "skills") {
|
|
404
679
|
if (!fs.exists(join(aiw, "manifest.yml")))
|
|
@@ -490,6 +765,12 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
490
765
|
await installVercelSkill(services.externalSkills ?? nodeCommandExecutor, "vercel-labs/agent-skills", "vercel-react-best-practices", target);
|
|
491
766
|
writeVercelSkillLock(fs, root, aiw, "vercel-labs/agent-skills", "vercel-react-best-practices");
|
|
492
767
|
}
|
|
768
|
+
if (command === "sync") {
|
|
769
|
+
const result = syncSelectedResources(fs, root, aiw, profile, selected);
|
|
770
|
+
return result.error
|
|
771
|
+
? { exitCode: 1, error: result.error }
|
|
772
|
+
: { exitCode: 0, output: result.output };
|
|
773
|
+
}
|
|
493
774
|
const conflicts = [];
|
|
494
775
|
generateProjectResources(profile, selected, (path, content) => {
|
|
495
776
|
const destination = join(aiw, path);
|
|
@@ -520,12 +801,13 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
520
801
|
}
|
|
521
802
|
}
|
|
522
803
|
if (command === "doctor") {
|
|
523
|
-
const required = ["manifest.yml", "profile.yml", "lock.yml"];
|
|
804
|
+
const required = ["manifest.yml", "profile.yml", "lock.yml", "ownership.yml"];
|
|
524
805
|
const missing = required.filter((file) => !fs.exists(join(aiw, file)));
|
|
525
806
|
if (missing.length)
|
|
526
807
|
return { exitCode: 1, error: `Missing AI Workflow files: ${missing.join(", ")}` };
|
|
527
808
|
try {
|
|
528
809
|
parseManifest(fs.read(join(aiw, "manifest.yml")));
|
|
810
|
+
parseOwnership(fs.read(join(aiw, "ownership.yml")));
|
|
529
811
|
if (fs.exists(join(aiw, "team-preset.yml")))
|
|
530
812
|
parseTeamPreset(fs.read(join(aiw, "team-preset.yml")));
|
|
531
813
|
if (fs.exists(join(aiw, "registries.yml")))
|
|
@@ -547,9 +829,21 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
547
829
|
return { exitCode: 0, output: "AI Workflow state repaired." };
|
|
548
830
|
}
|
|
549
831
|
if (command === "uninstall") {
|
|
550
|
-
|
|
832
|
+
const manifestPath = join(aiw, "manifest.yml");
|
|
833
|
+
assertSafeProjectPath(fs, root, manifestPath);
|
|
834
|
+
if (!fs.exists(manifestPath))
|
|
551
835
|
return { exitCode: 1, error: "AI Workflow is not installed." };
|
|
552
|
-
const
|
|
836
|
+
const ownershipPath = join(aiw, "ownership.yml");
|
|
837
|
+
assertSafeProjectPath(fs, root, ownershipPath);
|
|
838
|
+
if (!fs.exists(ownershipPath))
|
|
839
|
+
return {
|
|
840
|
+
exitCode: 1,
|
|
841
|
+
error: "AIW ownership inventory is missing; refusing to remove resources whose ownership cannot be verified.",
|
|
842
|
+
};
|
|
843
|
+
if (fs.pathType?.(ownershipPath) !== "file")
|
|
844
|
+
throw new Error("AIW ownership inventory is not a regular in-project file.");
|
|
845
|
+
const ownedFiles = parseOwnership(fs.read(ownershipPath));
|
|
846
|
+
const stateFiles = [
|
|
553
847
|
"manifest.yml",
|
|
554
848
|
"profile.yml",
|
|
555
849
|
"lock.yml",
|
|
@@ -558,17 +852,71 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
558
852
|
"team-preset.yml",
|
|
559
853
|
"registries.yml",
|
|
560
854
|
"telemetry.yml",
|
|
855
|
+
"checkpoints/migration-backup.yml",
|
|
561
856
|
];
|
|
562
857
|
const dryRun = args.includes("--dry-run");
|
|
563
858
|
if (!dryRun && !fs.remove)
|
|
564
859
|
return { exitCode: 1, error: "Filesystem cannot remove AIW-owned files." };
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
860
|
+
const removable = [];
|
|
861
|
+
const conflicts = [];
|
|
862
|
+
for (const file of ownedFiles) {
|
|
863
|
+
const path = resolve(root, file.path);
|
|
864
|
+
try {
|
|
865
|
+
assertSafeProjectPath(fs, root, path);
|
|
866
|
+
if (!fs.exists(path))
|
|
867
|
+
continue;
|
|
868
|
+
if (fs.pathType?.(path) !== "file") {
|
|
869
|
+
conflicts.push(file.path);
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
const actual = `sha256-${checksumPackage(fs.readBytes?.(path) ?? fs.read(path))}`;
|
|
873
|
+
if (actual === file.checksum)
|
|
874
|
+
removable.push(path);
|
|
875
|
+
else
|
|
876
|
+
conflicts.push(file.path);
|
|
877
|
+
}
|
|
878
|
+
catch {
|
|
879
|
+
conflicts.push(file.path);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const statePaths = [];
|
|
883
|
+
for (const file of stateFiles) {
|
|
884
|
+
const path = join(aiw, file);
|
|
885
|
+
if (!fs.exists(path))
|
|
886
|
+
continue;
|
|
887
|
+
assertSafeProjectPath(fs, root, path);
|
|
888
|
+
if (fs.pathType?.(path) !== "file")
|
|
889
|
+
conflicts.push(`${WORKFLOW_DIRECTORY}/${file}`);
|
|
890
|
+
else
|
|
891
|
+
statePaths.push(path);
|
|
892
|
+
}
|
|
893
|
+
const uniqueConflicts = [...new Set(conflicts)].sort();
|
|
894
|
+
const allRemovable = [...new Set([...removable, ...statePaths, ownershipPath])];
|
|
895
|
+
const relativeRemovable = allRemovable
|
|
896
|
+
.map((path) => relative(root, path).split(sep).join("/"))
|
|
897
|
+
.sort();
|
|
898
|
+
if (dryRun) {
|
|
899
|
+
const report = [`Would remove: ${relativeRemovable.join(", ") || "none"}`];
|
|
900
|
+
if (uniqueConflicts.length)
|
|
901
|
+
report.push(`Would preserve modified/conflicting files: ${uniqueConflicts.join(", ")}`);
|
|
902
|
+
return { exitCode: uniqueConflicts.length ? 1 : 0, output: report.join("\n") };
|
|
903
|
+
}
|
|
904
|
+
removable.forEach((path) => fs.remove?.(path));
|
|
905
|
+
statePaths
|
|
906
|
+
.filter((path) => !path.endsWith(`${sep}manifest.yml`))
|
|
907
|
+
.forEach((path) => fs.remove?.(path));
|
|
908
|
+
if (statePaths.includes(manifestPath))
|
|
909
|
+
fs.remove?.(manifestPath);
|
|
910
|
+
fs.remove?.(ownershipPath);
|
|
911
|
+
const removed = relativeRemovable.filter((path) => !uniqueConflicts.includes(path));
|
|
569
912
|
return {
|
|
570
|
-
exitCode: 0,
|
|
571
|
-
output:
|
|
913
|
+
exitCode: uniqueConflicts.length ? 1 : 0,
|
|
914
|
+
output: [
|
|
915
|
+
`AI Workflow-owned files removed: ${removed.join(", ") || "none"}.`,
|
|
916
|
+
...(uniqueConflicts.length
|
|
917
|
+
? [`Preserved modified/conflicting files: ${uniqueConflicts.join(", ")}`]
|
|
918
|
+
: []),
|
|
919
|
+
].join("\n"),
|
|
572
920
|
};
|
|
573
921
|
}
|
|
574
922
|
if (command === "brainstorm") {
|
|
@@ -707,15 +1055,27 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
707
1055
|
const sourceArg = args.find((arg) => arg.startsWith("--source="));
|
|
708
1056
|
if (!packageArg && !sourceArg)
|
|
709
1057
|
return { exitCode: 1, error: "Usage: aiw resolve --package=path | --source=source" };
|
|
1058
|
+
const approvedPermissions = parseApprovedPermissions(args);
|
|
710
1059
|
if (sourceArg) {
|
|
711
1060
|
const source = resolveProvider(sourceArg.slice(9), root);
|
|
712
|
-
|
|
1061
|
+
const networkPermissions = requiresExternalNetwork(source) ? ["network:external"] : [];
|
|
1062
|
+
enforceConfiguredOrganizationPolicy(fs, aiw, {
|
|
1063
|
+
...source,
|
|
1064
|
+
permissions: networkPermissions,
|
|
1065
|
+
});
|
|
1066
|
+
assertPermissions(networkPermissions, approvedPermissions);
|
|
713
1067
|
}
|
|
714
1068
|
const loaded = sourceArg
|
|
715
1069
|
? (services.packageSources ?? nodePackageSourceLoader).load(sourceArg.slice(9), root)
|
|
716
1070
|
: undefined;
|
|
717
1071
|
try {
|
|
718
|
-
const
|
|
1072
|
+
const manifestPath = packageArg ? resolve(root, packageArg.slice(10)) : undefined;
|
|
1073
|
+
const directManifestBytes = loaded ? undefined : fs.readBytes?.(manifestPath);
|
|
1074
|
+
const manifestContent = loaded?.manifest ??
|
|
1075
|
+
(directManifestBytes
|
|
1076
|
+
? Buffer.from(directManifestBytes).toString("utf8")
|
|
1077
|
+
: fs.read(manifestPath));
|
|
1078
|
+
const pkg = validatePackageContract(manifestContent, loaded ? (path) => fs.exists(join(loaded.root, path)) : undefined);
|
|
719
1079
|
const resolved = loaded
|
|
720
1080
|
? {
|
|
721
1081
|
...pkg,
|
|
@@ -725,8 +1085,18 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
725
1085
|
}
|
|
726
1086
|
: pkg;
|
|
727
1087
|
enforceConfiguredOrganizationPolicy(fs, aiw, resolved);
|
|
728
|
-
assertPackagePermissions(resolved,
|
|
729
|
-
|
|
1088
|
+
assertPackagePermissions(resolved, approvedPermissions);
|
|
1089
|
+
const packageRoot = loaded?.root ?? dirname(manifestPath);
|
|
1090
|
+
const manifestBytes = loaded
|
|
1091
|
+
? (loaded.manifestBytes ?? Buffer.from(loaded.manifest, "utf8"))
|
|
1092
|
+
: (directManifestBytes ?? Buffer.from(manifestContent, "utf8"));
|
|
1093
|
+
const integrity = checksumPackageSnapshot(manifestBytes, resolved, packageRoot, fs, loaded ? "package.yaml" : basename(manifestPath));
|
|
1094
|
+
const lockPath = join(aiw, "lock.yml");
|
|
1095
|
+
const locked = fs.exists(lockPath) ? parseLock(fs.read(lockPath)) : [];
|
|
1096
|
+
fs.write(lockPath, serializeLock([
|
|
1097
|
+
...locked.filter(({ id }) => id !== resolved.id),
|
|
1098
|
+
resolvePackage(resolved, integrity),
|
|
1099
|
+
]));
|
|
730
1100
|
return { exitCode: 0, output: `Package resolved: ${resolved.id}@${resolved.version}` };
|
|
731
1101
|
}
|
|
732
1102
|
finally {
|
|
@@ -816,7 +1186,12 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
816
1186
|
const packageArg = args.find((arg) => arg.startsWith("--package="));
|
|
817
1187
|
if (!packageArg)
|
|
818
1188
|
return { exitCode: 1, error: "Usage: aiw update --package=path --target=target" };
|
|
819
|
-
const
|
|
1189
|
+
const manifestPath = resolve(root, packageArg.slice(10));
|
|
1190
|
+
const manifestBytes = fs.readBytes?.(manifestPath);
|
|
1191
|
+
const manifestContent = manifestBytes
|
|
1192
|
+
? Buffer.from(manifestBytes).toString("utf8")
|
|
1193
|
+
: fs.read(manifestPath);
|
|
1194
|
+
const pkg = validatePackageContract(manifestContent);
|
|
820
1195
|
enforceConfiguredOrganizationPolicy(fs, aiw, pkg);
|
|
821
1196
|
assertPackagePermissions(pkg, parseApprovedPermissions(args));
|
|
822
1197
|
const target = args.find((arg) => arg.startsWith("--target="))?.slice(9) ?? "universal";
|
|
@@ -825,11 +1200,14 @@ export async function runCommand(args, root, fs, services = {}) {
|
|
|
825
1200
|
const lock = fs.exists(join(aiw, "lock.yml")) ? fs.read(join(aiw, "lock.yml")) : "";
|
|
826
1201
|
const lockedPackages = lock ? parseLock(lock) : [];
|
|
827
1202
|
const current = lockedPackages.find(({ id }) => id === pkg.id);
|
|
1203
|
+
const integrity = checksumPackageSnapshot(manifestBytes ?? Buffer.from(manifestContent, "utf8"), pkg, dirname(manifestPath), fs, basename(manifestPath));
|
|
1204
|
+
if (current?.version === pkg.version && current.integrity !== integrity)
|
|
1205
|
+
throw new Error(`Package integrity mismatch for ${pkg.id}@${pkg.version}; package bytes changed.`);
|
|
828
1206
|
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
1207
|
if (plan.status !== "update")
|
|
830
1208
|
return { exitCode: 1, error: plan.reason ?? `Update blocked: ${plan.status}.` };
|
|
831
1209
|
const existingPackages = lockedPackages.filter(({ id }) => id !== pkg.id);
|
|
832
|
-
fs.write(join(aiw, "lock.yml"), serializeLock([...existingPackages, resolvePackage(pkg)]));
|
|
1210
|
+
fs.write(join(aiw, "lock.yml"), serializeLock([...existingPackages, resolvePackage(pkg, integrity)]));
|
|
833
1211
|
return { exitCode: 0, output: `Package updated: ${pkg.id}@${pkg.version}` };
|
|
834
1212
|
}
|
|
835
1213
|
if (command === "registry") {
|
|
@@ -1043,6 +1421,81 @@ function isInsidePath(root, destination) {
|
|
|
1043
1421
|
const path = relative(root, destination);
|
|
1044
1422
|
return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
|
|
1045
1423
|
}
|
|
1424
|
+
function snapshotProjectFiles(fs, root, paths) {
|
|
1425
|
+
const snapshots = [];
|
|
1426
|
+
for (const path of [...new Set(paths)]) {
|
|
1427
|
+
assertSafeProjectPath(fs, root, path);
|
|
1428
|
+
const type = fs.pathType?.(path);
|
|
1429
|
+
if (type === "missing") {
|
|
1430
|
+
snapshots.push({
|
|
1431
|
+
path: relative(root, path).split(sep).join("/"),
|
|
1432
|
+
existed: false,
|
|
1433
|
+
contentBase64: "",
|
|
1434
|
+
});
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
if (type !== "file")
|
|
1438
|
+
throw new Error(`Migration path is not a regular project file: ${relative(root, path)}`);
|
|
1439
|
+
snapshots.push({
|
|
1440
|
+
path: relative(root, path).split(sep).join("/"),
|
|
1441
|
+
existed: true,
|
|
1442
|
+
contentBase64: Buffer.from(readResourceBytes(fs, path)).toString("base64"),
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
return snapshots;
|
|
1446
|
+
}
|
|
1447
|
+
function expectedMigrationSnapshots(root, changes) {
|
|
1448
|
+
return [...new Map(changes.map((change) => [change.path, change])).values()].map(({ path, content }) => ({
|
|
1449
|
+
path: relative(root, path).split(sep).join("/"),
|
|
1450
|
+
existed: content !== undefined,
|
|
1451
|
+
contentBase64: content ? Buffer.from(content).toString("base64") : "",
|
|
1452
|
+
}));
|
|
1453
|
+
}
|
|
1454
|
+
function migrationSnapshotMatches(fs, path, expected) {
|
|
1455
|
+
const type = fs.pathType?.(path);
|
|
1456
|
+
if (!expected.existed)
|
|
1457
|
+
return type === "missing";
|
|
1458
|
+
if (type !== "file")
|
|
1459
|
+
return false;
|
|
1460
|
+
return Buffer.from(readResourceBytes(fs, path)).equals(Buffer.from(expected.contentBase64, "base64"));
|
|
1461
|
+
}
|
|
1462
|
+
function applyProjectFileChanges(fs, root, changes) {
|
|
1463
|
+
const ordered = [...new Map(changes.map((change) => [change.path, change])).values()];
|
|
1464
|
+
if (ordered.some(({ content }) => content === undefined) && !fs.remove)
|
|
1465
|
+
throw new Error("Filesystem cannot remove obsolete migration resources.");
|
|
1466
|
+
const snapshots = snapshotProjectFiles(fs, root, ordered.map(({ path }) => path));
|
|
1467
|
+
try {
|
|
1468
|
+
for (const change of ordered) {
|
|
1469
|
+
if (change.content === undefined) {
|
|
1470
|
+
if (fs.exists(change.path))
|
|
1471
|
+
fs.remove?.(change.path);
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
writeResourceBytes(fs, change.path, change.content);
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
catch (error) {
|
|
1479
|
+
let rollbackFailed = false;
|
|
1480
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
1481
|
+
const path = resolve(root, snapshot.path);
|
|
1482
|
+
try {
|
|
1483
|
+
if (snapshot.existed)
|
|
1484
|
+
writeResourceBytes(fs, path, Buffer.from(snapshot.contentBase64, "base64"));
|
|
1485
|
+
else if (fs.exists(path))
|
|
1486
|
+
fs.remove?.(path);
|
|
1487
|
+
}
|
|
1488
|
+
catch {
|
|
1489
|
+
rollbackFailed = true;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
if (rollbackFailed)
|
|
1493
|
+
throw new Error("Migration failed and automatic rollback was incomplete; use aiw rollback.", {
|
|
1494
|
+
cause: error,
|
|
1495
|
+
});
|
|
1496
|
+
throw error;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1046
1499
|
function parsePrivateRegistryPackages(payload, registryUrl) {
|
|
1047
1500
|
if (!Array.isArray(payload))
|
|
1048
1501
|
throw new Error("Private registry response must be an array.");
|
|
@@ -1080,6 +1533,135 @@ function containsCredential(value, credential, visited = new Set()) {
|
|
|
1080
1533
|
visited.add(value);
|
|
1081
1534
|
return Object.values(value).some((entry) => containsCredential(entry, credential, visited));
|
|
1082
1535
|
}
|
|
1536
|
+
function syncSelectedResources(fs, root, aiw, profile, selected) {
|
|
1537
|
+
const target = parseManifest(fs.read(join(aiw, "manifest.yml"))).target.active;
|
|
1538
|
+
const resourceSelections = resourcesForCapabilities(selected);
|
|
1539
|
+
const bundled = loadBundledResources(target);
|
|
1540
|
+
const bundleByKey = new Map(bundled.map((resource) => [`${resource.type}/${resource.id}`, resource]));
|
|
1541
|
+
const desired = new Map();
|
|
1542
|
+
const generatedFiles = new Map();
|
|
1543
|
+
const addOwned = (path, content, metadata) => {
|
|
1544
|
+
const previous = desired.get(path);
|
|
1545
|
+
if (previous && previous.content !== content)
|
|
1546
|
+
throw new Error(`Selected resources render conflicting content at ${path}`);
|
|
1547
|
+
desired.set(path, { content, metadata });
|
|
1548
|
+
};
|
|
1549
|
+
const addResource = (type, id, content) => {
|
|
1550
|
+
const neutralPath = join(root, renderResourcePath("universal", type, id));
|
|
1551
|
+
const targetPath = join(root, renderResourcePath(target, type, id));
|
|
1552
|
+
addOwned(neutralPath, content, {
|
|
1553
|
+
kind: "neutral-resource",
|
|
1554
|
+
target: "universal",
|
|
1555
|
+
resourceType: type,
|
|
1556
|
+
resourceId: id,
|
|
1557
|
+
});
|
|
1558
|
+
if (targetPath !== neutralPath)
|
|
1559
|
+
addOwned(targetPath, content, {
|
|
1560
|
+
kind: "target-resource",
|
|
1561
|
+
target,
|
|
1562
|
+
resourceType: type,
|
|
1563
|
+
resourceId: id,
|
|
1564
|
+
});
|
|
1565
|
+
};
|
|
1566
|
+
for (const reference of resourceSelections) {
|
|
1567
|
+
const key = `${reference.type}/${reference.id}`;
|
|
1568
|
+
const resource = bundleByKey.get(key);
|
|
1569
|
+
if (!resource)
|
|
1570
|
+
return { error: `Bundled resource is unavailable: ${key}` };
|
|
1571
|
+
addResource(reference.type, reference.id, personalizeBundledResource(reference.type, reference.id, resource.content, profile));
|
|
1572
|
+
}
|
|
1573
|
+
generateProjectResources(profile, selected, (path, content) => {
|
|
1574
|
+
generatedFiles.set(join(aiw, path), content);
|
|
1575
|
+
const rule = path.match(/^generated\/rules\/(project-quality|tdd-project-policy)\.md$/);
|
|
1576
|
+
if (rule)
|
|
1577
|
+
addResource("rules", rule[1], content);
|
|
1578
|
+
});
|
|
1579
|
+
const ownershipPath = join(aiw, "ownership.yml");
|
|
1580
|
+
if (fs.pathType?.(ownershipPath) !== "file")
|
|
1581
|
+
return {
|
|
1582
|
+
error: "AI Workflow ownership inventory is missing or unsafe; reinstall before syncing.",
|
|
1583
|
+
};
|
|
1584
|
+
const inventory = parseOwnership(fs.read(ownershipPath));
|
|
1585
|
+
const nextInventory = new Map(inventory.map((entry) => [entry.path, entry]));
|
|
1586
|
+
const changes = [];
|
|
1587
|
+
const conflicts = [];
|
|
1588
|
+
for (const [path, { content, metadata }] of desired) {
|
|
1589
|
+
assertSafeProjectPath(fs, root, path);
|
|
1590
|
+
const relativePath = relative(root, path).split(sep).join("/");
|
|
1591
|
+
const type = fs.pathType?.(path);
|
|
1592
|
+
const owned = nextInventory.get(relativePath);
|
|
1593
|
+
if (type === "missing") {
|
|
1594
|
+
changes.push({ path, content: Buffer.from(content, "utf8") });
|
|
1595
|
+
nextInventory.set(relativePath, createOwnedFile(relativePath, content, metadata));
|
|
1596
|
+
continue;
|
|
1597
|
+
}
|
|
1598
|
+
if (type !== "file" || !owned) {
|
|
1599
|
+
conflicts.push(relativePath);
|
|
1600
|
+
continue;
|
|
1601
|
+
}
|
|
1602
|
+
const actual = fs.read(path);
|
|
1603
|
+
if (createOwnedFile(relativePath, actual).checksum !== owned.checksum) {
|
|
1604
|
+
conflicts.push(relativePath);
|
|
1605
|
+
continue;
|
|
1606
|
+
}
|
|
1607
|
+
if (actual !== content)
|
|
1608
|
+
changes.push({ path, content: Buffer.from(content, "utf8") });
|
|
1609
|
+
nextInventory.set(relativePath, createOwnedFile(relativePath, content, metadata));
|
|
1610
|
+
}
|
|
1611
|
+
for (const [path, content] of generatedFiles) {
|
|
1612
|
+
assertSafeProjectPath(fs, root, path);
|
|
1613
|
+
const type = fs.pathType?.(path);
|
|
1614
|
+
if (type === "missing")
|
|
1615
|
+
changes.push({ path, content: Buffer.from(content, "utf8") });
|
|
1616
|
+
else if (type !== "file" || fs.read(path) !== content)
|
|
1617
|
+
conflicts.push(relative(root, path).split(sep).join("/"));
|
|
1618
|
+
}
|
|
1619
|
+
if (conflicts.length)
|
|
1620
|
+
return {
|
|
1621
|
+
error: `Sync preserved existing or modified project resources: ${[...new Set(conflicts)].sort().join(", ")}`,
|
|
1622
|
+
};
|
|
1623
|
+
const nextOwnership = serializeOwnership([...nextInventory.values()]);
|
|
1624
|
+
if (fs.read(ownershipPath) !== nextOwnership)
|
|
1625
|
+
changes.push({ path: ownershipPath, content: Buffer.from(nextOwnership, "utf8") });
|
|
1626
|
+
if (changes.length)
|
|
1627
|
+
applyProjectFileChanges(fs, root, changes);
|
|
1628
|
+
return {
|
|
1629
|
+
output: `Synchronized ${resourceSelections.length} selected bundled resources for ${target} and generated ${generatedFiles.size} project-specific files.`,
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
function personalizeBundledResource(type, id, content, profile) {
|
|
1633
|
+
if (type !== "rules" || !["quality-gates", "tdd-policy", "dependency-policy"].includes(id))
|
|
1634
|
+
return content;
|
|
1635
|
+
const signals = [
|
|
1636
|
+
profile.runtime.languages.length ? `- Languages: ${profile.runtime.languages.join(", ")}` : "",
|
|
1637
|
+
profile.frameworks.length ? `- Frameworks: ${profile.frameworks.join(", ")}` : "",
|
|
1638
|
+
profile.testing ? `- Test runner: ${profile.testing.name}` : "",
|
|
1639
|
+
profile.testing?.command
|
|
1640
|
+
? `- Test command: \`${safeProjectSignal(profile.testing.command)}\``
|
|
1641
|
+
: "",
|
|
1642
|
+
profile.quality.linter ? `- Linter: ${profile.quality.linter.name}` : "",
|
|
1643
|
+
profile.quality.linter?.command
|
|
1644
|
+
? `- Lint command: \`${safeProjectSignal(profile.quality.linter.command)}\``
|
|
1645
|
+
: "",
|
|
1646
|
+
profile.quality.formatter ? `- Formatter: ${profile.quality.formatter.name}` : "",
|
|
1647
|
+
profile.quality.formatter?.command
|
|
1648
|
+
? `- Format command: \`${safeProjectSignal(profile.quality.formatter.command)}\``
|
|
1649
|
+
: "",
|
|
1650
|
+
].filter(Boolean);
|
|
1651
|
+
return signals.length
|
|
1652
|
+
? `${content.trimEnd()}\n\n## Detected project context\n\n${signals.join("\n")}\n`
|
|
1653
|
+
: content;
|
|
1654
|
+
}
|
|
1655
|
+
function safeProjectSignal(value) {
|
|
1656
|
+
return value
|
|
1657
|
+
.split("")
|
|
1658
|
+
.map((character) => character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127 || character === "`"
|
|
1659
|
+
? " "
|
|
1660
|
+
: character)
|
|
1661
|
+
.join("")
|
|
1662
|
+
.replace(/\s+/g, " ")
|
|
1663
|
+
.slice(0, 180);
|
|
1664
|
+
}
|
|
1083
1665
|
function readSelectedRecommendations(fs, aiw) {
|
|
1084
1666
|
if (!fs.exists(join(aiw, "recommendations.yml")))
|
|
1085
1667
|
return [];
|