swallowkit 1.1.2 → 2.0.0-alpha.10

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 (262) hide show
  1. package/README.ja.md +61 -7
  2. package/README.md +61 -7
  3. package/dist/cli/commands/add-auth.d.ts.map +1 -1
  4. package/dist/cli/commands/add-auth.js +103 -27
  5. package/dist/cli/commands/add-auth.js.map +1 -1
  6. package/dist/cli/commands/configure-mcp.d.ts +12 -0
  7. package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
  8. package/dist/cli/commands/configure-mcp.js +42 -0
  9. package/dist/cli/commands/configure-mcp.js.map +1 -0
  10. package/dist/cli/commands/dev.d.ts +5 -2
  11. package/dist/cli/commands/dev.d.ts.map +1 -1
  12. package/dist/cli/commands/dev.js +143 -67
  13. package/dist/cli/commands/dev.js.map +1 -1
  14. package/dist/cli/commands/init.d.ts +14 -7
  15. package/dist/cli/commands/init.d.ts.map +1 -1
  16. package/dist/cli/commands/init.js +149 -170
  17. package/dist/cli/commands/init.js.map +1 -1
  18. package/dist/cli/commands/migrations.d.ts.map +1 -1
  19. package/dist/cli/commands/migrations.js +25 -0
  20. package/dist/cli/commands/migrations.js.map +1 -1
  21. package/dist/cli/commands/provision.d.ts +5 -0
  22. package/dist/cli/commands/provision.d.ts.map +1 -1
  23. package/dist/cli/commands/provision.js +278 -55
  24. package/dist/cli/commands/provision.js.map +1 -1
  25. package/dist/cli/commands/scaffold.d.ts +5 -1
  26. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  27. package/dist/cli/commands/scaffold.js +123 -18
  28. package/dist/cli/commands/scaffold.js.map +1 -1
  29. package/dist/cli/index.d.ts +2 -0
  30. package/dist/cli/index.d.ts.map +1 -1
  31. package/dist/cli/index.js +49 -8
  32. package/dist/cli/index.js.map +1 -1
  33. package/dist/core/config.d.ts +2 -1
  34. package/dist/core/config.d.ts.map +1 -1
  35. package/dist/core/config.js +19 -0
  36. package/dist/core/config.js.map +1 -1
  37. package/dist/core/operations/create-model.d.ts +2 -0
  38. package/dist/core/operations/create-model.d.ts.map +1 -1
  39. package/dist/core/operations/create-model.js +60 -8
  40. package/dist/core/operations/create-model.js.map +1 -1
  41. package/dist/core/operations/migration-generator.d.ts.map +1 -1
  42. package/dist/core/operations/migration-generator.js +12 -1
  43. package/dist/core/operations/migration-generator.js.map +1 -1
  44. package/dist/core/operations/model-migration.d.ts +22 -0
  45. package/dist/core/operations/model-migration.d.ts.map +1 -0
  46. package/dist/core/operations/model-migration.js +36 -0
  47. package/dist/core/operations/model-migration.js.map +1 -0
  48. package/dist/core/operations/provision-deployment.d.ts +52 -0
  49. package/dist/core/operations/provision-deployment.d.ts.map +1 -0
  50. package/dist/core/operations/provision-deployment.js +164 -0
  51. package/dist/core/operations/provision-deployment.js.map +1 -0
  52. package/dist/core/operations/provision-operations.d.ts +62 -6
  53. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  54. package/dist/core/operations/provision-operations.js +653 -115
  55. package/dist/core/operations/provision-operations.js.map +1 -1
  56. package/dist/core/operations/scaffold-apply.d.ts +1 -0
  57. package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
  58. package/dist/core/operations/scaffold-apply.js +1 -0
  59. package/dist/core/operations/scaffold-apply.js.map +1 -1
  60. package/dist/core/operations/scaffold-machine.d.ts +1 -0
  61. package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
  62. package/dist/core/operations/scaffold-machine.js.map +1 -1
  63. package/dist/core/operations/scaffold-plan.d.ts +2 -0
  64. package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
  65. package/dist/core/operations/scaffold-plan.js +3 -1
  66. package/dist/core/operations/scaffold-plan.js.map +1 -1
  67. package/dist/core/project/agent-instructions.d.ts +2 -1
  68. package/dist/core/project/agent-instructions.d.ts.map +1 -1
  69. package/dist/core/project/agent-instructions.js +15 -0
  70. package/dist/core/project/agent-instructions.js.map +1 -1
  71. package/dist/core/project/boundaries.d.ts +5 -2
  72. package/dist/core/project/boundaries.d.ts.map +1 -1
  73. package/dist/core/project/boundaries.js +51 -20
  74. package/dist/core/project/boundaries.js.map +1 -1
  75. package/dist/core/project/capabilities.d.ts +17 -0
  76. package/dist/core/project/capabilities.d.ts.map +1 -1
  77. package/dist/core/project/capabilities.js +45 -6
  78. package/dist/core/project/capabilities.js.map +1 -1
  79. package/dist/core/project/home-page.d.ts +4 -0
  80. package/dist/core/project/home-page.d.ts.map +1 -0
  81. package/dist/core/project/home-page.js +113 -0
  82. package/dist/core/project/home-page.js.map +1 -0
  83. package/dist/core/project/manifest.d.ts +3 -1
  84. package/dist/core/project/manifest.d.ts.map +1 -1
  85. package/dist/core/project/manifest.js +47 -25
  86. package/dist/core/project/manifest.js.map +1 -1
  87. package/dist/core/project/mcp-config.d.ts +26 -0
  88. package/dist/core/project/mcp-config.d.ts.map +1 -0
  89. package/dist/core/project/mcp-config.js +117 -0
  90. package/dist/core/project/mcp-config.js.map +1 -0
  91. package/dist/core/project/react-project.d.ts +26 -0
  92. package/dist/core/project/react-project.d.ts.map +1 -0
  93. package/dist/core/project/react-project.js +443 -0
  94. package/dist/core/project/react-project.js.map +1 -0
  95. package/dist/core/project/state.d.ts +7 -0
  96. package/dist/core/project/state.d.ts.map +1 -1
  97. package/dist/core/project/state.js +86 -0
  98. package/dist/core/project/state.js.map +1 -1
  99. package/dist/core/project/validation.d.ts.map +1 -1
  100. package/dist/core/project/validation.js +32 -10
  101. package/dist/core/project/validation.js.map +1 -1
  102. package/dist/core/project/workflows.d.ts.map +1 -1
  103. package/dist/core/project/workflows.js +53 -7
  104. package/dist/core/project/workflows.js.map +1 -1
  105. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  106. package/dist/core/scaffold/auth-generator.js +26 -27
  107. package/dist/core/scaffold/auth-generator.js.map +1 -1
  108. package/dist/core/scaffold/model-parser.d.ts +23 -0
  109. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  110. package/dist/core/scaffold/model-parser.js +407 -137
  111. package/dist/core/scaffold/model-parser.js.map +1 -1
  112. package/dist/core/scaffold/native-schema-generator.d.ts +13 -0
  113. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  114. package/dist/core/scaffold/native-schema-generator.js +294 -91
  115. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  116. package/dist/core/scaffold/openapi-generator.d.ts +5 -0
  117. package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
  118. package/dist/core/scaffold/openapi-generator.js +53 -3
  119. package/dist/core/scaffold/openapi-generator.js.map +1 -1
  120. package/dist/core/scaffold/react-bff-generator.d.ts +10 -0
  121. package/dist/core/scaffold/react-bff-generator.d.ts.map +1 -0
  122. package/dist/core/scaffold/react-bff-generator.js +252 -0
  123. package/dist/core/scaffold/react-bff-generator.js.map +1 -0
  124. package/dist/core/scaffold/react-ui-generator.d.ts +6 -0
  125. package/dist/core/scaffold/react-ui-generator.d.ts.map +1 -0
  126. package/dist/core/scaffold/react-ui-generator.js +41 -0
  127. package/dist/core/scaffold/react-ui-generator.js.map +1 -0
  128. package/dist/core/scaffold/ui-generator.d.ts +0 -3
  129. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  130. package/dist/core/scaffold/ui-generator.js +68 -7
  131. package/dist/core/scaffold/ui-generator.js.map +1 -1
  132. package/dist/core/verify/index.d.ts +4 -3
  133. package/dist/core/verify/index.d.ts.map +1 -1
  134. package/dist/core/verify/index.js +122 -5
  135. package/dist/core/verify/index.js.map +1 -1
  136. package/dist/index.d.ts +3 -3
  137. package/dist/index.d.ts.map +1 -1
  138. package/dist/index.js +3 -2
  139. package/dist/index.js.map +1 -1
  140. package/dist/machine/contracts.d.ts +1 -0
  141. package/dist/machine/contracts.d.ts.map +1 -1
  142. package/dist/machine/errors.d.ts +3 -2
  143. package/dist/machine/errors.d.ts.map +1 -1
  144. package/dist/machine/errors.js +2 -1
  145. package/dist/machine/errors.js.map +1 -1
  146. package/dist/machine/index.d.ts.map +1 -1
  147. package/dist/machine/index.js +61 -30
  148. package/dist/machine/index.js.map +1 -1
  149. package/dist/mcp/index.d.ts +6 -4
  150. package/dist/mcp/index.d.ts.map +1 -1
  151. package/dist/mcp/index.js +210 -25
  152. package/dist/mcp/index.js.map +1 -1
  153. package/dist/mcp/version-selection.d.ts +55 -0
  154. package/dist/mcp/version-selection.d.ts.map +1 -0
  155. package/dist/mcp/version-selection.js +152 -0
  156. package/dist/mcp/version-selection.js.map +1 -0
  157. package/dist/types/index.d.ts +4 -0
  158. package/dist/types/index.d.ts.map +1 -1
  159. package/dist/utils/azure-cli.d.ts +56 -0
  160. package/dist/utils/azure-cli.d.ts.map +1 -0
  161. package/dist/utils/azure-cli.js +326 -0
  162. package/dist/utils/azure-cli.js.map +1 -0
  163. package/dist/utils/azure-diagnostics.d.ts +16 -0
  164. package/dist/utils/azure-diagnostics.d.ts.map +1 -0
  165. package/dist/utils/azure-diagnostics.js +68 -0
  166. package/dist/utils/azure-diagnostics.js.map +1 -0
  167. package/dist/utils/package-manager.d.ts +2 -2
  168. package/dist/utils/package-manager.d.ts.map +1 -1
  169. package/dist/utils/package-manager.js +6 -3
  170. package/dist/utils/package-manager.js.map +1 -1
  171. package/dist/utils/python-uv.d.ts +5 -7
  172. package/dist/utils/python-uv.d.ts.map +1 -1
  173. package/dist/utils/python-uv.js +50 -24
  174. package/dist/utils/python-uv.js.map +1 -1
  175. package/dist/utils/react-dev.d.ts +4 -0
  176. package/dist/utils/react-dev.d.ts.map +1 -0
  177. package/dist/utils/react-dev.js +35 -0
  178. package/dist/utils/react-dev.js.map +1 -0
  179. package/dist/utils/semver.d.ts +14 -0
  180. package/dist/utils/semver.d.ts.map +1 -0
  181. package/dist/utils/semver.js +61 -0
  182. package/dist/utils/semver.js.map +1 -0
  183. package/package.json +7 -4
  184. package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
  185. package/src/__tests__/agent-instructions.test.ts +6 -1
  186. package/src/__tests__/agent-loop-phase34.test.ts +143 -8
  187. package/src/__tests__/auth.test.ts +9 -0
  188. package/src/__tests__/azure-cli.test.ts +202 -0
  189. package/src/__tests__/boundaries.test.ts +42 -0
  190. package/src/__tests__/csharp-schema.test.ts +261 -0
  191. package/src/__tests__/dev.test.ts +13 -6
  192. package/src/__tests__/fixtures/az wrapper.cmd +2 -0
  193. package/src/__tests__/fixtures/azure-cli.cjs +153 -0
  194. package/src/__tests__/fixtures/provision-runner.cjs +28 -0
  195. package/src/__tests__/generated-project-matrix.test.ts +789 -0
  196. package/src/__tests__/home-page.test.ts +50 -0
  197. package/src/__tests__/init.test.ts +145 -5
  198. package/src/__tests__/mcp-version.test.ts +262 -0
  199. package/src/__tests__/mcp.test.ts +120 -3
  200. package/src/__tests__/multi-auth.test.ts +115 -4
  201. package/src/__tests__/package-manager.test.ts +17 -2
  202. package/src/__tests__/plan-apply.test.ts +179 -0
  203. package/src/__tests__/provision-runtime.test.ts +722 -0
  204. package/src/__tests__/python-uv.test.ts +50 -14
  205. package/src/__tests__/python-venv.test.ts +48 -0
  206. package/src/__tests__/react-dev.test.ts +72 -0
  207. package/src/__tests__/react.test.ts +838 -0
  208. package/src/__tests__/release-metadata.test.ts +121 -0
  209. package/src/__tests__/schema-analysis.test.ts +329 -0
  210. package/src/cli/commands/add-auth.ts +103 -29
  211. package/src/cli/commands/configure-mcp.ts +54 -0
  212. package/src/cli/commands/dev.ts +145 -73
  213. package/src/cli/commands/init.ts +147 -172
  214. package/src/cli/commands/migrations.ts +22 -0
  215. package/src/cli/commands/provision.ts +294 -56
  216. package/src/cli/commands/scaffold.ts +145 -22
  217. package/src/cli/index.ts +46 -10
  218. package/src/core/config.ts +21 -1
  219. package/src/core/operations/create-model.ts +51 -13
  220. package/src/core/operations/migration-generator.ts +14 -1
  221. package/src/core/operations/model-migration.ts +52 -0
  222. package/src/core/operations/provision-deployment.ts +183 -0
  223. package/src/core/operations/provision-operations.ts +662 -154
  224. package/src/core/operations/scaffold-apply.ts +2 -0
  225. package/src/core/operations/scaffold-machine.ts +1 -0
  226. package/src/core/operations/scaffold-plan.ts +5 -1
  227. package/src/core/project/agent-instructions.ts +17 -1
  228. package/src/core/project/agent-templates/AGENTS.md +113 -15
  229. package/src/core/project/agent-templates/CLAUDE.md +14 -1
  230. package/src/core/project/agent-templates/bff-routes.instructions.md +5 -3
  231. package/src/core/project/agent-templates/copilot-instructions.md +15 -1
  232. package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
  233. package/src/core/project/boundaries.ts +62 -20
  234. package/src/core/project/capabilities.ts +63 -6
  235. package/src/core/project/home-page.ts +114 -0
  236. package/src/core/project/manifest.ts +52 -27
  237. package/src/core/project/mcp-config.ts +84 -0
  238. package/src/core/project/react-project.ts +529 -0
  239. package/src/core/project/state.ts +70 -0
  240. package/src/core/project/validation.ts +32 -10
  241. package/src/core/project/workflows.ts +53 -7
  242. package/src/core/scaffold/auth-generator.ts +26 -27
  243. package/src/core/scaffold/model-parser.ts +413 -129
  244. package/src/core/scaffold/native-schema-generator.ts +319 -103
  245. package/src/core/scaffold/openapi-generator.ts +63 -5
  246. package/src/core/scaffold/react-bff-generator.ts +292 -0
  247. package/src/core/scaffold/react-ui-generator.ts +51 -0
  248. package/src/core/scaffold/ui-generator.ts +70 -8
  249. package/src/core/verify/index.ts +118 -6
  250. package/src/index.ts +3 -1
  251. package/src/machine/contracts.ts +1 -0
  252. package/src/machine/errors.ts +4 -2
  253. package/src/machine/index.ts +67 -36
  254. package/src/mcp/index.ts +221 -31
  255. package/src/mcp/version-selection.ts +163 -0
  256. package/src/types/index.ts +4 -0
  257. package/src/utils/azure-cli.ts +315 -0
  258. package/src/utils/azure-diagnostics.ts +75 -0
  259. package/src/utils/package-manager.ts +7 -4
  260. package/src/utils/python-uv.ts +53 -33
  261. package/src/utils/react-dev.ts +31 -0
  262. package/src/utils/semver.ts +59 -0
@@ -1,21 +1,27 @@
1
1
  /**
2
2
  * Plan / Apply Provision — Azure プロビジョニングの事前計画と承認付き実行。
3
3
  *
4
- * - plan provision: ローカルの決定論的プリフライト(Bicep 解析・az CLI 有無)のみ。
5
- * ネットワークアクセスや az login を必要とする操作は行わない。
6
- * --what-if を明示した場合のみ az deployment group what-if を実行する。
4
+ * - plan provision: Bicep と Azure の現在の状態を確認する。リソースは変更しない。
7
5
  * - apply provision: 常に承認(--approve)必須。承認なしでは requires-human で停止する。
6
+ * - 対話的な `swallowkit provision` は同じ計画をメモリ上で作成・表示し、確認後に
7
+ * executeProvisionPlan で同じ安全確認を経て実行する(計画ファイルは保存しない)。
8
8
  */
9
9
 
10
10
  import * as crypto from "crypto";
11
11
  import * as fs from "fs";
12
+ import { AzCliAvailability, AzCliResult, checkAzCliAvailability, runAzCli, runAzCliAsync } from "../../utils/azure-cli";
13
+ import { AzureCliDiagnostic, azureCliDiagnostic, redactAzureText } from "../../utils/azure-diagnostics";
12
14
  import * as path from "path";
13
- import { spawnSync } from "child_process";
14
15
  import { MachineCommandError } from "../../machine/errors";
16
+ import type { MachineNextAction, MachineOperationStatus } from "../../machine/contracts";
15
17
  import { getSwallowKitVersion } from "../../version";
16
18
  import { inspectInfra, InfraInspection } from "../project/infra";
17
- import { deletePlanState, loadPlanState, savePlanState } from "../project/state";
19
+ import { deletePlanState, listProvisionRuns, loadPlanState, loadProvisionRun, lockProvisionTarget, savePlanState, saveProvisionRun } from "../project/state";
18
20
  import { hashFileIfExists } from "./file-session";
21
+ import {
22
+ DEFAULT_PROVISION_WAIT_SECONDS, DEPLOYMENT_QUERY, DeploymentObservation, DeploymentReference,
23
+ MAX_PROVISION_WAIT_SECONDS, queryAzureDeployment, queryDeploymentOperationErrors, UNBOUNDED_WAIT_TRANSIENT_RETRIES, waitForAzureDeployment,
24
+ } from "./provision-deployment";
19
25
  import {
20
26
  MIGRATION_MANIFEST_PATH,
21
27
  MigrationManifest,
@@ -30,7 +36,6 @@ import {
30
36
  getMigrationTagKey,
31
37
  loadMigrationManifest,
32
38
  replaceMigrationManifest,
33
- resolveMigrations,
34
39
  selectPendingMigrations,
35
40
  } from "../project/migrations";
36
41
 
@@ -48,9 +53,11 @@ export interface PlanProvisionOptions {
48
53
  adoptExisting?: boolean;
49
54
  baseline?: number;
50
55
  reconcile?: boolean;
56
+ /** Attach an existing, matching baseline deployment without submitting Bicep again. */
57
+ resumeDeployment?: string;
51
58
  }
52
59
 
53
- export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile";
60
+ export type ProvisionMode = "offline" | "bootstrap" | "adoption-required" | "adopt" | "migrate" | "noop" | "reconcile" | "recover";
54
61
 
55
62
  interface ProvisionCommand {
56
63
  file: "az";
@@ -84,6 +91,7 @@ export interface ProvisionPlanData {
84
91
  requiresApproval: true;
85
92
  fingerprints: Record<string, string | null>;
86
93
  baselineSnapshotChecksum?: string;
94
+ recoveryDeployment?: DeploymentReference;
87
95
  }
88
96
 
89
97
  function validateProvisionInputs(options: PlanProvisionOptions): void {
@@ -111,34 +119,37 @@ function validateProvisionInputs(options: PlanProvisionOptions): void {
111
119
  "failed"
112
120
  );
113
121
  }
122
+ if (options.resumeDeployment && (!/^[A-Za-z0-9._()-]{1,64}$/.test(options.resumeDeployment) || options.reconcile || options.adoptExisting)) {
123
+ throw new MachineCommandError("invalid-arguments", "--resume-deployment requires a valid deployment name and cannot be combined with adoption or reconcile.", undefined, "blocked");
124
+ }
114
125
  }
115
126
 
116
- function isAzCliAvailable(): boolean {
117
- try {
118
- const result = spawnSync("az", ["--version"], { shell: false, stdio: "ignore", timeout: 30_000 });
119
- return result.status === 0;
120
- } catch {
121
- return false;
122
- }
127
+ function azCliUnavailableWarning(availability: Exclude<AzCliAvailability, { available: true }>): string {
128
+ return availability.reason === "not-found"
129
+ ? "az-cli-not-found: Azure CLI was not found on PATH. Install it (https://aka.ms/azure-cli) or add it to PATH, then try again."
130
+ : `az-cli-not-runnable: Azure CLI was found but could not be started. ${availability.detail}`;
123
131
  }
124
132
 
125
133
  function displayCommand(command: ProvisionCommand): string {
126
134
  return [command.file, ...command.args.map((arg) => /\s/.test(arg) ? JSON.stringify(arg) : arg)].join(" ");
127
135
  }
128
136
 
129
- interface AzResult {
130
- status: number | null;
131
- stdout: string;
132
- stderr: string;
137
+ function runAz(args: string[], timeout = 60_000): AzCliResult {
138
+ return runAzCli(args, { timeout });
133
139
  }
134
140
 
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
- };
141
+ function azQueryError(code: string, message: string, result: AzCliResult, secretValues: readonly string[] = []): MachineCommandError {
142
+ const diagnostic = azureCliDiagnostic(result, "azure-state", secretValues);
143
+ const output = (diagnostic.stderr || diagnostic.stdout).trim().slice(0, 2_000);
144
+ if (/az login|not logged in|AADSTS|expired/i.test(output)) {
145
+ return new MachineCommandError(
146
+ "az-not-logged-in",
147
+ `Azure CLI is not signed in (or the session expired). Run "az login", then try again.\n${output.slice(0, 1000)}`,
148
+ { diagnostic },
149
+ "requires-human"
150
+ );
151
+ }
152
+ return new MachineCommandError(code, `${message}: ${output || result.errorCode || result.failureKind || `exit ${result.status}`}`, { diagnostic }, "blocked");
142
153
  }
143
154
 
144
155
  function subscriptionArgs(subscription?: string): string[] {
@@ -147,10 +158,10 @@ function subscriptionArgs(subscription?: string): string[] {
147
158
 
148
159
  function buildBootstrapCommands(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): ProvisionCommand[] {
149
160
  const commands: ProvisionCommand[] = [];
150
- commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription)], kind: "resource-group" });
161
+ commands.push({ file: "az", args: ["group", "create", "--name", options.resourceGroup, "--location", options.location, ...subscriptionArgs(options.subscription), "--query", "id", "-o", "json"], kind: "resource-group" });
151
162
  commands.push({
152
163
  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)],
164
+ 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", DEPLOYMENT_QUERY, "-o", "json"],
154
165
  kind: "deployment",
155
166
  });
156
167
  return commands;
@@ -175,24 +186,30 @@ interface RemoteGroupState {
175
186
 
176
187
  function inspectRemoteGroup(options: PlanProvisionOptions): RemoteGroupState {
177
188
  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");
189
+ if (existsResult.status !== 0 || existsResult.failureKind || !/^(true|false)$/.test(existsResult.stdout.trim())) {
190
+ throw azQueryError("azure-state-query-failed", "Could not query resource group", existsResult);
180
191
  }
181
192
  if ((existsResult.stdout || "").trim() !== "true") return { exists: false, tags: {} };
182
193
  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");
194
+ if (showResult.status !== 0 || showResult.failureKind || showResult.stdoutTruncated) {
195
+ throw azQueryError("azure-state-query-failed", "Could not read resource group tags", showResult);
196
+ }
197
+ let parsed: { id?: string; tags?: Record<string, string> };
198
+ try { parsed = JSON.parse(showResult.stdout); } catch {
199
+ throw azQueryError("azure-state-query-failed", "Invalid resource group response", showResult);
200
+ }
201
+ if (!parsed || typeof parsed.id !== "string") {
202
+ throw azQueryError("azure-state-query-failed", "Invalid resource group identity", showResult);
185
203
  }
186
- const parsed = JSON.parse(showResult.stdout || "{}");
187
204
  return { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
188
205
  }
189
206
 
190
207
  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 };
208
+ return { file: "az", args: ["tag", "update", "--resource-id", resourceId, "--operation", "Merge", "--tags", `${key}=${encodeMigrationState(state)}`, "-o", "none"], kind: "tag", expectedState };
192
209
  }
193
210
 
194
211
  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 };
212
+ return { file: "az", args: ["group", "update", "--name", options.resourceGroup, "--set", `tags.${key}=${encodeMigrationState(state)}`, ...subscriptionArgs(options.subscription), "-o", "none"], kind: "tag", expectedState: null };
196
213
  }
197
214
 
198
215
  function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, parametersPath: string): string {
@@ -208,7 +225,7 @@ function runBaseWhatIf(options: PlanProvisionOptions, mainBicepPath: string, par
208
225
  "-o", "json",
209
226
  ]);
210
227
  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");
228
+ throw azQueryError("what-if-failed", "az deployment group what-if failed", result);
212
229
  }
213
230
  return result.stdout.trim();
214
231
  }
@@ -231,7 +248,98 @@ function assertAdoptionWhatIfIsSafe(whatIfResult: string): void {
231
248
  }
232
249
  }
233
250
 
234
- export async function planProvisionOperation(options: PlanProvisionOptions): Promise<ProvisionPlanData> {
251
+ function resolveSubscription(options: PlanProvisionOptions): string {
252
+ if (options.subscription) return options.subscription;
253
+ const result = runAz(["account", "show", "--query", "id", "-o", "tsv"]);
254
+ const subscription = result.stdout.trim();
255
+ if (result.status !== 0 || !SUBSCRIPTION_PATTERN.test(subscription)) {
256
+ throw azQueryError("azure-subscription-query-failed", "Could not identify the Azure subscription for this provision run", result);
257
+ }
258
+ return subscription;
259
+ }
260
+
261
+ function canonicalTemplate(value: unknown): string {
262
+ const normalize = (item: unknown): unknown => {
263
+ if (Array.isArray(item)) return item.map(normalize);
264
+ if (item && typeof item === "object") {
265
+ return Object.fromEntries(Object.entries(item).filter(([key]) => key !== "_generator")
266
+ .sort(([left], [right]) => left.localeCompare(right)).map(([key, entry]) => [key, normalize(entry)]));
267
+ }
268
+ return item;
269
+ };
270
+ return JSON.stringify(normalize(value));
271
+ }
272
+
273
+ async function verifyExistingBaseline(
274
+ options: PlanProvisionOptions,
275
+ mainBicepPath: string,
276
+ parametersPath: string
277
+ ): Promise<DeploymentReference> {
278
+ const projectRoot = path.dirname(path.dirname(mainBicepPath));
279
+ const secretValues = provisionSecretValues({
280
+ fingerprints: Object.fromEntries(collectLegacyBaselineFiles(projectRoot).map(file => [file.path, file.checksum])),
281
+ }, projectRoot);
282
+ const reference: DeploymentReference = {
283
+ subscription: options.subscription!, resourceGroup: options.resourceGroup, name: options.resumeDeployment!,
284
+ };
285
+ const observation = await queryAzureDeployment(reference, { secretValues });
286
+ if (observation.state === "unknown") {
287
+ throw new MachineCommandError("recovery-deployment-unknown", "The existing deployment could not be identified. No Bicep will be submitted.", { deployment: observation }, "blocked");
288
+ }
289
+ const compiled = await runAzCliAsync(["bicep", "build", "--file", mainBicepPath, "--stdout"], { timeout: 60_000, captureBytes: 16_777_216 });
290
+ const exported = await runAzCliAsync([
291
+ "deployment", "group", "export", "--name", reference.name,
292
+ "--resource-group", reference.resourceGroup, "--subscription", reference.subscription,
293
+ ], { timeout: 60_000, captureBytes: 16_777_216 });
294
+ const parameters = await runAzCliAsync([
295
+ "deployment", "group", "show", "--name", reference.name, "--resource-group", reference.resourceGroup,
296
+ "--subscription", reference.subscription, "--query", "properties.parameters", "-o", "json",
297
+ ], { timeout: 30_000 });
298
+ if ([compiled, exported, parameters].some((result) => result.status !== 0 || result.failureKind || result.stdoutTruncated)) {
299
+ throw new 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.", {
300
+ diagnostics: [compiled, exported, parameters].map((result, index) => ({
301
+ ...azureCliDiagnostic(result, ["local-bicep-build", "recovery-template", "recovery-parameters"][index], secretValues),
302
+ stdout: "[template/parameter payload omitted]", stderr: "[template/parameter payload omitted]", outputTruncated: true,
303
+ })),
304
+ }, "blocked");
305
+ }
306
+ let template: Record<string, unknown>;
307
+ let remoteParameters: Record<string, { type?: string; value?: unknown }>;
308
+ let localParameters: Record<string, { value?: unknown }>;
309
+ try {
310
+ template = JSON.parse(compiled.stdout) as Record<string, unknown>;
311
+ if (canonicalTemplate(template) !== canonicalTemplate(JSON.parse(exported.stdout))) {
312
+ throw new 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");
313
+ }
314
+ remoteParameters = JSON.parse(parameters.stdout) as Record<string, { type?: string; value?: unknown }>;
315
+ localParameters = JSON.parse(fs.readFileSync(parametersPath, "utf-8")).parameters as Record<string, { value?: unknown }>;
316
+ if (!remoteParameters || !localParameters || typeof remoteParameters !== "object" || typeof localParameters !== "object") throw new Error("Invalid parameters");
317
+ } catch (error) {
318
+ if (error instanceof MachineCommandError) throw error;
319
+ throw new MachineCommandError("recovery-invalid-evidence", "Recovery template/parameter evidence is not valid JSON.", undefined, "blocked");
320
+ }
321
+ const declarations = template.parameters as Record<string, { type?: string }> | undefined;
322
+ if (Object.values(declarations ?? {}).some((parameter) => /^secure/i.test(parameter.type ?? ""))) {
323
+ throw new 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");
324
+ }
325
+ const expected = { ...localParameters, location: { value: options.location }, swaLocation: { value: options.swaLocation } };
326
+ for (const [name, parameter] of Object.entries(expected)) {
327
+ if (!parameter || !Object.prototype.hasOwnProperty.call(parameter, "value")
328
+ || canonicalTemplate(parameter.value) !== canonicalTemplate(remoteParameters[name]?.value)) {
329
+ throw new MachineCommandError("recovery-parameters-mismatch", "The deployed parameters differ from the local reviewed parameters/regions. No tag or ledger will be changed.", undefined, "blocked");
330
+ }
331
+ }
332
+ return observation.reference;
333
+ }
334
+
335
+ /**
336
+ * Builds the provision plan. Machine/MCP callers persist it for a later `apply provision --plan`;
337
+ * the interactive `swallowkit provision` command keeps it in memory and executes it after confirmation.
338
+ */
339
+ export async function planProvisionOperation(
340
+ options: PlanProvisionOptions,
341
+ planOptions: { persist?: boolean } = {}
342
+ ): Promise<ProvisionPlanData> {
235
343
  const projectRoot = process.cwd();
236
344
  validateProvisionInputs(options);
237
345
 
@@ -250,9 +358,10 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
250
358
  warnings.push("infra/main.parameters.json not found; deployment will fail until it is created.");
251
359
  }
252
360
 
253
- const azCliAvailable = isAzCliAvailable();
254
- if (!azCliAvailable) {
255
- warnings.push("az-cli-not-found: Azure CLI is not installed. Install it before applying: https://aka.ms/azure-cli");
361
+ const azCli = checkAzCliAvailability();
362
+ const azCliAvailable = azCli.available;
363
+ if (!azCli.available) {
364
+ warnings.push(azCliUnavailableWarning(azCli));
256
365
  }
257
366
 
258
367
  const mainBicepPath = path.join(projectRoot, "infra", "main.bicep");
@@ -265,6 +374,7 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
265
374
  let whatIfResult: string | undefined;
266
375
  let manifestAfterApply: MigrationManifest | undefined;
267
376
  let baselineDrift: string[] = [];
377
+ let recoveryDeployment: DeploymentReference | undefined;
268
378
 
269
379
  const manifestPath = path.join(projectRoot, MIGRATION_MANIFEST_PATH);
270
380
  const manifest = fs.existsSync(manifestPath) ? loadMigrationManifest(projectRoot) : null;
@@ -281,6 +391,18 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
281
391
  }
282
392
 
283
393
  if (azCliAvailable) {
394
+ options = { ...options, subscription: resolveSubscription(options) };
395
+ const unfinished = readRuns(projectRoot).find((run) =>
396
+ run.outcome !== "succeeded" && run.outcome !== "failed" && run.plan.resourceGroup === options.resourceGroup
397
+ && run.plan.subscription === options.subscription);
398
+ if (unfinished) {
399
+ throw new MachineCommandError(
400
+ "provision-run-pending",
401
+ `Provision run ${unfinished.runId} is unfinished. Inspect or resume that run instead of submitting Bicep again.`,
402
+ { runId: unfinished.runId, outcome: unfinished.outcome },
403
+ "blocked", provisionNextActions(unfinished.runId)
404
+ );
405
+ }
284
406
  const remoteGroup = inspectRemoteGroup(options);
285
407
  if (!manifest) {
286
408
  if (remoteGroup.exists) {
@@ -295,7 +417,32 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
295
417
  }
296
418
 
297
419
  migrationTagKey = getMigrationTagKey(manifest.projectId);
298
- if (!remoteGroup.exists) {
420
+ if (options.resumeDeployment) {
421
+ if (!remoteGroup.exists || manifest.baseline.legacy) {
422
+ throw new MachineCommandError("recovery-baseline-required", "--resume-deployment requires an existing resource group and the original non-legacy baseline manifest.", undefined, "blocked");
423
+ }
424
+ const tagValue = remoteGroup.tags[migrationTagKey];
425
+ remoteState = tagValue ? decodeMigrationState(tagValue) : null;
426
+ if (remoteState && remoteState.version !== 0) {
427
+ throw new MachineCommandError("recovery-baseline-only", "Untracked-deployment recovery supports baseline 0 only. Use the original checkpoint to resume migrations.", undefined, "blocked");
428
+ }
429
+ recoveryDeployment = await verifyExistingBaseline(options, mainBicepPath, parametersPath);
430
+ const baselineFiles = collectLegacyBaselineFiles(projectRoot);
431
+ manifestAfterApply = {
432
+ ...manifest,
433
+ baseline: { ...manifest.baseline, files: baselineFiles, checksum: computeBaselineChecksum(baselineFiles) },
434
+ };
435
+ if (remoteState && remoteState.checksum !== manifestAfterApply.baseline.checksum) {
436
+ throw new MachineCommandError("recovery-checksum-mismatch", "The existing baseline tag does not match this project. Nothing will be overwritten.", undefined, "blocked");
437
+ }
438
+ mode = "recover";
439
+ warnings.push("Recovery only observes the matching existing deployment, then records migration state after Azure Succeeded. It never submits Bicep.");
440
+ commandSpecs = [{
441
+ file: "az",
442
+ args: ["deployment", "group", "show", "--name", recoveryDeployment.name, "--resource-group", options.resourceGroup, ...subscriptionArgs(options.subscription), "--query", DEPLOYMENT_QUERY, "-o", "json"],
443
+ kind: "deployment",
444
+ }, tagCommand(remoteGroup.id!, migrationTagKey, { schemaVersion: 1, version: 0, checksum: manifestAfterApply.baseline.checksum }, remoteState)];
445
+ } else if (!remoteGroup.exists) {
299
446
  mode = "bootstrap";
300
447
  const baselineFiles = collectLegacyBaselineFiles(projectRoot);
301
448
  manifestAfterApply = {
@@ -321,7 +468,7 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
321
468
  commandSpecs = [];
322
469
  if (options.reconcile && !manifest.baseline.legacy) {
323
470
  whatIfResult = runBaseWhatIf(options, mainBicepPath, parametersPath);
324
- warnings.push("Recovering an interrupted bootstrap by reconciling the baseline before recording migration state.");
471
+ warnings.push("Explicit reconcile redeploys the untagged baseline. For an interrupted wait/tag/ledger update use --resume-deployment instead.");
325
472
  mode = "reconcile";
326
473
  const baselineFiles = collectLegacyBaselineFiles(projectRoot);
327
474
  manifestAfterApply = {
@@ -379,12 +526,13 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
379
526
  file: "az",
380
527
  args: [
381
528
  "deployment", "group", "create",
382
- "--name", "swallowkit-migration",
529
+ "--name", `swallowkit-migration-${migration.version}-${migration.checksum.slice(0, 12)}`,
383
530
  "--resource-group", options.resourceGroup,
384
531
  "--template-file", path.join(projectRoot, migration.template),
385
532
  ...(migration.parameters ? ["--parameters", path.join(projectRoot, migration.parameters)] : []),
386
533
  "--parameters", `projectName=${projectName}`, `location=${options.location}`,
387
534
  ...subscriptionArgs(options.subscription),
535
+ "--no-wait", "--query", DEPLOYMENT_QUERY, "-o", "json",
388
536
  ],
389
537
  kind: "migration",
390
538
  expectedState,
@@ -395,8 +543,14 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
395
543
  }
396
544
  }
397
545
  }
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");
546
+ } else if (options.whatIf || options.adoptExisting || options.reconcile || options.resumeDeployment) {
547
+ const detail = azCli.available ? "" : ` ${azCliUnavailableWarning(azCli)}`;
548
+ throw new MachineCommandError(
549
+ !azCli.available && azCli.reason === "not-runnable" ? "az-cli-not-runnable" : "az-cli-not-found",
550
+ `Azure CLI is required for --what-if, --adopt-existing, --reconcile, and --resume-deployment.${detail}`,
551
+ undefined,
552
+ "blocked"
553
+ );
400
554
  }
401
555
 
402
556
  const fingerprints: Record<string, string | null> = {
@@ -428,6 +582,8 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
428
582
  mode,
429
583
  remoteState,
430
584
  pendingMigrations: pendingMigrations.map((migration) => ({ version: migration.version, checksum: migration.checksum })),
585
+ recoveryDeployment,
586
+ nonce: crypto.randomUUID(),
431
587
  baselineSnapshotChecksum,
432
588
  fingerprints,
433
589
  })
@@ -458,73 +614,233 @@ export async function planProvisionOperation(options: PlanProvisionOptions): Pro
458
614
  requiresApproval: true,
459
615
  fingerprints,
460
616
  ...(baselineSnapshotChecksum ? { baselineSnapshotChecksum } : {}),
617
+ ...(recoveryDeployment ? { recoveryDeployment } : {}),
461
618
  };
462
619
 
463
- savePlanState(plan, projectRoot);
620
+ if (planOptions.persist !== false) savePlanState(plan, projectRoot);
464
621
  return plan;
465
622
  }
466
623
 
467
624
  export interface ApplyProvisionOptions {
468
625
  planId: string;
469
626
  approve?: boolean;
627
+ waitSeconds?: number;
628
+ /** Human CLI: keep polling until Azure reaches a terminal state instead of using the bounded wait budget. */
629
+ waitUntilComplete?: boolean;
470
630
  }
471
631
 
472
632
  export interface ApplyProvisionResult {
633
+ status: MachineOperationStatus;
634
+ outcome: "succeeded" | "in-progress" | "unknown" | "partial" | "failed";
635
+ runId: string;
473
636
  planId: string;
474
637
  resourceGroup: string;
475
638
  location: string;
476
639
  swaLocation: string;
477
640
  executedCommands: string[];
478
- deploymentOutputs?: Record<string, unknown>;
479
641
  warnings: string[];
642
+ diagnostics: AzureCliDiagnostic[];
643
+ deployment?: DeploymentObservation;
644
+ nextActions: MachineNextAction[];
645
+ interrupted?: NodeJS.Signals;
646
+ }
647
+
648
+ export interface ProvisionRun {
649
+ schemaVersion: 1;
650
+ runId: string;
651
+ projectRoot: string;
652
+ plan: ProvisionPlanData;
653
+ cursor: number;
654
+ attemptedIndex?: number;
655
+ deployment?: DeploymentReference;
656
+ observation?: DeploymentObservation;
657
+ outcome: ApplyProvisionResult["outcome"];
658
+ executedCommands: string[];
659
+ warnings: string[];
660
+ diagnostics: AzureCliDiagnostic[];
661
+ updatedAt: string;
480
662
  }
481
663
 
482
664
  function statesEqual(left: MigrationState | null | undefined, right: MigrationState | null | undefined): boolean {
483
665
  return left?.version === right?.version && left?.checksum === right?.checksum;
484
666
  }
485
667
 
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 };
668
+ const MACHINE_RETRY_HINT = "Re-run plan provision.";
669
+ export const INTERACTIVE_PROVISION_RETRY_HINT = 'Run "swallowkit provision" again to review the current state.';
670
+
671
+ export function provisionNextActions(runId: string): MachineNextAction[] {
672
+ return [
673
+ { command: `swallowkit machine inspect provision --plan ${runId}`, description: "Read the original Azure deployment state without creating, cancelling, deleting, tagging, or updating the ledger." },
674
+ { 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." },
675
+ ];
676
+ }
677
+
678
+ function resultForRun(run: ProvisionRun, outcome = run.outcome, interrupted?: NodeJS.Signals): ApplyProvisionResult {
679
+ run.warnings = run.warnings.slice(-20);
680
+ run.diagnostics = run.diagnostics.slice(-20);
681
+ return {
682
+ status: outcome === "succeeded" ? "complete" : outcome === "in-progress" ? "in-progress" : outcome === "failed" ? "failed" : "blocked",
683
+ outcome, runId: run.runId, planId: run.plan.planId,
684
+ resourceGroup: run.plan.resourceGroup, location: run.plan.location, swaLocation: run.plan.swaLocation,
685
+ executedCommands: run.executedCommands, warnings: run.warnings, diagnostics: run.diagnostics,
686
+ ...(run.observation ? { deployment: run.observation } : {}),
687
+ nextActions: outcome === "succeeded"
688
+ ? [{ command: "swallowkit machine inspect infra", description: "Inspect local infrastructure assets after deployment and migration bookkeeping completed." }]
689
+ : provisionNextActions(run.runId),
690
+ ...(interrupted ? { interrupted } : {}),
691
+ };
692
+ }
693
+
694
+ function readRun(runId: string, projectRoot: string): ProvisionRun | null {
695
+ let run: ProvisionRun | null;
696
+ try { run = loadProvisionRun<ProvisionRun>(runId, projectRoot); } catch {
697
+ throw new MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint cannot be read. Do not re-submit Bicep; inspect the original deployment first.", { runId }, "blocked");
698
+ }
699
+ if (run && (run.schemaVersion !== 1 || run.runId !== runId || run.plan?.planId !== runId
700
+ || run.projectRoot !== path.resolve(projectRoot) || !Number.isInteger(run.cursor)
701
+ || !Array.isArray(run.plan.commandSpecs) || !run.plan.fingerprints
702
+ || !Array.isArray(run.warnings) || !Array.isArray(run.diagnostics) || !Array.isArray(run.executedCommands)
703
+ || !["succeeded", "in-progress", "unknown", "partial", "failed"].includes(run.outcome)
704
+ || run.cursor < 0 || run.cursor > run.plan.commandSpecs.length)) {
705
+ throw new MachineCommandError("invalid-provision-checkpoint", "The execution checkpoint is invalid or belongs to another checkout. Nothing will be re-submitted.", { runId }, "blocked");
706
+ }
707
+ return run;
708
+ }
709
+
710
+ function readRuns(projectRoot: string): ProvisionRun[] {
711
+ try {
712
+ return listProvisionRuns<ProvisionRun>(projectRoot).map(record => {
713
+ if (!record || typeof record.runId !== "string") throw new Error("Invalid checkpoint identity.");
714
+ const run = readRun(record.runId, projectRoot);
715
+ if (!run) throw new Error("Missing execution checkpoint.");
716
+ return run;
717
+ });
718
+ } catch (error) {
719
+ if (error instanceof MachineCommandError) throw error;
720
+ throw new MachineCommandError("invalid-provision-checkpoint", "An execution checkpoint is corrupt. Preserve it and inspect the original deployment; nothing will be re-submitted.", undefined, "blocked");
721
+ }
722
+ }
723
+
724
+ function validateWaitSeconds(value = DEFAULT_PROVISION_WAIT_SECONDS): number {
725
+ if (!Number.isInteger(value) || value < 0 || value > MAX_PROVISION_WAIT_SECONDS) {
726
+ throw new MachineCommandError("invalid-arguments", `--wait-seconds must be an integer from 0 to ${MAX_PROVISION_WAIT_SECONDS}.`, undefined, "blocked");
727
+ }
728
+ return value;
729
+ }
730
+
731
+ function assertLocalFiles(plan: ProvisionPlanData, projectRoot: string, retryHint: string, allowRecordedManifest = false): void {
732
+ const changedFiles = Object.entries(plan.fingerprints).filter(([relativePath, expectedHash]) => {
733
+ const file = path.join(projectRoot, relativePath);
734
+ if (allowRecordedManifest && relativePath === MIGRATION_MANIFEST_PATH && plan.manifestAfterApply && fs.existsSync(file)
735
+ && fs.readFileSync(file, "utf-8") === `${JSON.stringify(plan.manifestAfterApply, null, 2)}\n`) return false;
736
+ return hashFileIfExists(file) !== expectedHash;
737
+ }).map(([file]) => file);
738
+ if (changedFiles.length || (plan.baselineSnapshotChecksum
739
+ && computeBaselineChecksum(collectLegacyBaselineFiles(projectRoot)) !== plan.baselineSnapshotChecksum)) {
740
+ throw new MachineCommandError("stale-plan", `${changedFiles.join(", ") || "Baseline infrastructure files"} changed after the preview was computed. ${retryHint}`, { planId: plan.planId, changedFiles }, "blocked");
741
+ }
742
+ }
743
+
744
+ async function readMigrationState(plan: ProvisionPlanData, signal?: AbortSignal, secretValues: readonly string[] = []): Promise<{ group: RemoteGroupState; state: MigrationState | null }> {
745
+ const exists = await runAzCliAsync(["group", "exists", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription)], { timeout: 30_000, signal });
746
+ if (exists.status !== 0 || exists.failureKind || !/^(true|false)$/.test(exists.stdout.trim())) {
747
+ throw azQueryError("azure-state-query-failed", "Could not query resource group", exists, secretValues);
748
+ }
749
+ if (exists.stdout.trim() === "false") return { group: { exists: false, tags: {} }, state: null };
750
+ const show = await runAzCliAsync(["group", "show", "--name", plan.resourceGroup, ...subscriptionArgs(plan.subscription), "--query", "{id:id,tags:tags}", "-o", "json"], { timeout: 30_000, signal });
751
+ if (show.status !== 0 || show.failureKind || show.stdoutTruncated) throw azQueryError("azure-state-query-failed", "Could not read resource group tags", show, secretValues);
752
+ let parsed: { id?: unknown; tags?: Record<string, string> };
753
+ try { parsed = JSON.parse(show.stdout); } catch { throw azQueryError("azure-state-query-failed", "Invalid resource group response", show, secretValues); }
754
+ const expectedId = `/subscriptions/${plan.subscription}/resourceGroups/${plan.resourceGroup}`;
755
+ if (typeof parsed.id !== "string" || parsed.id.toLowerCase() !== expectedId.toLowerCase()) {
756
+ throw new MachineCommandError("azure-resource-group-mismatch", "Azure returned a different resource group/subscription. Nothing will be updated.", undefined, "blocked");
757
+ }
758
+ const group = { exists: true, id: parsed.id, tags: parsed.tags ?? {} };
759
+ const tag = plan.migrationTagKey ? group.tags[plan.migrationTagKey] : undefined;
760
+ return { group, state: tag ? decodeMigrationState(tag) : null };
761
+ }
762
+
763
+ function desiredTagState(command: ProvisionCommand, key?: string): MigrationState {
764
+ const value = command.args.find((arg) => arg.startsWith(`${key}=`) || arg.startsWith(`tags.${key}=`));
765
+ if (!value) throw new MachineCommandError("incompatible-plan", "The reviewed migration tag is missing.", undefined, "blocked");
766
+ return decodeMigrationState(value.slice(value.indexOf("=") + 1));
491
767
  }
492
768
 
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");
769
+ function provisionSecretValues(plan: Pick<ProvisionPlanData, "fingerprints">, projectRoot: string): string[] {
770
+ const files = Object.keys(plan.fingerprints);
771
+ const secureBicep = files.filter(file => file.endsWith(".bicep")).some(file => /@secure\s*\(/.test(fs.readFileSync(path.join(projectRoot, file), "utf-8")));
772
+ const values: string[] = [];
773
+ const collect = (value: unknown): void => {
774
+ if (typeof value === "string" && value) values.push(value);
775
+ else if (Array.isArray(value)) value.forEach(collect);
776
+ else if (value && typeof value === "object") Object.values(value).forEach(collect);
777
+ };
778
+ for (const file of files.filter(file => file.endsWith(".json") && file !== MIGRATION_MANIFEST_PATH)) {
779
+ let parsed: { parameters?: Record<string, { value?: unknown }> };
780
+ try { parsed = JSON.parse(fs.readFileSync(path.join(projectRoot, file), "utf-8")); } catch {
781
+ throw new MachineCommandError("invalid-infra-parameters", "Infrastructure JSON is invalid. Correct it before provision; unstructured Azure output cannot safely redact unknown parameter secrets.", undefined, "blocked");
782
+ }
783
+ for (const [name, parameter] of Object.entries(parsed.parameters ?? {})) {
784
+ if (secureBicep || /secret|password|token|key|connection.?string/i.test(name)) collect(parameter?.value);
499
785
  }
500
- return;
501
786
  }
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
- );
787
+ return [...new Set(values)].sort((left, right) => right.length - left.length);
788
+ }
789
+
790
+ function rememberDiagnostic(run: ProvisionRun, result: AzCliResult, phase: string, secretValues: readonly string[]): void {
791
+ run.diagnostics.push(azureCliDiagnostic(result, phase, secretValues));
792
+ run.diagnostics = run.diagnostics.slice(-20);
793
+ if (result.stderr.trim()) {
794
+ run.warnings.push(result.stderrTruncated ? "Azure CLI stderr exceeded the capture limit; output omitted." : redactAzureText(result.stderr, secretValues).slice(0, 4_000));
795
+ run.warnings = run.warnings.slice(-20);
509
796
  }
510
797
  }
511
798
 
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
- );
799
+ export async function inspectProvisionOperation(options: { planId: string; waitSeconds?: number }): Promise<ApplyProvisionResult> {
800
+ const run = readRun(options.planId, process.cwd());
801
+ if (!run) throw new 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");
802
+ const waitSeconds = validateWaitSeconds(options.waitSeconds ?? 0);
803
+ if (run.deployment) {
804
+ let secretValues: string[] = [];
805
+ let omitOutput = false;
806
+ try {
807
+ assertLocalFiles(run.plan, process.cwd(), "Reviewed files no longer match.", true);
808
+ secretValues = provisionSecretValues(run.plan, process.cwd());
809
+ } catch {
810
+ omitOutput = true;
811
+ 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.");
812
+ }
813
+ run.observation = await waitForAzureDeployment(run.deployment, { deadline: Date.now() + waitSeconds * 1_000, secretValues });
814
+ if (omitOutput) {
815
+ run.observation.errors = [];
816
+ run.observation.diagnostic = { ...run.observation.diagnostic, stdout: "[output omitted: reviewed parameters unavailable]", stderr: "", outputTruncated: true };
817
+ }
818
+ run.diagnostics = [...run.diagnostics, run.observation.diagnostic].slice(-20);
819
+ let outcome: ApplyProvisionResult["outcome"] = run.observation.state === "Failed" || run.observation.state === "Canceled" ? "failed"
820
+ : run.observation.state === "Succeeded" ? run.outcome === "succeeded" ? "succeeded" : "partial"
821
+ : run.observation.state === "unknown" ? "unknown" : "in-progress";
822
+ if (outcome === "succeeded") {
823
+ try {
824
+ assertLocalFiles(run.plan, process.cwd(), "The original reviewed files no longer match.", true);
825
+ const tag = [...run.plan.commandSpecs].reverse().find(command => command.kind === "tag");
826
+ if (tag && !statesEqual((await readMigrationState(run.plan, undefined, secretValues)).state, desiredTagState(tag, run.plan.migrationTagKey))) {
827
+ outcome = "partial";
828
+ run.warnings.push("Azure Succeeded, but the completed run's migration tag no longer matches. No bookkeeping or deployment write was performed.");
829
+ }
830
+ } catch {
831
+ outcome = "partial";
832
+ run.warnings.push("Azure Succeeded, but current local files or migration bookkeeping could not be verified. No writes were performed.");
833
+ }
834
+ }
835
+ return resultForRun(run, outcome);
521
836
  }
837
+ return resultForRun(run);
522
838
  }
523
839
 
524
840
  export async function applyProvisionOperation(options: ApplyProvisionOptions): Promise<ApplyProvisionResult> {
525
841
  const projectRoot = process.cwd();
526
-
527
- const plan = loadPlanState<ProvisionPlanData>(options.planId, projectRoot);
842
+ const run = /^[a-f0-9]{12}$/.test(options.planId) ? readRun(options.planId, projectRoot) : null;
843
+ const plan = run?.plan ?? loadPlanState<ProvisionPlanData>(options.planId, projectRoot);
528
844
  if (!plan) {
529
845
  throw new MachineCommandError(
530
846
  "plan-not-found",
@@ -534,111 +850,303 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
534
850
  );
535
851
  }
536
852
 
537
- // Bicep が plan 後に変更されていないか確認
538
- const changedFiles: string[] = [];
539
- for (const [relativePath, expectedHash] of Object.entries(plan.fingerprints)) {
540
- const currentHash = hashFileIfExists(path.join(projectRoot, relativePath));
541
- if (currentHash !== expectedHash) changedFiles.push(relativePath);
542
- }
543
- if (changedFiles.length > 0) {
544
- throw new MachineCommandError(
545
- "stale-plan",
546
- `Plan "${options.planId}" is stale: ${changedFiles.join(", ")} changed after the plan was created. Re-run "plan provision".`,
547
- { planId: options.planId, changedFiles },
548
- "blocked"
549
- );
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
- );
853
+ const result = await executeProvisionPlan(plan, {
854
+ approve: options.approve, waitSeconds: options.waitSeconds, waitUntilComplete: options.waitUntilComplete,
855
+ retryHint: MACHINE_RETRY_HINT, projectRoot,
856
+ });
857
+ if (result.status === "complete") {
858
+ try { deletePlanState(plan.planId, projectRoot); } catch {
859
+ result.warnings.push("Azure and migration bookkeeping completed, but the old preview could not be removed. The completed execution checkpoint prevents replay.");
560
860
  }
561
861
  }
862
+ return result;
863
+ }
562
864
 
865
+ /**
866
+ * Runs an approved provision plan with every safety check: local files and Azure state must still
867
+ * match the reviewed preview, and each migration/tag step re-checks Azure state before it runs.
868
+ */
869
+ export async function executeProvisionPlan(
870
+ plan: ProvisionPlanData,
871
+ options: {
872
+ approve?: boolean; retryHint?: string; projectRoot?: string; waitSeconds?: number;
873
+ /** Human CLI only: no overall deadline; each az call stays bounded and the checkpoint stays resumable. */
874
+ waitUntilComplete?: boolean;
875
+ signal?: AbortSignal; pollIntervalMs?: number;
876
+ } = {}
877
+ ): Promise<ApplyProvisionResult> {
878
+ const projectRoot = options.projectRoot ?? process.cwd();
879
+ const retryHint = options.retryHint ?? MACHINE_RETRY_HINT;
880
+ const waitUntilComplete = options.waitUntilComplete === true && options.waitSeconds === undefined;
881
+ const waitSeconds = waitUntilComplete ? 0 : validateWaitSeconds(options.waitSeconds);
563
882
  if (!options.approve) {
564
883
  throw new MachineCommandError(
565
884
  "approval-required",
566
885
  "Provisioning creates billable Azure resources and always requires human approval. Review plan.commands and re-run with --approve.",
567
886
  { planId: plan.planId, commands: plan.commands },
568
- "requires-human"
887
+ "requires-human",
888
+ [{ command: `swallowkit machine apply provision --plan ${plan.planId} --approve`, description: "Review the commands and obtain explicit human consent before applying or resuming." }]
569
889
  );
570
890
  }
571
891
  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");
892
+ throw new MachineCommandError("incompatible-plan", `This provision plan was created by an older SwallowKit version. ${retryHint}`, undefined, "blocked");
573
893
  }
574
894
  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");
895
+ throw new MachineCommandError(
896
+ "online-plan-required",
897
+ `Azure state was not read because Azure CLI was unavailable, so nothing can be applied. Make az available and signed in (az login). ${retryHint}`,
898
+ { planId: plan.planId, warnings: plan.warnings },
899
+ "blocked"
900
+ );
576
901
  }
577
902
 
578
903
  if (plan.mode === "adoption-required") {
579
904
  throw new MachineCommandError(
580
905
  "adoption-required",
581
- 'This existing resource group has no SwallowKit migration state. Re-plan with --adopt-existing --baseline 0 --what-if.',
906
+ '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.',
582
907
  { planId: plan.planId },
583
908
  "requires-human"
584
909
  );
585
910
  }
586
-
587
- if (!isAzCliAvailable()) {
588
- throw new MachineCommandError(
589
- "az-cli-not-found",
590
- "Azure CLI is not installed. Install it first: https://aka.ms/azure-cli",
591
- undefined,
592
- "blocked"
593
- );
911
+ let run = readRun(plan.planId, projectRoot);
912
+ const resuming = run !== null;
913
+ if (run) plan = run.plan;
914
+ try {
915
+ assertLocalFiles(plan, projectRoot, run ? "Restore the original reviewed files before resuming this checkpoint; do not resubmit Bicep." : retryHint, run?.cursor === plan.commandSpecs.length);
916
+ } catch (error) {
917
+ if (run && error instanceof MachineCommandError) {
918
+ throw new MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status, provisionNextActions(run.runId));
919
+ }
920
+ throw error;
921
+ }
922
+ const secretValues = provisionSecretValues(plan, projectRoot);
923
+ if (!plan.subscription || plan.commandSpecs.some((command) => (command.kind === "deployment" || command.kind === "migration")
924
+ && !plan.recoveryDeployment && !command.args.includes("--no-wait"))) {
925
+ throw new 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");
926
+ }
927
+ const azCli = checkAzCliAvailability();
928
+ if (!azCli.available) {
929
+ throw new MachineCommandError(azCli.reason === "not-found" ? "az-cli-not-found" : "az-cli-not-runnable", azCliUnavailableWarning(azCli), undefined, "blocked");
594
930
  }
595
931
 
596
- const executedCommands: string[] = [];
597
- const warnings = [...plan.warnings];
598
- let deploymentOutputs: Record<string, unknown> | undefined;
599
-
600
- assertPlanRemoteState(plan, plan.remoteState);
601
-
602
- for (const command of plan.commandSpecs) {
603
- if (command.kind === "migration") {
604
- assertPlanRemoteState(plan, command.expectedState);
932
+ const targetKey = crypto.createHash("sha256").update(`${plan.subscription}/${plan.resourceGroup.toLowerCase()}`).digest("hex");
933
+ let unlock: () => void;
934
+ try { unlock = lockProvisionTarget(targetKey, projectRoot); } catch (error) {
935
+ throw new MachineCommandError("provision-target-locked", error instanceof Error ? error.message : "Provision target is locked.", { runId: plan.planId }, "blocked", provisionNextActions(plan.planId));
936
+ }
937
+ const controller = new AbortController();
938
+ let interrupted: NodeJS.Signals | undefined;
939
+ const interrupt = (signal: NodeJS.Signals) => {
940
+ interrupted = signal;
941
+ controller.abort();
942
+ };
943
+ const sigint = () => interrupt("SIGINT");
944
+ const sigterm = () => interrupt("SIGTERM");
945
+ const externalAbort = () => controller.abort();
946
+ process.on("SIGINT", sigint);
947
+ process.on("SIGTERM", sigterm);
948
+ options.signal?.addEventListener("abort", externalAbort, { once: true });
949
+ if (options.signal?.aborted) controller.abort();
950
+ const signal = controller.signal;
951
+ const deadline = waitUntilComplete ? Number.POSITIVE_INFINITY : Date.now() + waitSeconds * 1_000;
952
+ const persist = () => {
953
+ if (!run) return;
954
+ run.warnings = run.warnings.slice(-20);
955
+ run.diagnostics = run.diagnostics.slice(-20);
956
+ run.updatedAt = new Date().toISOString();
957
+ saveProvisionRun(run, projectRoot);
958
+ };
959
+ const pause = (outcome: ApplyProvisionResult["outcome"], warning: string) => {
960
+ run!.outcome = outcome;
961
+ run!.warnings.push(warning);
962
+ persist();
963
+ return resultForRun(run!, outcome, interrupted);
964
+ };
965
+ const observe = async () => {
966
+ const observation = await waitForAzureDeployment(run!.deployment!, {
967
+ deadline: signal.aborted ? Date.now() : deadline,
968
+ // A final short read after Ctrl+C must not inherit the aborted wait signal.
969
+ ...(signal.aborted ? {} : { signal }),
970
+ ...(signal.aborted ? { queryTimeoutMs: 5_000 } : {}),
971
+ pollIntervalMs: options.pollIntervalMs,
972
+ ...(waitUntilComplete ? { transientRetries: UNBOUNDED_WAIT_TRANSIENT_RETRIES } : {}),
973
+ secretValues,
974
+ onObservation: (value) => {
975
+ run!.observation = value;
976
+ if (value.state !== "unknown") run!.deployment = value.reference;
977
+ persist();
978
+ },
979
+ });
980
+ run!.observation = observation;
981
+ run!.diagnostics.push(observation.diagnostic);
982
+ run!.diagnostics = run!.diagnostics.slice(-20);
983
+ return observation;
984
+ };
985
+ try {
986
+ if (!run) {
987
+ const unfinished = readRuns(projectRoot).find((other) =>
988
+ other.outcome !== "succeeded" && other.outcome !== "failed"
989
+ && other.plan.resourceGroup === plan.resourceGroup && other.plan.subscription === plan.subscription);
990
+ if (unfinished) throw new MachineCommandError("provision-run-pending", "Resume the existing execution checkpoint instead of creating another deployment.", { runId: unfinished.runId }, "blocked", provisionNextActions(unfinished.runId));
991
+ const current = await readMigrationState(plan, signal, secretValues);
992
+ if (plan.mode === "bootstrap" ? current.group.exists : !current.group.exists || !statesEqual(current.state, plan.remoteState)) {
993
+ throw new MachineCommandError("stale-azure-state", plan.mode === "bootstrap"
994
+ ? `The resource group was created after the preview was computed. ${retryHint}`
995
+ : `Azure migration state changed after the preview was computed. ${retryHint}`, { expected: plan.remoteState, actual: current.state }, "blocked");
996
+ }
997
+ const checkpointPlan = { ...plan };
998
+ delete checkpointPlan.whatIfResult;
999
+ run = {
1000
+ schemaVersion: 1, runId: plan.planId, projectRoot: path.resolve(projectRoot), plan: checkpointPlan,
1001
+ cursor: 0, outcome: "in-progress", executedCommands: [], warnings: [...plan.warnings], diagnostics: [],
1002
+ updatedAt: new Date().toISOString(),
1003
+ };
1004
+ persist();
605
1005
  }
606
- if (command.kind === "tag") {
607
- assertTagExpectedState(plan, command.expectedState);
1006
+ for (; run.cursor < plan.commandSpecs.length;) {
1007
+ if (signal.aborted) 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.");
1008
+ assertLocalFiles(plan, projectRoot, "Restore the original reviewed files before resuming; do not resubmit Bicep.");
1009
+ const command = plan.commandSpecs[run.cursor];
1010
+ if (command.kind === "deployment" || command.kind === "migration") {
1011
+ if (command.kind === "migration" && run.attemptedIndex !== run.cursor && run.observation?.state === "Succeeded" && Date.now() >= deadline) {
1012
+ 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.");
1013
+ }
1014
+ if (run.attemptedIndex !== run.cursor) {
1015
+ let observationOnly = Boolean(plan.recoveryDeployment);
1016
+ if (plan.recoveryDeployment) {
1017
+ run.deployment = plan.recoveryDeployment;
1018
+ } else {
1019
+ const current = await readMigrationState(plan, signal, secretValues);
1020
+ if (!current.group.exists || !statesEqual(current.state, command.expectedState ?? plan.remoteState)) {
1021
+ throw new MachineCommandError("stale-azure-state", `Azure migration state changed before deployment. ${retryHint}`, { expected: command.expectedState ?? plan.remoteState, actual: current.state }, "blocked");
1022
+ }
1023
+ const name = command.args[command.args.indexOf("--name") + 1];
1024
+ const reference: DeploymentReference = { name, resourceGroup: plan.resourceGroup, subscription: plan.subscription! };
1025
+ const previous = await queryAzureDeployment(reference, { signal, secretValues });
1026
+ if (previous.state === "unknown" && previous.reason !== "not-found") {
1027
+ run.deployment = reference;
1028
+ run.observation = previous;
1029
+ return pause("unknown", "The deployment name could not be checked before submission. No create request was sent; inspect the original deployment before proceeding.");
1030
+ }
1031
+ if (previous.state !== "unknown" && (plan.mode !== "reconcile" || previous.state === "Running" || previous.state === "Accepted")) {
1032
+ run.deployment = previous.reference;
1033
+ run.observation = previous;
1034
+ if (!resuming || command.kind !== "deployment") {
1035
+ 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.");
1036
+ }
1037
+ run.deployment = await verifyExistingBaseline({ ...plan, resumeDeployment: name },
1038
+ path.join(projectRoot, "infra", "main.bicep"), path.join(projectRoot, "infra", "main.parameters.json"));
1039
+ observationOnly = true;
1040
+ } else {
1041
+ run.deployment = {
1042
+ ...reference,
1043
+ ...(previous.state !== "unknown" ? { previousCorrelationId: previous.reference.correlationId } : {}),
1044
+ };
1045
+ }
1046
+ }
1047
+ // Persist intent before spawn: even a hard process termination must never replay this create.
1048
+ run.attemptedIndex = run.cursor;
1049
+ persist();
1050
+ if (!observationOnly) {
1051
+ const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
1052
+ run.executedCommands.push(displayCommand(command));
1053
+ rememberDiagnostic(run, result, command.kind === "migration" ? "migration-submit" : "baseline-submit", secretValues);
1054
+ persist();
1055
+ }
1056
+ }
1057
+ const observation = await observe();
1058
+ if (signal.aborted) 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.");
1059
+ if (observation.state === "Failed" || observation.state === "Canceled") {
1060
+ const operations = await queryDeploymentOperationErrors(observation.reference, secretValues, signal);
1061
+ observation.errors.push(...operations.errors);
1062
+ run.diagnostics.push(operations.diagnostic);
1063
+ run.outcome = "failed";
1064
+ persist();
1065
+ throw new MachineCommandError("azure-deployment-failed",
1066
+ `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."}`,
1067
+ resultForRun(run), "failed", provisionNextActions(run.runId));
1068
+ }
1069
+ if (observation.state !== "Succeeded") {
1070
+ return pause(observation.state === "unknown" ? "unknown" : "in-progress",
1071
+ 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.`
1072
+ : `Azure deployment is ${observation.state}. The bounded local wait ended; status/resume tracks the same deployment without submitting Bicep again.`);
1073
+ }
1074
+ } else if (command.kind === "tag") {
1075
+ if (run.deployment) {
1076
+ const observation = await queryAzureDeployment(run.deployment, { signal, secretValues });
1077
+ run.observation = observation;
1078
+ if (observation.state !== "Succeeded") 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.");
1079
+ }
1080
+ const current = await readMigrationState(plan, signal, secretValues);
1081
+ const desired = desiredTagState(command, plan.migrationTagKey);
1082
+ if (!current.group.exists) throw new MachineCommandError("stale-azure-state", "The resource group disappeared before tagging.", undefined, "blocked");
1083
+ if (!statesEqual(current.state, desired)) {
1084
+ if (!statesEqual(current.state, command.expectedState)) {
1085
+ throw new MachineCommandError("stale-azure-state", `Azure migration state changed before its tag could be updated. ${retryHint}`, { expected: command.expectedState, actual: current.state }, "blocked");
1086
+ }
1087
+ run.attemptedIndex = run.cursor;
1088
+ persist();
1089
+ const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
1090
+ run.executedCommands.push(displayCommand(command));
1091
+ rememberDiagnostic(run, result, "migration-tag", secretValues);
1092
+ if (result.status !== 0 || result.failureKind || signal.aborted) {
1093
+ 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.");
1094
+ }
1095
+ const after = await readMigrationState(plan, signal, secretValues);
1096
+ if (!statesEqual(after.state, desired)) return pause("partial", "The tag update returned successfully but its version/checksum could not be confirmed. Resume bookkeeping without redeploying Bicep.");
1097
+ }
1098
+ } else {
1099
+ const current = await readMigrationState(plan, signal, secretValues);
1100
+ if (run.attemptedIndex === run.cursor) {
1101
+ if (!current.group.exists) return pause("unknown", "Resource group creation was attempted, but its result is unknown. Inspect the target before resuming; nothing will be re-created automatically.");
1102
+ } else {
1103
+ if (current.group.exists) throw new MachineCommandError("stale-azure-state", `The resource group was created after the preview was computed. ${retryHint}`, undefined, "blocked");
1104
+ run.attemptedIndex = run.cursor;
1105
+ persist();
1106
+ const result = await runAzCliAsync(command.args, { timeout: 60_000, signal });
1107
+ run.executedCommands.push(displayCommand(command));
1108
+ rememberDiagnostic(run, result, "resource-group-create", secretValues);
1109
+ if (result.status !== 0 || result.failureKind || signal.aborted) return pause("unknown", "Local resource-group creation did not complete. Azure may have accepted it; inspect/resume this checkpoint, not a new bootstrap.");
1110
+ }
1111
+ }
1112
+ run.cursor++;
1113
+ delete run.attemptedIndex;
1114
+ persist();
608
1115
  }
609
- const result = runAz(command.args);
610
- const commandText = displayCommand(command);
611
- executedCommands.push(commandText);
612
- if (result.status !== 0) {
613
- throw new MachineCommandError(
614
- "provision-failed",
615
- `Command failed: ${commandText}\n${(result.stderr || result.stdout).trim().slice(0, 4000)}`,
616
- { executedCommands },
617
- "failed"
618
- );
1116
+ if (signal.aborted) return pause("partial", "Local provisioning was interrupted before ledger completion. Resume bookkeeping; no deployment will be submitted again.");
1117
+ assertLocalFiles(plan, projectRoot, "Restore the reviewed files before completing the local ledger.", true);
1118
+ const finalTag = [...plan.commandSpecs].reverse().find((command) => command.kind === "tag");
1119
+ if (finalTag) {
1120
+ const current = await readMigrationState(plan, signal, secretValues);
1121
+ if (!statesEqual(current.state, desiredTagState(finalTag, plan.migrationTagKey))) return pause("partial", "The final remote migration version/checksum changed before local ledger completion. No ledger success was recorded.");
619
1122
  }
620
- if (command.kind === "deployment" || command.kind === "migration") {
621
- try {
622
- const deployment = JSON.parse(result.stdout || "{}");
623
- deploymentOutputs = deployment.properties?.outputs;
624
- } catch {
625
- warnings.push("Could not parse deployment output JSON.");
626
- }
1123
+ if (run.deployment) {
1124
+ run.observation = await queryAzureDeployment(run.deployment, { signal, secretValues });
1125
+ if (run.observation.state !== "Succeeded") return pause("partial", "The original deployment identity/terminal state could not be confirmed before local ledger completion. No successful ledger update was performed.");
1126
+ }
1127
+ if (plan.manifestAfterApply && run.outcome !== "succeeded") replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
1128
+ run.outcome = "succeeded";
1129
+ persist();
1130
+ return resultForRun(run);
1131
+ } catch (error) {
1132
+ if (!run) throw error;
1133
+ if (error instanceof MachineCommandError && error.code === "azure-deployment-failed") throw error;
1134
+ run.outcome = run.observation?.state === "Succeeded" ? "partial" : "unknown";
1135
+ run.warnings.push(`Provisioning stopped: ${redactAzureText(error instanceof Error ? error.message : "Unknown local error", secretValues)}. Azure deployment and local bookkeeping are separate; inspect/resume this checkpoint.`);
1136
+ try { persist(); } catch {
1137
+ run.warnings.push("The checkpoint could not be updated. Preserve the existing checkpoint; inspect the original Azure deployment before any further write.");
1138
+ }
1139
+ if (error instanceof MachineCommandError) {
1140
+ throw new MachineCommandError(error.code, error.message, { ...resultForRun(run), cause: error.details }, error.status ?? "blocked", provisionNextActions(run.runId));
1141
+ }
1142
+ return resultForRun(run, run.outcome, interrupted);
1143
+ } finally {
1144
+ process.removeListener("SIGINT", sigint);
1145
+ process.removeListener("SIGTERM", sigterm);
1146
+ options.signal?.removeEventListener("abort", externalAbort);
1147
+ try { unlock(); } catch {
1148
+ run?.warnings.push("The local execution lock could not be removed. Inspect its owner before retrying; Azure deployment/ledger results are unchanged.");
1149
+ console.warn("Provision lock cleanup failed; inspect the checkpoint before another apply.");
627
1150
  }
628
1151
  }
629
-
630
- if (plan.manifestAfterApply) {
631
- replaceMigrationManifest(plan.manifestAfterApply, projectRoot);
632
- }
633
- deletePlanState(plan.planId, projectRoot);
634
-
635
- return {
636
- planId: plan.planId,
637
- resourceGroup: plan.resourceGroup,
638
- location: plan.location,
639
- swaLocation: plan.swaLocation,
640
- executedCommands,
641
- ...(deploymentOutputs ? { deploymentOutputs } : {}),
642
- warnings,
643
- };
644
1152
  }