swallowkit 1.0.3 → 1.1.0

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.
Files changed (72) hide show
  1. package/dist/cli/commands/create-migration.d.ts +3 -0
  2. package/dist/cli/commands/create-migration.d.ts.map +1 -0
  3. package/dist/cli/commands/create-migration.js +16 -0
  4. package/dist/cli/commands/create-migration.js.map +1 -0
  5. package/dist/cli/commands/index.d.ts +2 -0
  6. package/dist/cli/commands/index.d.ts.map +1 -1
  7. package/dist/cli/commands/index.js +5 -1
  8. package/dist/cli/commands/index.js.map +1 -1
  9. package/dist/cli/commands/init.d.ts.map +1 -1
  10. package/dist/cli/commands/init.js +2 -0
  11. package/dist/cli/commands/init.js.map +1 -1
  12. package/dist/cli/commands/migrations.d.ts +3 -0
  13. package/dist/cli/commands/migrations.d.ts.map +1 -0
  14. package/dist/cli/commands/migrations.js +22 -0
  15. package/dist/cli/commands/migrations.js.map +1 -0
  16. package/dist/cli/commands/provision.d.ts +1 -1
  17. package/dist/cli/commands/provision.d.ts.map +1 -1
  18. package/dist/cli/commands/provision.js +95 -185
  19. package/dist/cli/commands/provision.js.map +1 -1
  20. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  21. package/dist/cli/commands/scaffold.js +6 -130
  22. package/dist/cli/commands/scaffold.js.map +1 -1
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +4 -0
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/core/operations/migration-generator.d.ts +10 -0
  27. package/dist/core/operations/migration-generator.d.ts.map +1 -0
  28. package/dist/core/operations/migration-generator.js +194 -0
  29. package/dist/core/operations/migration-generator.js.map +1 -0
  30. package/dist/core/operations/provision-operations.d.ts +19 -0
  31. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  32. package/dist/core/operations/provision-operations.js +299 -30
  33. package/dist/core/operations/provision-operations.js.map +1 -1
  34. package/dist/core/project/capabilities.js +1 -1
  35. package/dist/core/project/capabilities.js.map +1 -1
  36. package/dist/core/project/infra.d.ts +4 -0
  37. package/dist/core/project/infra.d.ts.map +1 -1
  38. package/dist/core/project/infra.js +19 -1
  39. package/dist/core/project/infra.js.map +1 -1
  40. package/dist/core/project/migrations.d.ts +53 -0
  41. package/dist/core/project/migrations.d.ts.map +1 -0
  42. package/dist/core/project/migrations.js +262 -0
  43. package/dist/core/project/migrations.js.map +1 -0
  44. package/dist/core/project/workflows.d.ts.map +1 -1
  45. package/dist/core/project/workflows.js +7 -3
  46. package/dist/core/project/workflows.js.map +1 -1
  47. package/dist/machine/index.d.ts.map +1 -1
  48. package/dist/machine/index.js +6 -0
  49. package/dist/machine/index.js.map +1 -1
  50. package/dist/mcp/index.d.ts.map +1 -1
  51. package/dist/mcp/index.js +11 -2
  52. package/dist/mcp/index.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/__tests__/agent-loop-phase34.test.ts +139 -0
  55. package/src/__tests__/init.test.ts +8 -0
  56. package/src/__tests__/migration.test.ts +116 -0
  57. package/src/__tests__/scaffold.test.ts +7 -4
  58. package/src/cli/commands/create-migration.ts +13 -0
  59. package/src/cli/commands/index.ts +2 -0
  60. package/src/cli/commands/init.ts +2 -0
  61. package/src/cli/commands/migrations.ts +20 -0
  62. package/src/cli/commands/provision.ts +115 -184
  63. package/src/cli/commands/scaffold.ts +6 -145
  64. package/src/cli/index.ts +4 -0
  65. package/src/core/operations/migration-generator.ts +188 -0
  66. package/src/core/operations/provision-operations.ts +391 -44
  67. package/src/core/project/capabilities.ts +1 -1
  68. package/src/core/project/infra.ts +23 -1
  69. package/src/core/project/migrations.ts +274 -0
  70. package/src/core/project/workflows.ts +7 -3
  71. package/src/machine/index.ts +7 -1
  72. package/src/mcp/index.ts +8 -2
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import * as crypto from "crypto";
11
+ import * as fs from "fs";
11
12
  import * as path from "path";
12
13
  import { spawnSync } from "child_process";
13
14
  import { MachineCommandError } from "../../machine/errors";
@@ -15,6 +16,23 @@ import { getSwallowKitVersion } from "../../version";
15
16
  import { inspectInfra, InfraInspection } from "../project/infra";
16
17
  import { deletePlanState, loadPlanState, savePlanState } from "../project/state";
17
18
  import { hashFileIfExists } from "./file-session";
19
+ import {
20
+ MIGRATION_MANIFEST_PATH,
21
+ MigrationManifest,
22
+ MigrationState,
23
+ ResolvedMigration,
24
+ checksumAtVersion,
25
+ collectLegacyBaselineFiles,
26
+ computeBaselineChecksum,
27
+ decodeMigrationState,
28
+ encodeMigrationState,
29
+ findBaselineDrift,
30
+ getMigrationTagKey,
31
+ loadMigrationManifest,
32
+ replaceMigrationManifest,
33
+ resolveMigrations,
34
+ selectPendingMigrations,
35
+ } from "../project/migrations";
18
36
 
19
37
  const RESOURCE_GROUP_PATTERN = /^[A-Za-z0-9._()-]{1,90}$/;
20
38
  const LOCATION_PATTERN = /^[a-z0-9]{3,30}$/;
@@ -27,6 +45,18 @@ export interface PlanProvisionOptions {
27
45
  subscription?: string;
28
46
  /** 明示指定時のみ az deployment group what-if を実行(az login 済みが前提) */
29
47
  whatIf?: boolean;
48
+ adoptExisting?: boolean;
49
+ baseline?: number;
50
+ reconcile?: boolean;
51
+ }
52
+
53
+ export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile";
54
+
55
+ interface ProvisionCommand {
56
+ file: "az";
57
+ args: string[];
58
+ kind: "resource-group" | "deployment" | "migration" | "tag";
59
+ expectedState?: MigrationState | null;
30
60
  }
31
61
 
32
62
  export interface ProvisionPlanData {
@@ -39,14 +69,21 @@ export interface ProvisionPlanData {
39
69
  createdAt: string;
40
70
  swallowkitVersion: string;
41
71
  azCliAvailable: boolean;
72
+ mode: ProvisionMode;
42
73
  infra: InfraInspection;
43
74
  /** apply 時に実行されるコマンド(レビュー用) */
44
75
  commands: string[];
76
+ commandSpecs: ProvisionCommand[];
77
+ migrationTagKey?: string;
78
+ remoteState?: MigrationState | null;
79
+ pendingMigrations: ResolvedMigration[];
80
+ manifestAfterApply?: MigrationManifest;
45
81
  whatIfResult?: string;
46
82
  warnings: string[];
47
83
  /** provision は常に人間の承認が必要 */
48
84
  requiresApproval: true;
49
85
  fingerprints: Record<string, string | null>;
86
+ baselineSnapshotChecksum?: string;
50
87
  }
51
88
 
52
89
  function validateProvisionInputs(options: PlanProvisionOptions): void {
@@ -78,25 +115,122 @@ function validateProvisionInputs(options: PlanProvisionOptions): void {
78
115
 
79
116
  function isAzCliAvailable(): boolean {
80
117
  try {
81
- const result = spawnSync("az", ["--version"], { shell: true, stdio: "ignore", timeout: 30_000 });
118
+ const result = spawnSync("az", ["--version"], { shell: false, stdio: "ignore", timeout: 30_000 });
82
119
  return result.status === 0;
83
120
  } catch {
84
121
  return false;
85
122
  }
86
123
  }
87
124
 
88
- function buildProvisionCommands(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): string[] {
89
- const commands: string[] = [];
90
- if (options.subscription) {
91
- commands.push(`az account set --subscription ${options.subscription}`);
92
- }
93
- commands.push(`az group create --name ${options.resourceGroup} --location ${options.location}`);
94
- commands.push(
95
- `az deployment group create --resource-group ${options.resourceGroup} --template-file "${mainBicepPath}" --parameters "${parametersPath}" --parameters location=${options.location} --parameters swaLocation=${options.swaLocation}`
96
- );
125
+ function displayCommand(command: ProvisionCommand): string {
126
+ return [command.file, ...command.args.map((arg) => /\s/.test(arg) ? JSON.stringify(arg) : arg)].join(" ");
127
+ }
128
+
129
+ interface AzResult {
130
+ status: number | null;
131
+ stdout: string;
132
+ stderr: string;
133
+ }
134
+
135
+ function runAz(args: string[], timeout = 300_000): AzResult {
136
+ const result = spawnSync("az", args, { shell: false, encoding: "utf-8", timeout });
137
+ return {
138
+ status: result.status,
139
+ stdout: result.stdout ?? "",
140
+ stderr: result.stderr ?? "",
141
+ };
142
+ }
143
+
144
+ function subscriptionArgs(subscription?: string): string[] {
145
+ return subscription ? ["--subscription", subscription] : [];
146
+ }
147
+
148
+ function buildBootstrapCommands(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): ProvisionCommand[] {
149
+ const commands: ProvisionCommand[] = [];
150
+ commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription)], kind: "resource-group" });
151
+ commands.push({
152
+ file: "az",
153
+ args: ["deployment", "group", "create", "--name", "swallowkit-baseline", "--resource-group", options.resourceGroup, "--template-file", mainBicepPath, "--parameters", parametersPath, "--parameters", `location=${options.location}`, `swaLocation=${options.swaLocation}`, ...subscriptionArgs(options.subscription)],
154
+ kind: "deployment",
155
+ });
97
156
  return commands;
98
157
  }
99
158
 
159
+ function readProjectName(parametersPath: string): string {
160
+ try {
161
+ const parameters = JSON.parse(fs.readFileSync(parametersPath, "utf-8"));
162
+ const projectName = parameters?.parameters?.projectName?.value;
163
+ if (typeof projectName === "string" && /^[A-Za-z0-9-]+$/.test(projectName)) return projectName;
164
+ } catch {
165
+ // The caller reports the same actionable error for malformed and missing values.
166
+ }
167
+ throw new MachineCommandError("invalid-infra-parameters", "infra/main.parameters.json must define parameters.projectName.value.", undefined, "blocked");
168
+ }
169
+
170
+ interface RemoteGroupState {
171
+ exists: boolean;
172
+ id?: string;
173
+ tags: Record<string, string>;
174
+ }
175
+
176
+ function inspectRemoteGroup(options: PlanProvisionOptions): RemoteGroupState {
177
+ const existsResult = runAz(["group", "exists", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription)], 60_000);
178
+ if (existsResult.status !== 0) {
179
+ throw new MachineCommandError("azure-state-query-failed", `Could not query resource group: ${(existsResult.stderr || existsResult.stdout || "").trim()}`, undefined, "blocked");
180
+ }
181
+ if ((existsResult.stdout || "").trim() !== "true") return { exists: false, tags: {} };
182
+ const showResult = runAz(["group", "show", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], 60_000);
183
+ if (showResult.status !== 0) {
184
+ throw new MachineCommandError("azure-state-query-failed", `Could not read resource group tags: ${(showResult.stderr || showResult.stdout || "").trim()}`, undefined, "blocked");
185
+ }
186
+ const parsed = JSON.parse(showResult.stdout || "{}");
187
+ return { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
188
+ }
189
+
190
+ function tagCommand(resourceId: string, key: string, state: MigrationState, expectedState: MigrationState | null): ProvisionCommand {
191
+ return { file: "az", args: ["tag", "update", "--resource-id", resourceId, "--operation", "Merge", "--tags", `${key}=${encodeMigrationState(state)}`], kind: "tag", expectedState };
192
+ }
193
+
194
+ function bootstrapTagCommand(options: PlanProvisionOptions, key: string, state: MigrationState): ProvisionCommand {
195
+ return { file: "az", args: ["group", "update", "--name", options.resourceGroup, "--set", `tags.${key}=${encodeMigrationState(state)}`, ...subscriptionArgs(options.subscription)], kind: "tag", expectedState: null };
196
+ }
197
+
198
+ function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): string {
199
+ const result = runAz([
200
+ "deployment", "group", "what-if",
201
+ "--resource-group", options.resourceGroup,
202
+ "--template-file", mainBicepPath,
203
+ "--parameters", parametersPath,
204
+ "--parameters", `location=${options.location}`, `swaLocation=${options.swaLocation}`,
205
+ ...subscriptionArgs(options.subscription),
206
+ "--result-format", "FullResourcePayloads",
207
+ "--no-pretty-print",
208
+ "-o", "json",
209
+ ]);
210
+ if (result.status !== 0) {
211
+ throw new MachineCommandError("what-if-failed", `az deployment group what-if failed: ${(result.stderr || result.stdout).trim().slice(0, 2000)}`, undefined, "blocked");
212
+ }
213
+ return result.stdout.trim();
214
+ }
215
+
216
+ function assertAdoptionWhatIfIsSafe(whatIfResult: string): void {
217
+ try {
218
+ const parsed = JSON.parse(whatIfResult) as { changes?: Array<{ changeType?: string; resourceId?: string }> };
219
+ const destructive = (parsed.changes ?? []).filter((change) => change.changeType === "Create" || change.changeType === "Delete");
220
+ if (destructive.length > 0) {
221
+ throw new MachineCommandError(
222
+ "adoption-drift-blocked",
223
+ "Legacy adoption found resources that would be created or deleted. Move undeployed additions into migrations before adopting this environment.",
224
+ { changes: destructive },
225
+ "blocked"
226
+ );
227
+ }
228
+ } catch (error) {
229
+ if (error instanceof MachineCommandError) throw error;
230
+ throw new MachineCommandError("what-if-invalid", "Azure what-if did not return valid JSON for legacy adoption.", undefined, "blocked");
231
+ }
232
+ }
233
+
100
234
  export async function planProvisionOperation(options: PlanProvisionOptions): Promise<ProvisionPlanData> {
101
235
  const projectRoot = process.cwd();
102
236
  validateProvisionInputs(options);
@@ -123,43 +257,165 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
123
257
 
124
258
  const mainBicepPath = path.join(projectRoot, "infra", "main.bicep");
125
259
  const parametersPath = path.join(projectRoot, "infra", "main.parameters.json");
126
- const commands = buildProvisionCommands(options, mainBicepPath, parametersPath);
127
-
260
+ let mode: ProvisionMode = "offline";
261
+ let commandSpecs: ProvisionCommand[] = buildBootstrapCommands(options, mainBicepPath, parametersPath);
262
+ let pendingMigrations: ResolvedMigration[] = [];
263
+ let migrationTagKey: string | undefined;
264
+ let remoteState: MigrationState | null | undefined;
128
265
  let whatIfResult: string | undefined;
129
- if (options.whatIf) {
130
- if (!azCliAvailable) {
131
- throw new MachineCommandError(
132
- "az-cli-not-found",
133
- "Azure CLI is required for --what-if. Install it first: https://aka.ms/azure-cli",
134
- undefined,
135
- "blocked"
136
- );
266
+ let manifestAfterApply: MigrationManifest | undefined;
267
+ let baselineDrift: string[] = [];
268
+
269
+ const manifestPath = path.join(projectRoot, MIGRATION_MANIFEST_PATH);
270
+ const manifest = fs.existsSync(manifestPath) ? loadMigrationManifest(projectRoot) : null;
271
+ if (manifest) {
272
+ for (const migration of manifest.migrations) {
273
+ const files = [migration.template, migration.parameters].filter((value): value is string => Boolean(value));
274
+ for (const relativePath of files) {
275
+ if (!fs.existsSync(path.join(projectRoot, relativePath))) {
276
+ throw new MachineCommandError("migration-file-not-found", `Migration file not found: ${relativePath}`, undefined, "blocked");
277
+ }
278
+ }
137
279
  }
138
- const whatIfArgs = [
139
- "deployment", "group", "what-if",
140
- "--resource-group", options.resourceGroup,
141
- "--template-file", `"${mainBicepPath}"`,
142
- "--parameters", `"${parametersPath}"`,
143
- "--parameters", `location=${options.location}`,
144
- "--parameters", `swaLocation=${options.swaLocation}`,
145
- "--no-pretty-print",
146
- ];
147
- const result = spawnSync("az", whatIfArgs, { shell: true, encoding: "utf-8", timeout: 300_000 });
148
- if (result.status !== 0) {
149
- throw new MachineCommandError(
150
- "what-if-failed",
151
- `az deployment group what-if failed: ${(result.stderr || result.stdout || "").trim().slice(0, 2000)}`,
152
- undefined,
153
- "blocked"
154
- );
280
+ baselineDrift = findBaselineDrift(manifest, projectRoot);
281
+ }
282
+
283
+ if (azCliAvailable) {
284
+ const remoteGroup = inspectRemoteGroup(options);
285
+ if (!manifest) {
286
+ if (remoteGroup.exists) {
287
+ throw new MachineCommandError(
288
+ "migration-manifest-not-found",
289
+ 'Existing Azure resources require migration tracking. Run "swallowkit migrations init --legacy-baseline" first.',
290
+ undefined,
291
+ "blocked"
292
+ );
293
+ }
294
+ throw new MachineCommandError("migration-manifest-not-found", "Initialize a migration manifest before the first provision.", undefined, "blocked");
155
295
  }
156
- whatIfResult = (result.stdout || "").trim();
296
+
297
+ migrationTagKey = getMigrationTagKey(manifest.projectId);
298
+ if (!remoteGroup.exists) {
299
+ mode = "bootstrap";
300
+ const baselineFiles = collectLegacyBaselineFiles(projectRoot);
301
+ manifestAfterApply = {
302
+ ...manifest,
303
+ baseline: { ...manifest.baseline, files: baselineFiles, checksum: computeBaselineChecksum(baselineFiles) },
304
+ };
305
+ const baselineState: MigrationState = { schemaVersion: 1, version: 0, checksum: manifestAfterApply.baseline.checksum };
306
+ commandSpecs = [...buildBootstrapCommands(options, mainBicepPath, parametersPath), bootstrapTagCommand(options, migrationTagKey, baselineState)];
307
+ if (options.whatIf) warnings.push("what-if-skipped: Resource-group-scope what-if requires the target resource group to exist.");
308
+ } else {
309
+ if (baselineDrift.length > 0 && !options.reconcile) {
310
+ throw new MachineCommandError(
311
+ "baseline-drift",
312
+ "Baseline infrastructure files changed after migration tracking was initialized. Move additive changes into a migration or use --reconcile for an intentional full deployment.",
313
+ { changedFiles: baselineDrift },
314
+ "blocked"
315
+ );
316
+ }
317
+ const tagValue = remoteGroup.tags[migrationTagKey];
318
+ remoteState = tagValue ? decodeMigrationState(tagValue) : null;
319
+ if (!remoteState) {
320
+ mode = "adoption-required";
321
+ commandSpecs = [];
322
+ if (options.reconcile && !manifest.baseline.legacy) {
323
+ whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
324
+ warnings.push("Recovering an interrupted bootstrap by reconciling the baseline before recording migration state.");
325
+ mode = "reconcile";
326
+ const baselineFiles = collectLegacyBaselineFiles(projectRoot);
327
+ manifestAfterApply = {
328
+ ...manifest,
329
+ baseline: { ...manifest.baseline, files: baselineFiles, checksum: computeBaselineChecksum(baselineFiles) },
330
+ };
331
+ commandSpecs = buildBootstrapCommands(options, mainBicepPath, parametersPath).filter((command) => command.kind !== "resource-group");
332
+ commandSpecs.push(tagCommand(
333
+ remoteGroup.id!,
334
+ migrationTagKey,
335
+ { schemaVersion: 1, version: 0, checksum: manifestAfterApply.baseline.checksum },
336
+ null
337
+ ));
338
+ } else if (options.adoptExisting) {
339
+ if (options.baseline !== 0) {
340
+ throw new MachineCommandError("invalid-baseline", "Legacy adoption currently supports only --baseline 0.", undefined, "blocked");
341
+ }
342
+ if (!options.whatIf) {
343
+ throw new MachineCommandError("adoption-what-if-required", "Legacy adoption requires --what-if before the baseline tag can be recorded.", undefined, "requires-human");
344
+ }
345
+ if (!manifest.baseline.legacy) {
346
+ throw new MachineCommandError("invalid-baseline", "--adopt-existing requires a manifest created with migrations init --legacy-baseline.", undefined, "blocked");
347
+ }
348
+ whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
349
+ assertAdoptionWhatIfIsSafe(whatIfResult);
350
+ warnings.push("Legacy adoption records the current environment as baseline 0 without deploying main.bicep. Review all Modify results before approval.");
351
+ mode = "adopt";
352
+ commandSpecs = [tagCommand(remoteGroup.id!, migrationTagKey, { schemaVersion: 1, version: 0, checksum: manifest.baseline.checksum }, null)];
353
+ }
354
+ } else if (options.reconcile) {
355
+ selectPendingMigrations(manifest, remoteState, projectRoot);
356
+ mode = "reconcile";
357
+ warnings.push("Reconcile redeploys infra/main.bicep and may reset template-managed properties such as Function App settings.");
358
+ whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
359
+ commandSpecs = buildBootstrapCommands(options, mainBicepPath, parametersPath).filter((command) => command.kind !== "resource-group");
360
+ const baselineFiles = collectLegacyBaselineFiles(projectRoot);
361
+ manifestAfterApply = {
362
+ ...manifest,
363
+ baseline: { ...manifest.baseline, files: baselineFiles, checksum: computeBaselineChecksum(baselineFiles) },
364
+ };
365
+ const reconciledState: MigrationState = {
366
+ schemaVersion: 1,
367
+ version: remoteState.version,
368
+ checksum: checksumAtVersion(manifestAfterApply, remoteState.version, projectRoot),
369
+ };
370
+ commandSpecs.push(tagCommand(remoteGroup.id!, migrationTagKey, reconciledState, remoteState));
371
+ } else {
372
+ pendingMigrations = selectPendingMigrations(manifest, remoteState, projectRoot);
373
+ mode = pendingMigrations.length > 0 ? "migrate" : "noop";
374
+ commandSpecs = [];
375
+ const projectName = pendingMigrations.length > 0 ? readProjectName(parametersPath) : "";
376
+ let expectedState = remoteState;
377
+ for (const migration of pendingMigrations) {
378
+ commandSpecs.push({
379
+ file: "az",
380
+ args: [
381
+ "deployment", "group", "create",
382
+ "--name", "swallowkit-migration",
383
+ "--resource-group", options.resourceGroup,
384
+ "--template-file", path.join(projectRoot, migration.template),
385
+ ...(migration.parameters ? ["--parameters", path.join(projectRoot, migration.parameters)] : []),
386
+ "--parameters", `projectName=${projectName}`, `location=${options.location}`,
387
+ ...subscriptionArgs(options.subscription),
388
+ ],
389
+ kind: "migration",
390
+ expectedState,
391
+ });
392
+ const nextState = { schemaVersion: 1 as const, version: migration.version, checksum: migration.cumulativeChecksum };
393
+ commandSpecs.push(tagCommand(remoteGroup.id!, migrationTagKey, nextState, expectedState));
394
+ expectedState = nextState;
395
+ }
396
+ }
397
+ }
398
+ } else if (options.whatIf || options.adoptExisting || options.reconcile) {
399
+ throw new MachineCommandError("az-cli-not-found", "Azure CLI is required for --what-if, --adopt-existing, and --reconcile.", undefined, "blocked");
157
400
  }
158
401
 
159
402
  const fingerprints: Record<string, string | null> = {
160
403
  "infra/main.bicep": hashFileIfExists(mainBicepPath),
161
404
  "infra/main.parameters.json": hashFileIfExists(parametersPath),
162
405
  };
406
+ const baselineSnapshotChecksum = manifest
407
+ ? computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot))
408
+ : undefined;
409
+ if (manifest) {
410
+ fingerprints[MIGRATION_MANIFEST_PATH] = hashFileIfExists(manifestPath);
411
+ for (const baselineFile of manifest.baseline.files) {
412
+ fingerprints[baselineFile.path] = hashFileIfExists(path.join(projectRoot, baselineFile.path));
413
+ }
414
+ for (const migration of manifest.migrations) {
415
+ fingerprints[migration.template] = hashFileIfExists(path.join(projectRoot, migration.template));
416
+ if (migration.parameters) fingerprints[migration.parameters] = hashFileIfExists(path.join(projectRoot, migration.parameters));
417
+ }
418
+ }
163
419
 
164
420
  const planId = crypto
165
421
  .createHash("sha256")
@@ -169,6 +425,10 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
169
425
  location: options.location,
170
426
  swaLocation: options.swaLocation,
171
427
  subscription: options.subscription,
428
+ mode,
429
+ remoteState,
430
+ pendingMigrations: pendingMigrations.map((migration) => ({ version: migration.version, checksum: migration.checksum })),
431
+ baselineSnapshotChecksum,
172
432
  fingerprints,
173
433
  })
174
434
  )
@@ -185,12 +445,19 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
185
445
  createdAt: new Date().toISOString(),
186
446
  swallowkitVersion: getSwallowKitVersion(),
187
447
  azCliAvailable,
448
+ mode,
188
449
  infra,
189
- commands,
450
+ commands: commandSpecs.map(displayCommand),
451
+ commandSpecs,
452
+ ...(migrationTagKey ? { migrationTagKey } : {}),
453
+ ...(remoteState !== undefined ? { remoteState } : {}),
454
+ pendingMigrations,
455
+ ...(manifestAfterApply ? { manifestAfterApply } : {}),
190
456
  ...(whatIfResult !== undefined ? { whatIfResult } : {}),
191
457
  warnings,
192
458
  requiresApproval: true,
193
459
  fingerprints,
460
+ ...(baselineSnapshotChecksum ? { baselineSnapshotChecksum } : {}),
194
461
  };
195
462
 
196
463
  savePlanState(plan, projectRoot);
@@ -212,6 +479,48 @@ export interface ApplyProvisionResult {
212
479
  warnings: string[];
213
480
  }
214
481
 
482
+ function statesEqual(left: MigrationState | null | undefined, right: MigrationState | null | undefined): boolean {
483
+ return left?.version === right?.version && left?.checksum === right?.checksum;
484
+ }
485
+
486
+ function readRemoteMigrationState(plan: ProvisionPlanData): { group: RemoteGroupState; state: MigrationState | null } {
487
+ const group = inspectRemoteGroup(plan);
488
+ if (!group.exists || !plan.migrationTagKey) return { group, state: null };
489
+ const tagValue = group.tags[plan.migrationTagKey];
490
+ return { group, state: tagValue ? decodeMigrationState(tagValue) : null };
491
+ }
492
+
493
+ function assertPlanRemoteState(plan: ProvisionPlanData, expected: MigrationState | null | undefined): void {
494
+ if (plan.mode === "offline") return;
495
+ const current = readRemoteMigrationState(plan);
496
+ if (plan.mode === "bootstrap") {
497
+ if (current.group.exists) {
498
+ throw new MachineCommandError("stale-azure-state", "The resource group was created after this plan. Re-run plan provision.", undefined, "blocked");
499
+ }
500
+ return;
501
+ }
502
+ if (!current.group.exists || !statesEqual(current.state, expected)) {
503
+ throw new MachineCommandError(
504
+ "stale-azure-state",
505
+ "Azure migration state changed after this plan was created. Re-run plan provision.",
506
+ { expected, actual: current.state, resourceGroupExists: current.group.exists },
507
+ "blocked"
508
+ );
509
+ }
510
+ }
511
+
512
+ function assertTagExpectedState(plan: ProvisionPlanData, expected: MigrationState | null | undefined): void {
513
+ const current = readRemoteMigrationState(plan);
514
+ if (!current.group.exists || !statesEqual(current.state, expected)) {
515
+ throw new MachineCommandError(
516
+ "stale-azure-state",
517
+ "Azure migration state changed before its tag could be updated. Re-run plan provision.",
518
+ { expected, actual: current.state, resourceGroupExists: current.group.exists },
519
+ "blocked"
520
+ );
521
+ }
522
+ }
523
+
215
524
  export async function applyProvisionOperation(options: ApplyProvisionOptions): Promise<ApplyProvisionResult> {
216
525
  const projectRoot = process.cwd();
217
526
 
@@ -239,6 +548,17 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
239
548
  "blocked"
240
549
  );
241
550
  }
551
+ if (plan.baselineSnapshotChecksum) {
552
+ const currentBaselineChecksum = computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot));
553
+ if (currentBaselineChecksum !== plan.baselineSnapshotChecksum) {
554
+ throw new MachineCommandError(
555
+ "stale-plan",
556
+ `Plan "${options.planId}" is stale: baseline infrastructure inventory changed after the plan was created.`,
557
+ { planId: options.planId },
558
+ "blocked"
559
+ );
560
+ }
561
+ }
242
562
 
243
563
  if (!options.approve) {
244
564
  throw new MachineCommandError(
@@ -248,6 +568,21 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
248
568
  "requires-human"
249
569
  );
250
570
  }
571
+ if (!plan.mode || !plan.commandSpecs) {
572
+ throw new MachineCommandError("incompatible-plan", "This provision plan was created by an older SwallowKit version. Re-run plan provision.", undefined, "blocked");
573
+ }
574
+ if (plan.mode === "offline") {
575
+ throw new MachineCommandError("online-plan-required", "Provision apply requires a fresh plan created while Azure CLI is available and authenticated.", undefined, "blocked");
576
+ }
577
+
578
+ if (plan.mode === "adoption-required") {
579
+ throw new MachineCommandError(
580
+ "adoption-required",
581
+ 'This existing resource group has no SwallowKit migration state. Re-plan with --adopt-existing --baseline 0 --what-if.',
582
+ { planId: plan.planId },
583
+ "requires-human"
584
+ );
585
+ }
251
586
 
252
587
  if (!isAzCliAvailable()) {
253
588
  throw new MachineCommandError(
@@ -262,18 +597,27 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
262
597
  const warnings = [...plan.warnings];
263
598
  let deploymentOutputs: Record<string, unknown> | undefined;
264
599
 
265
- for (const command of plan.commands) {
266
- const result = spawnSync(command, [], { shell: true, encoding: "utf-8" });
267
- executedCommands.push(command);
600
+ assertPlanRemoteState(plan, plan.remoteState);
601
+
602
+ for (const command of plan.commandSpecs) {
603
+ if (command.kind === "migration") {
604
+ assertPlanRemoteState(plan, command.expectedState);
605
+ }
606
+ if (command.kind === "tag") {
607
+ assertTagExpectedState(plan, command.expectedState);
608
+ }
609
+ const result = runAz(command.args);
610
+ const commandText = displayCommand(command);
611
+ executedCommands.push(commandText);
268
612
  if (result.status !== 0) {
269
613
  throw new MachineCommandError(
270
614
  "provision-failed",
271
- `Command failed: ${command}\n${(result.stderr || result.stdout || "").trim().slice(0, 4000)}`,
615
+ `Command failed: ${commandText}\n${(result.stderr || result.stdout).trim().slice(0, 4000)}`,
272
616
  { executedCommands },
273
617
  "failed"
274
618
  );
275
619
  }
276
- if (command.includes("deployment group create")) {
620
+ if (command.kind === "deployment" || command.kind === "migration") {
277
621
  try {
278
622
  const deployment = JSON.parse(result.stdout || "{}");
279
623
  deploymentOutputs = deployment.properties?.outputs;
@@ -283,6 +627,9 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
283
627
  }
284
628
  }
285
629
 
630
+ if (plan.manifestAfterApply) {
631
+ replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
632
+ }
286
633
  deletePlanState(plan.planId, projectRoot);
287
634
 
288
635
  return {
@@ -179,7 +179,7 @@ export function inspectCapabilities(): CapabilitiesContract {
179
179
  "inspect project | entities | routes | artifacts | boundaries | infra | drift | capabilities",
180
180
  "validate project",
181
181
  "generate model <names...> | generate scaffold <model>",
182
- "plan scaffold <models...> | plan auth --provider <p> [--scheme <name>] [--allowed-providers <csv>] | plan provision -g <rg> --location <loc> --swa-location <loc>",
182
+ "plan scaffold <models...> | plan auth --provider <p> [--scheme <name>] [--allowed-providers <csv>] | plan provision -g <rg> --location <loc> --swa-location <loc> [--adopt-existing --baseline 0 --what-if] [--reconcile --what-if]",
183
183
  "apply scaffold [models...] --plan <id> [--approve] | apply auth --plan <id> [--approve] | apply provision --plan <id> --approve",
184
184
  "verify project [--checks <ids>] [--compact]",
185
185
  "explain failure [--check <id>]",
@@ -47,6 +47,10 @@ export interface InfraInspection {
47
47
  };
48
48
  moduleFiles: string[];
49
49
  containers: InfraContainer[];
50
+ migrations: {
51
+ manifestExists: boolean;
52
+ versions: number[];
53
+ };
50
54
  boundary: {
51
55
  zone: "shared";
52
56
  note: string;
@@ -172,6 +176,20 @@ export function inspectInfra(projectRoot: string = process.cwd()): InfraInspecti
172
176
  return { file: `infra/containers/${f}`, wired };
173
177
  });
174
178
 
179
+ const migrationManifestPath = path.join(infraDir, "migrations", "manifest.json");
180
+ let migrationVersions: number[] = [];
181
+ if (fs.existsSync(migrationManifestPath)) {
182
+ try {
183
+ const manifest = JSON.parse(fs.readFileSync(migrationManifestPath, "utf-8")) as { migrations?: Array<{ version?: number }> };
184
+ migrationVersions = (manifest.migrations ?? [])
185
+ .map((migration) => migration.version)
186
+ .filter((version): version is number => Number.isSafeInteger(version))
187
+ .sort((left, right) => left - right);
188
+ } catch {
189
+ warnings.push("migration-manifest-invalid: infra/migrations/manifest.json is not valid JSON.");
190
+ }
191
+ }
192
+
175
193
  return {
176
194
  hasInfraDirectory,
177
195
  mainBicep: {
@@ -187,9 +205,13 @@ export function inspectInfra(projectRoot: string = process.cwd()): InfraInspecti
187
205
  },
188
206
  moduleFiles,
189
207
  containers,
208
+ migrations: {
209
+ manifestExists: fs.existsSync(migrationManifestPath),
210
+ versions: migrationVersions,
211
+ },
190
212
  boundary: {
191
213
  zone: "shared",
192
- note: "infra/main.bicep is an extension point: scaffold appends container modules deterministically; humans may edit other sections. infra/containers/*.bicep are managed (regenerated by scaffold).",
214
+ note: "infra/main.bicep and modules form the baseline. New model containers and custom resources belong in immutable infra/migrations entries.",
193
215
  },
194
216
  warnings,
195
217
  };