swallowkit 2.0.0-alpha.4 → 2.0.0-alpha.6

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 (113) hide show
  1. package/dist/cli/commands/add-auth.js +37 -6
  2. package/dist/cli/commands/add-auth.js.map +1 -1
  3. package/dist/cli/commands/init.d.ts +2 -0
  4. package/dist/cli/commands/init.d.ts.map +1 -1
  5. package/dist/cli/commands/init.js +3 -1
  6. package/dist/cli/commands/init.js.map +1 -1
  7. package/dist/cli/commands/provision.d.ts +2 -1
  8. package/dist/cli/commands/provision.d.ts.map +1 -1
  9. package/dist/cli/commands/provision.js +66 -4
  10. package/dist/cli/commands/provision.js.map +1 -1
  11. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  12. package/dist/cli/commands/scaffold.js +45 -1
  13. package/dist/cli/commands/scaffold.js.map +1 -1
  14. package/dist/cli/index.d.ts.map +1 -1
  15. package/dist/cli/index.js +15 -2
  16. package/dist/cli/index.js.map +1 -1
  17. package/dist/core/operations/create-model.d.ts +2 -0
  18. package/dist/core/operations/create-model.d.ts.map +1 -1
  19. package/dist/core/operations/create-model.js +60 -8
  20. package/dist/core/operations/create-model.js.map +1 -1
  21. package/dist/core/operations/provision-deployment.d.ts +45 -0
  22. package/dist/core/operations/provision-deployment.d.ts.map +1 -0
  23. package/dist/core/operations/provision-deployment.js +151 -0
  24. package/dist/core/operations/provision-deployment.js.map +1 -0
  25. package/dist/core/operations/provision-operations.d.ts +39 -5
  26. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  27. package/dist/core/operations/provision-operations.js +609 -103
  28. package/dist/core/operations/provision-operations.js.map +1 -1
  29. package/dist/core/project/boundaries.js +3 -3
  30. package/dist/core/project/boundaries.js.map +1 -1
  31. package/dist/core/project/capabilities.d.ts +9 -0
  32. package/dist/core/project/capabilities.d.ts.map +1 -1
  33. package/dist/core/project/capabilities.js +20 -6
  34. package/dist/core/project/capabilities.js.map +1 -1
  35. package/dist/core/project/react-project.js +1 -1
  36. package/dist/core/project/state.d.ts +7 -0
  37. package/dist/core/project/state.d.ts.map +1 -1
  38. package/dist/core/project/state.js +86 -0
  39. package/dist/core/project/state.js.map +1 -1
  40. package/dist/core/project/workflows.d.ts.map +1 -1
  41. package/dist/core/project/workflows.js +43 -7
  42. package/dist/core/project/workflows.js.map +1 -1
  43. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  44. package/dist/core/scaffold/auth-generator.js +18 -19
  45. package/dist/core/scaffold/auth-generator.js.map +1 -1
  46. package/dist/core/scaffold/native-schema-generator.d.ts +2 -0
  47. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  48. package/dist/core/scaffold/native-schema-generator.js +16 -1
  49. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  50. package/dist/core/verify/index.d.ts +4 -3
  51. package/dist/core/verify/index.d.ts.map +1 -1
  52. package/dist/core/verify/index.js +98 -4
  53. package/dist/core/verify/index.js.map +1 -1
  54. package/dist/machine/contracts.d.ts +1 -0
  55. package/dist/machine/contracts.d.ts.map +1 -1
  56. package/dist/machine/errors.d.ts +3 -2
  57. package/dist/machine/errors.d.ts.map +1 -1
  58. package/dist/machine/errors.js +2 -1
  59. package/dist/machine/errors.js.map +1 -1
  60. package/dist/machine/index.d.ts.map +1 -1
  61. package/dist/machine/index.js +20 -13
  62. package/dist/machine/index.js.map +1 -1
  63. package/dist/mcp/index.d.ts +3 -2
  64. package/dist/mcp/index.d.ts.map +1 -1
  65. package/dist/mcp/index.js +76 -15
  66. package/dist/mcp/index.js.map +1 -1
  67. package/dist/utils/azure-cli.d.ts +16 -4
  68. package/dist/utils/azure-cli.d.ts.map +1 -1
  69. package/dist/utils/azure-cli.js +166 -4
  70. package/dist/utils/azure-cli.js.map +1 -1
  71. package/dist/utils/azure-diagnostics.d.ts +16 -0
  72. package/dist/utils/azure-diagnostics.d.ts.map +1 -0
  73. package/dist/utils/azure-diagnostics.js +68 -0
  74. package/dist/utils/azure-diagnostics.js.map +1 -0
  75. package/package.json +6 -3
  76. package/src/__tests__/__snapshots__/boundaries.test.ts.snap +6 -6
  77. package/src/__tests__/agent-instructions.test.ts +3 -0
  78. package/src/__tests__/agent-loop-phase34.test.ts +114 -8
  79. package/src/__tests__/azure-cli.test.ts +5 -2
  80. package/src/__tests__/fixtures/az wrapper.cmd +2 -0
  81. package/src/__tests__/fixtures/azure-cli.cjs +130 -0
  82. package/src/__tests__/fixtures/provision-runner.cjs +28 -0
  83. package/src/__tests__/generated-project-matrix.test.ts +789 -0
  84. package/src/__tests__/mcp.test.ts +71 -0
  85. package/src/__tests__/multi-auth.test.ts +115 -4
  86. package/src/__tests__/plan-apply.test.ts +111 -0
  87. package/src/__tests__/provision-runtime.test.ts +562 -0
  88. package/src/cli/commands/add-auth.ts +36 -6
  89. package/src/cli/commands/init.ts +4 -4
  90. package/src/cli/commands/provision.ts +69 -5
  91. package/src/cli/commands/scaffold.ts +65 -1
  92. package/src/cli/index.ts +12 -2
  93. package/src/core/operations/create-model.ts +51 -13
  94. package/src/core/operations/provision-deployment.ts +167 -0
  95. package/src/core/operations/provision-operations.ts +597 -145
  96. package/src/core/project/agent-templates/AGENTS.md +46 -3
  97. package/src/core/project/agent-templates/CLAUDE.md +9 -0
  98. package/src/core/project/agent-templates/copilot-instructions.md +10 -0
  99. package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
  100. package/src/core/project/boundaries.ts +3 -3
  101. package/src/core/project/capabilities.ts +29 -6
  102. package/src/core/project/react-project.ts +1 -1
  103. package/src/core/project/state.ts +70 -0
  104. package/src/core/project/workflows.ts +43 -7
  105. package/src/core/scaffold/auth-generator.ts +18 -19
  106. package/src/core/scaffold/native-schema-generator.ts +16 -0
  107. package/src/core/verify/index.ts +95 -5
  108. package/src/machine/contracts.ts +1 -0
  109. package/src/machine/errors.ts +4 -2
  110. package/src/machine/index.ts +23 -16
  111. package/src/mcp/index.ts +68 -20
  112. package/src/utils/azure-cli.ts +167 -6
  113. package/src/utils/azure-diagnostics.ts +75 -0
@@ -2,9 +2,7 @@
2
2
  /**
3
3
  * Plan / Apply Provision — Azure プロビジョニングの事前計画と承認付き実行。
4
4
  *
5
- * - plan provision: ローカルの決定論的プリフライト(Bicep 解析・az CLI 有無)のみ。
6
- * ネットワークアクセスや az login を必要とする操作は行わない。
7
- * --what-if を明示した場合のみ az deployment group what-if を実行する。
5
+ * - plan provision: Bicep と Azure の現在の状態を確認する。リソースは変更しない。
8
6
  * - apply provision: 常に承認(--approve)必須。承認なしでは requires-human で停止する。
9
7
  * - 対話的な `swallowkit provision` は同じ計画をメモリ上で作成・表示し、確認後に
10
8
  * executeProvisionPlan で同じ安全確認を経て実行する(計画ファイルは保存しない)。
@@ -45,17 +43,21 @@ var __importStar = (this && this.__importStar) || (function () {
45
43
  Object.defineProperty(exports, "__esModule", { value: true });
46
44
  exports.INTERACTIVE_PROVISION_RETRY_HINT = void 0;
47
45
  exports.planProvisionOperation = planProvisionOperation;
46
+ exports.provisionNextActions = provisionNextActions;
47
+ exports.inspectProvisionOperation = inspectProvisionOperation;
48
48
  exports.applyProvisionOperation = applyProvisionOperation;
49
49
  exports.executeProvisionPlan = executeProvisionPlan;
50
50
  const crypto = __importStar(require("crypto"));
51
51
  const fs = __importStar(require("fs"));
52
52
  const azure_cli_1 = require("../../utils/azure-cli");
53
+ const azure_diagnostics_1 = require("../../utils/azure-diagnostics");
53
54
  const path = __importStar(require("path"));
54
55
  const errors_1 = require("../../machine/errors");
55
56
  const version_1 = require("../../version");
56
57
  const infra_1 = require("../project/infra");
57
58
  const state_1 = require("../project/state");
58
59
  const file_session_1 = require("./file-session");
60
+ const provision_deployment_1 = require("./provision-deployment");
59
61
  const migrations_1 = require("../project/migrations");
60
62
  const RESOURCE_GROUP_PATTERN = /^[A-Za-z0-9._()-]{1,90}$/;
61
63
  const LOCATION_PATTERN = /^[a-z0-9]{3,30}$/;
@@ -70,6 +72,9 @@ function validateProvisionInputs(options) {
70
72
  if (options.subscription && !SUBSCRIPTION_PATTERN.test(options.subscription)) {
71
73
  throw new errors_1.MachineCommandError("invalid-arguments", "Subscription must be a GUID.", undefined, "failed");
72
74
  }
75
+ if (options.resumeDeployment && (!/^[A-Za-z0-9._()-]{1,64}$/.test(options.resumeDeployment) || options.reconcile || options.adoptExisting)) {
76
+ throw new errors_1.MachineCommandError("invalid-arguments", "--resume-deployment requires a valid deployment name and cannot be combined with adoption or reconcile.", undefined, "blocked");
77
+ }
73
78
  }
74
79
  function azCliUnavailableWarning(availability) {
75
80
  return availability.reason === "not-found"
@@ -79,30 +84,26 @@ function azCliUnavailableWarning(availability) {
79
84
  function displayCommand(command) {
80
85
  return [command.file, ...command.args.map((arg) => /\s/.test(arg) ? JSON.stringify(arg) : arg)].join(" ");
81
86
  }
82
- function runAz(args, timeout = 300000) {
83
- const result = (0, azure_cli_1.runAzCli)(args, { timeout });
84
- return {
85
- status: result.status,
86
- stdout: result.stdout,
87
- stderr: result.stderr || (result.error ? result.error.message : ""),
88
- };
87
+ function runAz(args, timeout = 60000) {
88
+ return (0, azure_cli_1.runAzCli)(args, { timeout });
89
89
  }
90
- function azQueryError(code, message, result) {
91
- const output = (result.stderr || result.stdout || "").trim();
90
+ function azQueryError(code, message, result, secretValues = []) {
91
+ const diagnostic = (0, azure_diagnostics_1.azureCliDiagnostic)(result, "azure-state", secretValues);
92
+ const output = (diagnostic.stderr || diagnostic.stdout).trim().slice(0, 2000);
92
93
  if (/az login|not logged in|AADSTS|expired/i.test(output)) {
93
- return new errors_1.MachineCommandError("az-not-logged-in", `Azure CLI is not signed in (or the session expired). Run "az login", then try again.\n${output.slice(0, 1000)}`, undefined, "requires-human");
94
+ return new errors_1.MachineCommandError("az-not-logged-in", `Azure CLI is not signed in (or the session expired). Run "az login", then try again.\n${output.slice(0, 1000)}`, { diagnostic }, "requires-human");
94
95
  }
95
- return new errors_1.MachineCommandError(code, `${message}: ${output}`, undefined, "blocked");
96
+ return new errors_1.MachineCommandError(code, `${message}: ${output || result.errorCode || result.failureKind || `exit ${result.status}`}`, { diagnostic }, "blocked");
96
97
  }
97
98
  function subscriptionArgs(subscription) {
98
99
  return subscription ? ["--subscription", subscription] : [];
99
100
  }
100
101
  function buildBootstrapCommands(options, mainBicepPath, parametersPath) {
101
102
  const commands = [];
102
- commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription)], kind: "resource-group" });
103
+ commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription), "--query", "id", "-o", "json"], kind: "resource-group" });
103
104
  commands.push({
104
105
  file: "az",
105
- 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)],
106
+ 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), "--no-wait", "--query", provision_deployment_1.DEPLOYMENT_QUERY, "-o", "json"],
106
107
  kind: "deployment",
107
108
  });
108
109
  return commands;
@@ -121,23 +122,32 @@ function readProjectName(parametersPath) {
121
122
  }
122
123
  function inspectRemoteGroup(options) {
123
124
  const existsResult = runAz(["group", "exists", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription)], 60000);
124
- if (existsResult.status !== 0) {
125
+ if (existsResult.status !== 0 || existsResult.failureKind || !/^(true|false)$/.test(existsResult.stdout.trim())) {
125
126
  throw azQueryError("azure-state-query-failed", "Could not query resource group", existsResult);
126
127
  }
127
128
  if ((existsResult.stdout || "").trim() !== "true")
128
129
  return { exists: false, tags: {} };
129
130
  const showResult = runAz(["group", "show", "--name", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], 60000);
130
- if (showResult.status !== 0) {
131
+ if (showResult.status !== 0 || showResult.failureKind || showResult.stdoutTruncated) {
131
132
  throw azQueryError("azure-state-query-failed", "Could not read resource group tags", showResult);
132
133
  }
133
- const parsed = JSON.parse(showResult.stdout || "{}");
134
+ let parsed;
135
+ try {
136
+ parsed = JSON.parse(showResult.stdout);
137
+ }
138
+ catch {
139
+ throw azQueryError("azure-state-query-failed", "Invalid resource group response", showResult);
140
+ }
141
+ if (!parsed || typeof parsed.id !== "string") {
142
+ throw azQueryError("azure-state-query-failed", "Invalid resource group identity", showResult);
143
+ }
134
144
  return { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
135
145
  }
136
146
  function tagCommand(resourceId, key, state, expectedState) {
137
- return { file: "az", args: ["tag", "update", "--resource-id", resourceId, "--operation", "Merge", "--tags", `${key}=${(0, migrations_1.encodeMigrationState)(state)}`], kind: "tag", expectedState };
147
+ return { file: "az", args: ["tag", "update", "--resource-id", resourceId, "--operation", "Merge", "--tags", `${key}=${(0, migrations_1.encodeMigrationState)(state)}`, "-o", "none"], kind: "tag", expectedState };
138
148
  }
139
149
  function bootstrapTagCommand(options, key, state) {
140
- return { file: "az", args: ["group", "update", "--name", options.resourceGroup, "--set", `tags.${key}=${(0, migrations_1.encodeMigrationState)(state)}`, ...subscriptionArgs(options.subscription)], kind: "tag", expectedState: null };
150
+ return { file: "az", args: ["group", "update", "--name", options.resourceGroup, "--set", `tags.${key}=${(0, migrations_1.encodeMigrationState)(state)}`, ...subscriptionArgs(options.subscription), "-o", "none"], kind: "tag", expectedState: null };
141
151
  }
142
152
  function runBaseWhatIf(options, mainBicepPath, parametersPath) {
143
153
  const result = runAz([
@@ -152,7 +162,7 @@ function runBaseWhatIf(options, mainBicepPath, parametersPath) {
152
162
  "-o", "json",
153
163
  ]);
154
164
  if (result.status !== 0) {
155
- throw new errors_1.MachineCommandError("what-if-failed", `az deployment group what-if failed: ${(result.stderr || result.stdout).trim().slice(0, 2000)}`, undefined, "blocked");
165
+ throw azQueryError("what-if-failed", "az deployment group what-if failed", result);
156
166
  }
157
167
  return result.stdout.trim();
158
168
  }
@@ -170,6 +180,88 @@ function assertAdoptionWhatIfIsSafe(whatIfResult) {
170
180
  throw new errors_1.MachineCommandError("what-if-invalid", "Azure what-if did not return valid JSON for legacy adoption.", undefined, "blocked");
171
181
  }
172
182
  }
183
+ function resolveSubscription(options) {
184
+ if (options.subscription)
185
+ return options.subscription;
186
+ const result = runAz(["account", "show", "--query", "id", "-o", "tsv"]);
187
+ const subscription = result.stdout.trim();
188
+ if (result.status !== 0 || !SUBSCRIPTION_PATTERN.test(subscription)) {
189
+ throw azQueryError("azure-subscription-query-failed", "Could not identify the Azure subscription for this provision run", result);
190
+ }
191
+ return subscription;
192
+ }
193
+ function canonicalTemplate(value) {
194
+ const normalize = (item) => {
195
+ if (Array.isArray(item))
196
+ return item.map(normalize);
197
+ if (item && typeof item === "object") {
198
+ return Object.fromEntries(Object.entries(item).filter(([key]) => key !== "_generator")
199
+ .sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, normalize(entry)]));
200
+ }
201
+ return item;
202
+ };
203
+ return JSON.stringify(normalize(value));
204
+ }
205
+ async function verifyExistingBaseline(options, mainBicepPath, parametersPath) {
206
+ const projectRoot = path.dirname(path.dirname(mainBicepPath));
207
+ const secretValues = provisionSecretValues({
208
+ fingerprints: Object.fromEntries((0, migrations_1.collectLegacyBaselineFiles)(projectRoot).map(file => [file.path, file.checksum])),
209
+ }, projectRoot);
210
+ const reference = {
211
+ subscription: options.subscription, resourceGroup: options.resourceGroup, name: options.resumeDeployment,
212
+ };
213
+ const observation = await (0, provision_deployment_1.queryAzureDeployment)(reference, { secretValues });
214
+ if (observation.state === "unknown") {
215
+ throw new errors_1.MachineCommandError("recovery-deployment-unknown", "The existing deployment could not be identified. No Bicep will be submitted.", { deployment: observation }, "blocked");
216
+ }
217
+ const compiled = await (0, azure_cli_1.runAzCliAsync)(["bicep", "build", "--file", mainBicepPath, "--stdout"], { timeout: 60000, captureBytes: 16777216 });
218
+ const exported = await (0, azure_cli_1.runAzCliAsync)([
219
+ "deployment", "group", "export", "--name", reference.name,
220
+ "--resource-group", reference.resourceGroup, "--subscription", reference.subscription,
221
+ ], { timeout: 60000, captureBytes: 16777216 });
222
+ const parameters = await (0, azure_cli_1.runAzCliAsync)([
223
+ "deployment", "group", "show", "--name", reference.name, "--resource-group", reference.resourceGroup,
224
+ "--subscription", reference.subscription, "--query", "properties.parameters", "-o", "json",
225
+ ], { timeout: 30000 });
226
+ if ([compiled, exported, parameters].some((result) => result.status !== 0 || result.failureKind || result.stdoutTruncated)) {
227
+ throw new errors_1.MachineCommandError("recovery-evidence-unavailable", "Recovery requires the existing deployment template and parameters to match the local baseline. Evidence could not be read; nothing will be redeployed.", {
228
+ diagnostics: [compiled, exported, parameters].map((result, index) => ({
229
+ ...(0, azure_diagnostics_1.azureCliDiagnostic)(result, ["local-bicep-build", "recovery-template", "recovery-parameters"][index], secretValues),
230
+ stdout: "[template/parameter payload omitted]", stderr: "[template/parameter payload omitted]", outputTruncated: true,
231
+ })),
232
+ }, "blocked");
233
+ }
234
+ let template;
235
+ let remoteParameters;
236
+ let localParameters;
237
+ try {
238
+ template = JSON.parse(compiled.stdout);
239
+ if (canonicalTemplate(template) !== canonicalTemplate(JSON.parse(exported.stdout))) {
240
+ throw new errors_1.MachineCommandError("recovery-template-mismatch", "The deployed ARM template differs from the local Bicep. Restore the matching reviewed files before recovery; do not redeploy to repair migration bookkeeping.", undefined, "blocked");
241
+ }
242
+ remoteParameters = JSON.parse(parameters.stdout);
243
+ localParameters = JSON.parse(fs.readFileSync(parametersPath, "utf-8")).parameters;
244
+ if (!remoteParameters || !localParameters || typeof remoteParameters !== "object" || typeof localParameters !== "object")
245
+ throw new Error("Invalid parameters");
246
+ }
247
+ catch (error) {
248
+ if (error instanceof errors_1.MachineCommandError)
249
+ throw error;
250
+ throw new errors_1.MachineCommandError("recovery-invalid-evidence", "Recovery template/parameter evidence is not valid JSON.", undefined, "blocked");
251
+ }
252
+ const declarations = template.parameters;
253
+ if (Object.values(declarations ?? {}).some((parameter) => /^secure/i.test(parameter.type ?? ""))) {
254
+ throw new errors_1.MachineCommandError("recovery-secure-parameters", "This untracked deployment has secure parameters that cannot be verified. Recovery requires the original execution checkpoint; no secret values will be requested or printed.", undefined, "blocked");
255
+ }
256
+ const expected = { ...localParameters, location: { value: options.location }, swaLocation: { value: options.swaLocation } };
257
+ for (const [name, parameter] of Object.entries(expected)) {
258
+ if (!parameter || !Object.prototype.hasOwnProperty.call(parameter, "value")
259
+ || canonicalTemplate(parameter.value) !== canonicalTemplate(remoteParameters[name]?.value)) {
260
+ throw new errors_1.MachineCommandError("recovery-parameters-mismatch", "The deployed parameters differ from the local reviewed parameters/regions. No tag or ledger will be changed.", undefined, "blocked");
261
+ }
262
+ }
263
+ return observation.reference;
264
+ }
173
265
  /**
174
266
  * Builds the provision plan. Machine/MCP callers persist it for a later `apply provision --plan`;
175
267
  * the interactive `swallowkit provision` command keeps it in memory and executes it after confirmation.
@@ -200,6 +292,7 @@ async function planProvisionOperation(options, planOptions = {}) {
200
292
  let whatIfResult;
201
293
  let manifestAfterApply;
202
294
  let baselineDrift = [];
295
+ let recoveryDeployment;
203
296
  const manifestPath = path.join(projectRoot, migrations_1.MIGRATION_MANIFEST_PATH);
204
297
  const manifest = fs.existsSync(manifestPath) ? (0, migrations_1.loadMigrationManifest)(projectRoot) : null;
205
298
  if (manifest) {
@@ -214,6 +307,12 @@ async function planProvisionOperation(options, planOptions = {}) {
214
307
  baselineDrift = (0, migrations_1.findBaselineDrift)(manifest, projectRoot);
215
308
  }
216
309
  if (azCliAvailable) {
310
+ options = { ...options, subscription: resolveSubscription(options) };
311
+ const unfinished = readRuns(projectRoot).find((run) => run.outcome !== "succeeded" && run.outcome !== "failed" && run.plan.resourceGroup === options.resourceGroup
312
+ && run.plan.subscription === options.subscription);
313
+ if (unfinished) {
314
+ throw new errors_1.MachineCommandError("provision-run-pending", `Provision run ${unfinished.runId} is unfinished. Inspect or resume that run instead of submitting Bicep again.`, { runId: unfinished.runId, outcome: unfinished.outcome }, "blocked", provisionNextActions(unfinished.runId));
315
+ }
217
316
  const remoteGroup = inspectRemoteGroup(options);
218
317
  if (!manifest) {
219
318
  if (remoteGroup.exists) {
@@ -222,7 +321,33 @@ async function planProvisionOperation(options, planOptions = {}) {
222
321
  throw new errors_1.MachineCommandError("migration-manifest-not-found", "Initialize a migration manifest before the first provision.", undefined, "blocked");
223
322
  }
224
323
  migrationTagKey = (0, migrations_1.getMigrationTagKey)(manifest.projectId);
225
- if (!remoteGroup.exists) {
324
+ if (options.resumeDeployment) {
325
+ if (!remoteGroup.exists || manifest.baseline.legacy) {
326
+ throw new errors_1.MachineCommandError("recovery-baseline-required", "--resume-deployment requires an existing resource group and the original non-legacy baseline manifest.", undefined, "blocked");
327
+ }
328
+ const tagValue = remoteGroup.tags[migrationTagKey];
329
+ remoteState = tagValue ? (0, migrations_1.decodeMigrationState)(tagValue) : null;
330
+ if (remoteState && remoteState.version !== 0) {
331
+ throw new errors_1.MachineCommandError("recovery-baseline-only", "Untracked-deployment recovery supports baseline 0 only. Use the original checkpoint to resume migrations.", undefined, "blocked");
332
+ }
333
+ recoveryDeployment = await verifyExistingBaseline(options, mainBicepPath, parametersPath);
334
+ const baselineFiles = (0, migrations_1.collectLegacyBaselineFiles)(projectRoot);
335
+ manifestAfterApply = {
336
+ ...manifest,
337
+ baseline: { ...manifest.baseline, files: baselineFiles, checksum: (0, migrations_1.computeBaselineChecksum)(baselineFiles) },
338
+ };
339
+ if (remoteState && remoteState.checksum !== manifestAfterApply.baseline.checksum) {
340
+ throw new errors_1.MachineCommandError("recovery-checksum-mismatch", "The existing baseline tag does not match this project. Nothing will be overwritten.", undefined, "blocked");
341
+ }
342
+ mode = "recover";
343
+ warnings.push("Recovery only observes the matching existing deployment, then records migration state after Azure Succeeded. It never submits Bicep.");
344
+ commandSpecs = [{
345
+ file: "az",
346
+ args: ["deployment", "group", "show", "--name", recoveryDeployment.name, "--resource-group", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", provision_deployment_1.DEPLOYMENT_QUERY, "-o", "json"],
347
+ kind: "deployment",
348
+ }, tagCommand(remoteGroup.id, migrationTagKey, { schemaVersion: 1, version: 0, checksum: manifestAfterApply.baseline.checksum }, remoteState)];
349
+ }
350
+ else if (!remoteGroup.exists) {
226
351
  mode = "bootstrap";
227
352
  const baselineFiles = (0, migrations_1.collectLegacyBaselineFiles)(projectRoot);
228
353
  manifestAfterApply = {
@@ -245,7 +370,7 @@ async function planProvisionOperation(options, planOptions = {}) {
245
370
  commandSpecs = [];
246
371
  if (options.reconcile && !manifest.baseline.legacy) {
247
372
  whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
248
- warnings.push("Recovering an interrupted bootstrap by reconciling the baseline before recording migration state.");
373
+ warnings.push("Explicit reconcile redeploys the untagged baseline. For an interrupted wait/tag/ledger update use --resume-deployment instead.");
249
374
  mode = "reconcile";
250
375
  const baselineFiles = (0, migrations_1.collectLegacyBaselineFiles)(projectRoot);
251
376
  manifestAfterApply = {
@@ -301,12 +426,13 @@ async function planProvisionOperation(options, planOptions = {}) {
301
426
  file: "az",
302
427
  args: [
303
428
  "deployment", "group", "create",
304
- "--name", "swallowkit-migration",
429
+ "--name", `swallowkit-migration-${migration.version}-${migration.checksum.slice(0, 12)}`,
305
430
  "--resource-group", options.resourceGroup,
306
431
  "--template-file", path.join(projectRoot, migration.template),
307
432
  ...(migration.parameters ? ["--parameters", path.join(projectRoot, migration.parameters)] : []),
308
433
  "--parameters", `projectName=${projectName}`, `location=${options.location}`,
309
434
  ...subscriptionArgs(options.subscription),
435
+ "--no-wait", "--query", provision_deployment_1.DEPLOYMENT_QUERY, "-o", "json",
310
436
  ],
311
437
  kind: "migration",
312
438
  expectedState,
@@ -318,9 +444,9 @@ async function planProvisionOperation(options, planOptions = {}) {
318
444
  }
319
445
  }
320
446
  }
321
- else if (options.whatIf || options.adoptExisting || options.reconcile) {
447
+ else if (options.whatIf || options.adoptExisting || options.reconcile || options.resumeDeployment) {
322
448
  const detail = azCli.available ? "" : ` ${azCliUnavailableWarning(azCli)}`;
323
- throw new errors_1.MachineCommandError(!azCli.available && azCli.reason === "not-runnable" ? "az-cli-not-runnable" : "az-cli-not-found", `Azure CLI is required for --what-if, --adopt-existing, and --reconcile.${detail}`, undefined, "blocked");
449
+ throw new errors_1.MachineCommandError(!azCli.available && azCli.reason === "not-runnable" ? "az-cli-not-runnable" : "az-cli-not-found", `Azure CLI is required for --what-if, --adopt-existing, --reconcile, and --resume-deployment.${detail}`, undefined, "blocked");
324
450
  }
325
451
  const fingerprints = {
326
452
  "infra/main.bicep": (0, file_session_1.hashFileIfExists)(mainBicepPath),
@@ -350,6 +476,8 @@ async function planProvisionOperation(options, planOptions = {}) {
350
476
  mode,
351
477
  remoteState,
352
478
  pendingMigrations: pendingMigrations.map((migration) => ({ version: migration.version, checksum: migration.checksum })),
479
+ recoveryDeployment,
480
+ nonce: crypto.randomUUID(),
353
481
  baselineSnapshotChecksum,
354
482
  fingerprints,
355
483
  }))
@@ -378,6 +506,7 @@ async function planProvisionOperation(options, planOptions = {}) {
378
506
  requiresApproval: true,
379
507
  fingerprints,
380
508
  ...(baselineSnapshotChecksum ? { baselineSnapshotChecksum } : {}),
509
+ ...(recoveryDeployment ? { recoveryDeployment } : {}),
381
510
  };
382
511
  if (planOptions.persist !== false)
383
512
  (0, state_1.savePlanState)(plan, projectRoot);
@@ -386,43 +515,208 @@ async function planProvisionOperation(options, planOptions = {}) {
386
515
  function statesEqual(left, right) {
387
516
  return left?.version === right?.version && left?.checksum === right?.checksum;
388
517
  }
389
- function readRemoteMigrationState(plan) {
390
- const group = inspectRemoteGroup(plan);
391
- if (!group.exists || !plan.migrationTagKey)
392
- return { group, state: null };
393
- const tagValue = group.tags[plan.migrationTagKey];
394
- return { group, state: tagValue ? (0, migrations_1.decodeMigrationState)(tagValue) : null };
518
+ const MACHINE_RETRY_HINT = "Re-run plan provision.";
519
+ exports.INTERACTIVE_PROVISION_RETRY_HINT = 'Run "swallowkit provision" again to review the current state.';
520
+ function provisionNextActions(runId) {
521
+ return [
522
+ { command: `swallowkit machine inspect provision --plan ${runId}`, description: "Read the original Azure deployment state without creating, cancelling, deleting, tagging, or updating the ledger." },
523
+ { command: `swallowkit machine apply provision --plan ${runId} --approve`, description: "After explicit human approval, resume this checkpoint: observe the original deployment and finish tags/ledger; never resubmit an attempted deployment." },
524
+ ];
395
525
  }
396
- function assertPlanRemoteState(plan, expected, retryHint) {
397
- if (plan.mode === "offline")
398
- return;
399
- const current = readRemoteMigrationState(plan);
400
- if (plan.mode === "bootstrap") {
401
- if (current.group.exists) {
402
- throw new errors_1.MachineCommandError("stale-azure-state", `The resource group was created after the preview was computed. ${retryHint}`, undefined, "blocked");
526
+ function resultForRun(run, outcome = run.outcome, interrupted) {
527
+ run.warnings = run.warnings.slice(-20);
528
+ run.diagnostics = run.diagnostics.slice(-20);
529
+ return {
530
+ status: outcome === "succeeded" ? "complete" : outcome === "in-progress" ? "in-progress" : outcome === "failed" ? "failed" : "blocked",
531
+ outcome, runId: run.runId, planId: run.plan.planId,
532
+ resourceGroup: run.plan.resourceGroup, location: run.plan.location, swaLocation: run.plan.swaLocation,
533
+ executedCommands: run.executedCommands, warnings: run.warnings, diagnostics: run.diagnostics,
534
+ ...(run.observation ? { deployment: run.observation } : {}),
535
+ nextActions: outcome === "succeeded"
536
+ ? [{ command: "swallowkit machine inspect infra", description: "Inspect local infrastructure assets after deployment and migration bookkeeping completed." }]
537
+ : provisionNextActions(run.runId),
538
+ ...(interrupted ? { interrupted } : {}),
539
+ };
540
+ }
541
+ function readRun(runId, projectRoot) {
542
+ let run;
543
+ try {
544
+ run = (0, state_1.loadProvisionRun)(runId, projectRoot);
545
+ }
546
+ catch {
547
+ throw new errors_1.MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint cannot be read. Do not re-submit Bicep; inspect the original deployment first.", { runId }, "blocked");
548
+ }
549
+ if (run && (run.schemaVersion !== 1 || run.runId !== runId || run.plan?.planId !== runId
550
+ || run.projectRoot !== path.resolve(projectRoot) || !Number.isInteger(run.cursor)
551
+ || !Array.isArray(run.plan.commandSpecs) || !run.plan.fingerprints
552
+ || !Array.isArray(run.warnings) || !Array.isArray(run.diagnostics) || !Array.isArray(run.executedCommands)
553
+ || !["succeeded", "in-progress", "unknown", "partial", "failed"].includes(run.outcome)
554
+ || run.cursor < 0 || run.cursor > run.plan.commandSpecs.length)) {
555
+ throw new errors_1.MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint is invalid or belongs to another checkout. Nothing will be re-submitted.", { runId }, "blocked");
556
+ }
557
+ return run;
558
+ }
559
+ function readRuns(projectRoot) {
560
+ try {
561
+ return (0, state_1.listProvisionRuns)(projectRoot).map(record => {
562
+ if (!record || typeof record.runId !== "string")
563
+ throw new Error("Invalid checkpoint identity.");
564
+ const run = readRun(record.runId, projectRoot);
565
+ if (!run)
566
+ throw new Error("Missing execution checkpoint.");
567
+ return run;
568
+ });
569
+ }
570
+ catch (error) {
571
+ if (error instanceof errors_1.MachineCommandError)
572
+ throw error;
573
+ throw new errors_1.MachineCommandError("invalid-provision-checkpoint", "An execution checkpoint is corrupt. Preserve it and inspect the original deployment; nothing will be re-submitted.", undefined, "blocked");
574
+ }
575
+ }
576
+ function validateWaitSeconds(value = provision_deployment_1.DEFAULT_PROVISION_WAIT_SECONDS) {
577
+ if (!Number.isInteger(value) || value < 0 || value > provision_deployment_1.MAX_PROVISION_WAIT_SECONDS) {
578
+ throw new errors_1.MachineCommandError("invalid-arguments", `--wait-seconds must be an integer from 0 to ${provision_deployment_1.MAX_PROVISION_WAIT_SECONDS}.`, undefined, "blocked");
579
+ }
580
+ return value;
581
+ }
582
+ function assertLocalFiles(plan, projectRoot, retryHint, allowRecordedManifest = false) {
583
+ const changedFiles = Object.entries(plan.fingerprints).filter(([relativePath, expectedHash]) => {
584
+ const file = path.join(projectRoot, relativePath);
585
+ if (allowRecordedManifest && relativePath === migrations_1.MIGRATION_MANIFEST_PATH && plan.manifestAfterApply && fs.existsSync(file)
586
+ && fs.readFileSync(file, "utf-8") === `${JSON.stringify(plan.manifestAfterApply, null, 2)}\n`)
587
+ return false;
588
+ return (0, file_session_1.hashFileIfExists)(file) !== expectedHash;
589
+ }).map(([file]) => file);
590
+ if (changedFiles.length || (plan.baselineSnapshotChecksum
591
+ && (0, migrations_1.computeBaselineChecksum)((0, migrations_1.collectLegacyBaselineFiles)(projectRoot)) !== plan.baselineSnapshotChecksum)) {
592
+ throw new errors_1.MachineCommandError("stale-plan", `${changedFiles.join(", ") || "Baseline infrastructure files"} changed after the preview was computed. ${retryHint}`, { planId: plan.planId, changedFiles }, "blocked");
593
+ }
594
+ }
595
+ async function readMigrationState(plan, signal, secretValues = []) {
596
+ const exists = await (0, azure_cli_1.runAzCliAsync)(["group", "exists", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription)], { timeout: 30000, signal });
597
+ if (exists.status !== 0 || exists.failureKind || !/^(true|false)$/.test(exists.stdout.trim())) {
598
+ throw azQueryError("azure-state-query-failed", "Could not query resource group", exists, secretValues);
599
+ }
600
+ if (exists.stdout.trim() === "false")
601
+ return { group: { exists: false, tags: {} }, state: null };
602
+ const show = await (0, azure_cli_1.runAzCliAsync)(["group", "show", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], { timeout: 30000, signal });
603
+ if (show.status !== 0 || show.failureKind || show.stdoutTruncated)
604
+ throw azQueryError("azure-state-query-failed", "Could not read resource group tags", show, secretValues);
605
+ let parsed;
606
+ try {
607
+ parsed = JSON.parse(show.stdout);
608
+ }
609
+ catch {
610
+ throw azQueryError("azure-state-query-failed", "Invalid resource group response", show, secretValues);
611
+ }
612
+ const expectedId = `/subscriptions/${plan.subscription}/resourceGroups/${plan.resourceGroup}`;
613
+ if (typeof parsed.id !== "string" || parsed.id.toLowerCase() !== expectedId.toLowerCase()) {
614
+ throw new errors_1.MachineCommandError("azure-resource-group-mismatch", "Azure returned a different resource group/subscription. Nothing will be updated.", undefined, "blocked");
615
+ }
616
+ const group = { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
617
+ const tag = plan.migrationTagKey ? group.tags[plan.migrationTagKey] : undefined;
618
+ return { group, state: tag ? (0, migrations_1.decodeMigrationState)(tag) : null };
619
+ }
620
+ function desiredTagState(command, key) {
621
+ const value = command.args.find((arg) => arg.startsWith(`${key}=`) || arg.startsWith(`tags.${key}=`));
622
+ if (!value)
623
+ throw new errors_1.MachineCommandError("incompatible-plan", "The reviewed migration tag is missing.", undefined, "blocked");
624
+ return (0, migrations_1.decodeMigrationState)(value.slice(value.indexOf("=") + 1));
625
+ }
626
+ function provisionSecretValues(plan, projectRoot) {
627
+ const files = Object.keys(plan.fingerprints);
628
+ const secureBicep = files.filter(file => file.endsWith(".bicep")).some(file => /@secure\s*\(/.test(fs.readFileSync(path.join(projectRoot, file), "utf-8")));
629
+ const values = [];
630
+ const collect = (value) => {
631
+ if (typeof value === "string" && value)
632
+ values.push(value);
633
+ else if (Array.isArray(value))
634
+ value.forEach(collect);
635
+ else if (value && typeof value === "object")
636
+ Object.values(value).forEach(collect);
637
+ };
638
+ for (const file of files.filter(file => file.endsWith(".json") && file !== migrations_1.MIGRATION_MANIFEST_PATH)) {
639
+ let parsed;
640
+ try {
641
+ parsed = JSON.parse(fs.readFileSync(path.join(projectRoot, file), "utf-8"));
642
+ }
643
+ catch {
644
+ throw new errors_1.MachineCommandError("invalid-infra-parameters", "Infrastructure JSON is invalid. Correct it before provision; unstructured Azure output cannot safely redact unknown parameter secrets.", undefined, "blocked");
645
+ }
646
+ for (const [name, parameter] of Object.entries(parsed.parameters ?? {})) {
647
+ if (secureBicep || /secret|password|token|key|connection.?string/i.test(name))
648
+ collect(parameter?.value);
403
649
  }
404
- return;
405
650
  }
406
- if (!current.group.exists || !statesEqual(current.state, expected)) {
407
- throw new errors_1.MachineCommandError("stale-azure-state", `Azure migration state changed after the preview was computed. ${retryHint}`, { expected, actual: current.state, resourceGroupExists: current.group.exists }, "blocked");
651
+ return [...new Set(values)].sort((left, right) => right.length - left.length);
652
+ }
653
+ function rememberDiagnostic(run, result, phase, secretValues) {
654
+ run.diagnostics.push((0, azure_diagnostics_1.azureCliDiagnostic)(result, phase, secretValues));
655
+ run.diagnostics = run.diagnostics.slice(-20);
656
+ if (result.stderr.trim()) {
657
+ run.warnings.push(result.stderrTruncated ? "Azure CLI stderr exceeded the capture limit; output omitted." : (0, azure_diagnostics_1.redactAzureText)(result.stderr, secretValues).slice(0, 4000));
658
+ run.warnings = run.warnings.slice(-20);
408
659
  }
409
660
  }
410
- function assertTagExpectedState(plan, expected, retryHint) {
411
- const current = readRemoteMigrationState(plan);
412
- if (!current.group.exists || !statesEqual(current.state, expected)) {
413
- throw new errors_1.MachineCommandError("stale-azure-state", `Azure migration state changed before its tag could be updated. ${retryHint}`, { expected, actual: current.state, resourceGroupExists: current.group.exists }, "blocked");
661
+ async function inspectProvisionOperation(options) {
662
+ const run = readRun(options.planId, process.cwd());
663
+ if (!run)
664
+ throw new errors_1.MachineCommandError("provision-run-not-found", "No execution checkpoint exists for this ID. For an older interrupted bootstrap use plan provision --resume-deployment <name> with the original resource group/subscription/regions.", { runId: options.planId }, "blocked");
665
+ const waitSeconds = validateWaitSeconds(options.waitSeconds ?? 0);
666
+ if (run.deployment) {
667
+ let secretValues = [];
668
+ let omitOutput = false;
669
+ try {
670
+ assertLocalFiles(run.plan, process.cwd(), "Reviewed files no longer match.", true);
671
+ secretValues = provisionSecretValues(run.plan, process.cwd());
672
+ }
673
+ catch {
674
+ omitOutput = true;
675
+ run.warnings.push("Reviewed files are unavailable or changed; deployment state is still read, but diagnostic output/causes are omitted to avoid exposing unknown secrets.");
676
+ }
677
+ run.observation = await (0, provision_deployment_1.waitForAzureDeployment)(run.deployment, { deadline: Date.now() + waitSeconds * 1000, secretValues });
678
+ if (omitOutput) {
679
+ run.observation.errors = [];
680
+ run.observation.diagnostic = { ...run.observation.diagnostic, stdout: "[output omitted: reviewed parameters unavailable]", stderr: "", outputTruncated: true };
681
+ }
682
+ run.diagnostics = [...run.diagnostics, run.observation.diagnostic].slice(-20);
683
+ let outcome = run.observation.state === "Failed" || run.observation.state === "Canceled" ? "failed"
684
+ : run.observation.state === "Succeeded" ? run.outcome === "succeeded" ? "succeeded" : "partial"
685
+ : run.observation.state === "unknown" ? "unknown" : "in-progress";
686
+ if (outcome === "succeeded") {
687
+ try {
688
+ assertLocalFiles(run.plan, process.cwd(), "The original reviewed files no longer match.", true);
689
+ const tag = [...run.plan.commandSpecs].reverse().find(command => command.kind === "tag");
690
+ if (tag && !statesEqual((await readMigrationState(run.plan, undefined, secretValues)).state, desiredTagState(tag, run.plan.migrationTagKey))) {
691
+ outcome = "partial";
692
+ run.warnings.push("Azure Succeeded, but the completed run's migration tag no longer matches. No bookkeeping or deployment write was performed.");
693
+ }
694
+ }
695
+ catch {
696
+ outcome = "partial";
697
+ run.warnings.push("Azure Succeeded, but current local files or migration bookkeeping could not be verified. No writes were performed.");
698
+ }
699
+ }
700
+ return resultForRun(run, outcome);
414
701
  }
702
+ return resultForRun(run);
415
703
  }
416
- const MACHINE_RETRY_HINT = "Re-run plan provision.";
417
- exports.INTERACTIVE_PROVISION_RETRY_HINT = 'Run "swallowkit provision" again to review the current state.';
418
704
  async function applyProvisionOperation(options) {
419
705
  const projectRoot = process.cwd();
420
- const plan = (0, state_1.loadPlanState)(options.planId, projectRoot);
706
+ const run = /^[a-f0-9]{12}$/.test(options.planId) ? readRun(options.planId, projectRoot) : null;
707
+ const plan = run?.plan ?? (0, state_1.loadPlanState)(options.planId, projectRoot);
421
708
  if (!plan) {
422
709
  throw new errors_1.MachineCommandError("plan-not-found", `Plan "${options.planId}" was not found. Run "plan provision" to create a new plan.`, { planId: options.planId }, "blocked");
423
710
  }
424
- const result = await executeProvisionPlan(plan, { approve: options.approve, retryHint: MACHINE_RETRY_HINT, projectRoot });
425
- (0, state_1.deletePlanState)(plan.planId, projectRoot);
711
+ const result = await executeProvisionPlan(plan, { approve: options.approve, waitSeconds: options.waitSeconds, retryHint: MACHINE_RETRY_HINT, projectRoot });
712
+ if (result.status === "complete") {
713
+ try {
714
+ (0, state_1.deletePlanState)(plan.planId, projectRoot);
715
+ }
716
+ catch {
717
+ result.warnings.push("Azure and migration bookkeeping completed, but the old preview could not be removed. The completed execution checkpoint prevents replay.");
718
+ }
719
+ }
426
720
  return result;
427
721
  }
428
722
  /**
@@ -432,24 +726,9 @@ async function applyProvisionOperation(options) {
432
726
  async function executeProvisionPlan(plan, options = {}) {
433
727
  const projectRoot = options.projectRoot ?? process.cwd();
434
728
  const retryHint = options.retryHint ?? MACHINE_RETRY_HINT;
435
- // Bicep が plan 後に変更されていないか確認
436
- const changedFiles = [];
437
- for (const [relativePath, expectedHash] of Object.entries(plan.fingerprints)) {
438
- const currentHash = (0, file_session_1.hashFileIfExists)(path.join(projectRoot, relativePath));
439
- if (currentHash !== expectedHash)
440
- changedFiles.push(relativePath);
441
- }
442
- if (changedFiles.length > 0) {
443
- throw new errors_1.MachineCommandError("stale-plan", `${changedFiles.join(", ")} changed after the preview was computed. ${retryHint}`, { planId: plan.planId, changedFiles }, "blocked");
444
- }
445
- if (plan.baselineSnapshotChecksum) {
446
- const currentBaselineChecksum = (0, migrations_1.computeBaselineChecksum)((0, migrations_1.collectLegacyBaselineFiles)(projectRoot));
447
- if (currentBaselineChecksum !== plan.baselineSnapshotChecksum) {
448
- throw new errors_1.MachineCommandError("stale-plan", `Baseline infrastructure files changed after the preview was computed. ${retryHint}`, { planId: plan.planId }, "blocked");
449
- }
450
- }
729
+ const waitSeconds = validateWaitSeconds(options.waitSeconds);
451
730
  if (!options.approve) {
452
- throw new errors_1.MachineCommandError("approval-required", "Provisioning creates billable Azure resources and always requires human approval. Review plan.commands and re-run with --approve.", { planId: plan.planId, commands: plan.commands }, "requires-human");
731
+ throw new errors_1.MachineCommandError("approval-required", "Provisioning creates billable Azure resources and always requires human approval. Review plan.commands and re-run with --approve.", { planId: plan.planId, commands: plan.commands }, "requires-human", [{ command: `swallowkit machine apply provision --plan ${plan.planId} --approve`, description: "Review the commands and obtain explicit human consent before applying or resuming." }]);
453
732
  }
454
733
  if (!plan.mode || !plan.commandSpecs) {
455
734
  throw new errors_1.MachineCommandError("incompatible-plan", `This provision plan was created by an older SwallowKit version. ${retryHint}`, undefined, "blocked");
@@ -458,50 +737,277 @@ async function executeProvisionPlan(plan, options = {}) {
458
737
  throw new errors_1.MachineCommandError("online-plan-required", `Azure state was not read because Azure CLI was unavailable, so nothing can be applied. Make az available and signed in (az login). ${retryHint}`, { planId: plan.planId, warnings: plan.warnings }, "blocked");
459
738
  }
460
739
  if (plan.mode === "adoption-required") {
461
- throw new errors_1.MachineCommandError("adoption-required", 'This existing resource group has no SwallowKit migration state. Re-plan with --adopt-existing --baseline 0 --what-if.', { planId: plan.planId }, "requires-human");
740
+ throw new errors_1.MachineCommandError("adoption-required", 'This resource group has no migration tag. Recover a matching existing baseline with --resume-deployment <name>; adoption applies only to a legacy manifest. Do not reconcile just to repair bookkeeping.', { planId: plan.planId }, "requires-human");
741
+ }
742
+ let run = readRun(plan.planId, projectRoot);
743
+ const resuming = run !== null;
744
+ if (run)
745
+ plan = run.plan;
746
+ try {
747
+ assertLocalFiles(plan, projectRoot, run ? "Restore the original reviewed files before resuming this checkpoint; do not resubmit Bicep." : retryHint, run?.cursor === plan.commandSpecs.length);
748
+ }
749
+ catch (error) {
750
+ if (run && error instanceof errors_1.MachineCommandError) {
751
+ throw new errors_1.MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status, provisionNextActions(run.runId));
752
+ }
753
+ throw error;
754
+ }
755
+ const secretValues = provisionSecretValues(plan, projectRoot);
756
+ if (!plan.subscription || plan.commandSpecs.some((command) => (command.kind === "deployment" || command.kind === "migration")
757
+ && !plan.recoveryDeployment && !command.args.includes("--no-wait"))) {
758
+ throw new errors_1.MachineCommandError("incompatible-plan", `Create a fresh preview with bounded deployment tracking. For an older interrupted deployment use --resume-deployment instead of resubmitting Bicep. ${retryHint}`, undefined, "blocked");
462
759
  }
463
760
  const azCli = (0, azure_cli_1.checkAzCliAvailability)();
464
761
  if (!azCli.available) {
465
762
  throw new errors_1.MachineCommandError(azCli.reason === "not-found" ? "az-cli-not-found" : "az-cli-not-runnable", azCliUnavailableWarning(azCli), undefined, "blocked");
466
763
  }
467
- const executedCommands = [];
468
- const warnings = [...plan.warnings];
469
- let deploymentOutputs;
470
- assertPlanRemoteState(plan, plan.remoteState, retryHint);
471
- for (const command of plan.commandSpecs) {
472
- if (command.kind === "migration") {
473
- assertPlanRemoteState(plan, command.expectedState, retryHint);
474
- }
475
- if (command.kind === "tag") {
476
- assertTagExpectedState(plan, command.expectedState, retryHint);
477
- }
478
- const result = runAz(command.args);
479
- const commandText = displayCommand(command);
480
- executedCommands.push(commandText);
481
- if (result.status !== 0) {
482
- throw new errors_1.MachineCommandError("provision-failed", `Command failed: ${commandText}\n${(result.stderr || result.stdout).trim().slice(0, 4000)}`, { executedCommands }, "failed");
764
+ const targetKey = crypto.createHash("sha256").update(`${plan.subscription}/${plan.resourceGroup.toLowerCase()}`).digest("hex");
765
+ let unlock;
766
+ try {
767
+ unlock = (0, state_1.lockProvisionTarget)(targetKey, projectRoot);
768
+ }
769
+ catch (error) {
770
+ throw new errors_1.MachineCommandError("provision-target-locked", error instanceof Error ? error.message : "Provision target is locked.", { runId: plan.planId }, "blocked", provisionNextActions(plan.planId));
771
+ }
772
+ const controller = new AbortController();
773
+ let interrupted;
774
+ const interrupt = (signal) => {
775
+ interrupted = signal;
776
+ controller.abort();
777
+ };
778
+ const sigint = () => interrupt("SIGINT");
779
+ const sigterm = () => interrupt("SIGTERM");
780
+ const externalAbort = () => controller.abort();
781
+ process.on("SIGINT", sigint);
782
+ process.on("SIGTERM", sigterm);
783
+ options.signal?.addEventListener("abort", externalAbort, { once: true });
784
+ if (options.signal?.aborted)
785
+ controller.abort();
786
+ const signal = controller.signal;
787
+ const deadline = Date.now() + waitSeconds * 1000;
788
+ const persist = () => {
789
+ if (!run)
790
+ return;
791
+ run.warnings = run.warnings.slice(-20);
792
+ run.diagnostics = run.diagnostics.slice(-20);
793
+ run.updatedAt = new Date().toISOString();
794
+ (0, state_1.saveProvisionRun)(run, projectRoot);
795
+ };
796
+ const pause = (outcome, warning) => {
797
+ run.outcome = outcome;
798
+ run.warnings.push(warning);
799
+ persist();
800
+ return resultForRun(run, outcome, interrupted);
801
+ };
802
+ const observe = async () => {
803
+ const observation = await (0, provision_deployment_1.waitForAzureDeployment)(run.deployment, {
804
+ deadline: signal.aborted ? Date.now() : deadline,
805
+ // A final short read after Ctrl+C must not inherit the aborted wait signal.
806
+ ...(signal.aborted ? {} : { signal }),
807
+ ...(signal.aborted ? { queryTimeoutMs: 5000 } : {}),
808
+ pollIntervalMs: options.pollIntervalMs,
809
+ secretValues,
810
+ onObservation: (value) => {
811
+ run.observation = value;
812
+ if (value.state !== "unknown")
813
+ run.deployment = value.reference;
814
+ persist();
815
+ },
816
+ });
817
+ run.observation = observation;
818
+ run.diagnostics.push(observation.diagnostic);
819
+ run.diagnostics = run.diagnostics.slice(-20);
820
+ return observation;
821
+ };
822
+ try {
823
+ if (!run) {
824
+ const unfinished = readRuns(projectRoot).find((other) => other.outcome !== "succeeded" && other.outcome !== "failed"
825
+ && other.plan.resourceGroup === plan.resourceGroup && other.plan.subscription === plan.subscription);
826
+ if (unfinished)
827
+ throw new errors_1.MachineCommandError("provision-run-pending", "Resume the existing execution checkpoint instead of creating another deployment.", { runId: unfinished.runId }, "blocked", provisionNextActions(unfinished.runId));
828
+ const current = await readMigrationState(plan, signal, secretValues);
829
+ if (plan.mode === "bootstrap" ? current.group.exists : !current.group.exists || !statesEqual(current.state, plan.remoteState)) {
830
+ throw new errors_1.MachineCommandError("stale-azure-state", plan.mode === "bootstrap"
831
+ ? `The resource group was created after the preview was computed. ${retryHint}`
832
+ : `Azure migration state changed after the preview was computed. ${retryHint}`, { expected: plan.remoteState, actual: current.state }, "blocked");
833
+ }
834
+ const checkpointPlan = { ...plan };
835
+ delete checkpointPlan.whatIfResult;
836
+ run = {
837
+ schemaVersion: 1, runId: plan.planId, projectRoot: path.resolve(projectRoot), plan: checkpointPlan,
838
+ cursor: 0, outcome: "in-progress", executedCommands: [], warnings: [...plan.warnings], diagnostics: [],
839
+ updatedAt: new Date().toISOString(),
840
+ };
841
+ persist();
483
842
  }
484
- if (command.kind === "deployment" || command.kind === "migration") {
485
- try {
486
- const deployment = JSON.parse(result.stdout || "{}");
487
- deploymentOutputs = deployment.properties?.outputs;
843
+ for (; run.cursor < plan.commandSpecs.length;) {
844
+ if (signal.aborted)
845
+ return pause(run.observation?.state === "Succeeded" ? "partial" : "unknown", "Local provisioning was interrupted. Azure may continue; use status/resume. No Azure cancel/delete was requested and the ledger is not marked successful.");
846
+ assertLocalFiles(plan, projectRoot, "Restore the original reviewed files before resuming; do not resubmit Bicep.");
847
+ const command = plan.commandSpecs[run.cursor];
848
+ if (command.kind === "deployment" || command.kind === "migration") {
849
+ if (command.kind === "migration" && run.attemptedIndex !== run.cursor && run.observation?.state === "Succeeded" && Date.now() >= deadline) {
850
+ return pause("partial", "The bounded wait budget ended before the next reviewed migration. Resume this checkpoint after approval; completed deployments will not be re-submitted.");
851
+ }
852
+ if (run.attemptedIndex !== run.cursor) {
853
+ let observationOnly = Boolean(plan.recoveryDeployment);
854
+ if (plan.recoveryDeployment) {
855
+ run.deployment = plan.recoveryDeployment;
856
+ }
857
+ else {
858
+ const current = await readMigrationState(plan, signal, secretValues);
859
+ if (!current.group.exists || !statesEqual(current.state, command.expectedState ?? plan.remoteState)) {
860
+ throw new errors_1.MachineCommandError("stale-azure-state", `Azure migration state changed before deployment. ${retryHint}`, { expected: command.expectedState ?? plan.remoteState, actual: current.state }, "blocked");
861
+ }
862
+ const name = command.args[command.args.indexOf("--name") + 1];
863
+ const reference = { name, resourceGroup: plan.resourceGroup, subscription: plan.subscription };
864
+ const previous = await (0, provision_deployment_1.queryAzureDeployment)(reference, { signal, secretValues });
865
+ if (previous.state === "unknown" && previous.reason !== "not-found") {
866
+ run.deployment = reference;
867
+ run.observation = previous;
868
+ return pause("unknown", "The deployment name could not be checked before submission. No create request was sent; inspect the original deployment before proceeding.");
869
+ }
870
+ if (previous.state !== "unknown" && (plan.mode !== "reconcile" || previous.state === "Running" || previous.state === "Accepted")) {
871
+ run.deployment = previous.reference;
872
+ run.observation = previous;
873
+ if (!resuming || command.kind !== "deployment") {
874
+ return pause("unknown", "A deployment already exists under the reviewed name. No create request was sent. Resume only after matching its template/parameters; do not overwrite or re-submit it.");
875
+ }
876
+ run.deployment = await verifyExistingBaseline({ ...plan, resumeDeployment: name }, path.join(projectRoot, "infra", "main.bicep"), path.join(projectRoot, "infra", "main.parameters.json"));
877
+ observationOnly = true;
878
+ }
879
+ else {
880
+ run.deployment = {
881
+ ...reference,
882
+ ...(previous.state !== "unknown" ? { previousCorrelationId: previous.reference.correlationId } : {}),
883
+ };
884
+ }
885
+ }
886
+ // Persist intent before spawn: even a hard process termination must never replay this create.
887
+ run.attemptedIndex = run.cursor;
888
+ persist();
889
+ if (!observationOnly) {
890
+ const result = await (0, azure_cli_1.runAzCliAsync)(command.args, { timeout: 60000, signal });
891
+ run.executedCommands.push(displayCommand(command));
892
+ rememberDiagnostic(run, result, command.kind === "migration" ? "migration-submit" : "baseline-submit", secretValues);
893
+ persist();
894
+ }
895
+ }
896
+ const observation = await observe();
897
+ if (signal.aborted)
898
+ return pause(observation.state === "Succeeded" ? "partial" : observation.state === "Running" || observation.state === "Accepted" ? "in-progress" : "unknown", "Local provisioning was interrupted. Azure may continue. The original deployment is tracked; no cancellation or successful ledger update was performed.");
899
+ if (observation.state === "Failed" || observation.state === "Canceled") {
900
+ const operations = await (0, provision_deployment_1.queryDeploymentOperationErrors)(observation.reference, secretValues, signal);
901
+ observation.errors.push(...operations.errors);
902
+ run.diagnostics.push(operations.diagnostic);
903
+ run.outcome = "failed";
904
+ persist();
905
+ throw new errors_1.MachineCommandError("azure-deployment-failed", `Azure deployment ${observation.reference.name} is ${observation.state}.${observation.errors.length ? `\n${observation.errors.map((error) => `${error.code}: ${error.message}`).join("\n")}` : " Azure did not return a structured cause; inspect deployment operations."}`, resultForRun(run), "failed", provisionNextActions(run.runId));
906
+ }
907
+ if (observation.state !== "Succeeded") {
908
+ return pause(observation.state === "unknown" ? "unknown" : "in-progress", observation.state === "unknown" ? `Azure deployment result is unknown (${observation.reason}); a local az error is not proof that Azure failed. No create will be automatically retried.`
909
+ : `Azure deployment is ${observation.state}. The bounded local wait ended; status/resume tracks the same deployment without submitting Bicep again.`);
910
+ }
488
911
  }
489
- catch {
490
- warnings.push("Could not parse deployment output JSON.");
912
+ else if (command.kind === "tag") {
913
+ if (run.deployment) {
914
+ const observation = await (0, provision_deployment_1.queryAzureDeployment)(run.deployment, { signal, secretValues });
915
+ run.observation = observation;
916
+ if (observation.state !== "Succeeded")
917
+ return pause("partial", "Azure Succeeded was previously observed, but its identity/terminal state could not be confirmed before migration tagging. No tag or ledger update was performed.");
918
+ }
919
+ const current = await readMigrationState(plan, signal, secretValues);
920
+ const desired = desiredTagState(command, plan.migrationTagKey);
921
+ if (!current.group.exists)
922
+ throw new errors_1.MachineCommandError("stale-azure-state", "The resource group disappeared before tagging.", undefined, "blocked");
923
+ if (!statesEqual(current.state, desired)) {
924
+ if (!statesEqual(current.state, command.expectedState)) {
925
+ throw new errors_1.MachineCommandError("stale-azure-state", `Azure migration state changed before its tag could be updated. ${retryHint}`, { expected: command.expectedState, actual: current.state }, "blocked");
926
+ }
927
+ run.attemptedIndex = run.cursor;
928
+ persist();
929
+ const result = await (0, azure_cli_1.runAzCliAsync)(command.args, { timeout: 60000, signal });
930
+ run.executedCommands.push(displayCommand(command));
931
+ rememberDiagnostic(run, result, "migration-tag", secretValues);
932
+ if (result.status !== 0 || result.failureKind || signal.aborted) {
933
+ return pause("partial", "Azure deployment succeeded, but migration tagging did not complete locally. Resume this checkpoint to verify/finish tagging; do not redeploy the baseline.");
934
+ }
935
+ const after = await readMigrationState(plan, signal, secretValues);
936
+ if (!statesEqual(after.state, desired))
937
+ return pause("partial", "The tag update returned successfully but its version/checksum could not be confirmed. Resume bookkeeping without redeploying Bicep.");
938
+ }
491
939
  }
940
+ else {
941
+ const current = await readMigrationState(plan, signal, secretValues);
942
+ if (run.attemptedIndex === run.cursor) {
943
+ if (!current.group.exists)
944
+ return pause("unknown", "Resource group creation was attempted, but its result is unknown. Inspect the target before resuming; nothing will be re-created automatically.");
945
+ }
946
+ else {
947
+ if (current.group.exists)
948
+ throw new errors_1.MachineCommandError("stale-azure-state", `The resource group was created after the preview was computed. ${retryHint}`, undefined, "blocked");
949
+ run.attemptedIndex = run.cursor;
950
+ persist();
951
+ const result = await (0, azure_cli_1.runAzCliAsync)(command.args, { timeout: 60000, signal });
952
+ run.executedCommands.push(displayCommand(command));
953
+ rememberDiagnostic(run, result, "resource-group-create", secretValues);
954
+ if (result.status !== 0 || result.failureKind || signal.aborted)
955
+ return pause("unknown", "Local resource-group creation did not complete. Azure may have accepted it; inspect/resume this checkpoint, not a new bootstrap.");
956
+ }
957
+ }
958
+ run.cursor++;
959
+ delete run.attemptedIndex;
960
+ persist();
961
+ }
962
+ if (signal.aborted)
963
+ return pause("partial", "Local provisioning was interrupted before ledger completion. Resume bookkeeping; no deployment will be submitted again.");
964
+ assertLocalFiles(plan, projectRoot, "Restore the reviewed files before completing the local ledger.", true);
965
+ const finalTag = [...plan.commandSpecs].reverse().find((command) => command.kind === "tag");
966
+ if (finalTag) {
967
+ const current = await readMigrationState(plan, signal, secretValues);
968
+ if (!statesEqual(current.state, desiredTagState(finalTag, plan.migrationTagKey)))
969
+ return pause("partial", "The final remote migration version/checksum changed before local ledger completion. No ledger success was recorded.");
492
970
  }
971
+ if (run.deployment) {
972
+ run.observation = await (0, provision_deployment_1.queryAzureDeployment)(run.deployment, { signal, secretValues });
973
+ if (run.observation.state !== "Succeeded")
974
+ return pause("partial", "The original deployment identity/terminal state could not be confirmed before local ledger completion. No successful ledger update was performed.");
975
+ }
976
+ if (plan.manifestAfterApply && run.outcome !== "succeeded")
977
+ (0, migrations_1.replaceMigrationManifest)(plan.manifestAfterApply, projectRoot);
978
+ run.outcome = "succeeded";
979
+ persist();
980
+ return resultForRun(run);
493
981
  }
494
- if (plan.manifestAfterApply) {
495
- (0, migrations_1.replaceMigrationManifest)(plan.manifestAfterApply, projectRoot);
982
+ catch (error) {
983
+ if (!run)
984
+ throw error;
985
+ if (error instanceof errors_1.MachineCommandError && error.code === "azure-deployment-failed")
986
+ throw error;
987
+ run.outcome = run.observation?.state === "Succeeded" ? "partial" : "unknown";
988
+ run.warnings.push(`Provisioning stopped: ${(0, azure_diagnostics_1.redactAzureText)(error instanceof Error ? error.message : "Unknown local error", secretValues)}. Azure deployment and local bookkeeping are separate; inspect/resume this checkpoint.`);
989
+ try {
990
+ persist();
991
+ }
992
+ catch {
993
+ run.warnings.push("The checkpoint could not be updated. Preserve the existing checkpoint; inspect the original Azure deployment before any further write.");
994
+ }
995
+ if (error instanceof errors_1.MachineCommandError) {
996
+ throw new errors_1.MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status ?? "blocked", provisionNextActions(run.runId));
997
+ }
998
+ return resultForRun(run, run.outcome, interrupted);
999
+ }
1000
+ finally {
1001
+ process.removeListener("SIGINT", sigint);
1002
+ process.removeListener("SIGTERM", sigterm);
1003
+ options.signal?.removeEventListener("abort", externalAbort);
1004
+ try {
1005
+ unlock();
1006
+ }
1007
+ catch {
1008
+ run?.warnings.push("The local execution lock could not be removed. Inspect its owner before retrying; Azure deployment/ledger results are unchanged.");
1009
+ console.warn("Provision lock cleanup failed; inspect the checkpoint before another apply.");
1010
+ }
496
1011
  }
497
- return {
498
- planId: plan.planId,
499
- resourceGroup: plan.resourceGroup,
500
- location: plan.location,
501
- swaLocation: plan.swaLocation,
502
- executedCommands,
503
- ...(deploymentOutputs ? { deploymentOutputs } : {}),
504
- warnings,
505
- };
506
1012
  }
507
1013
  //# sourceMappingURL=provision-operations.js.map