swallowkit 2.0.0-alpha.5 → 2.0.0-alpha.7

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 (64) hide show
  1. package/dist/cli/commands/provision.d.ts +4 -1
  2. package/dist/cli/commands/provision.d.ts.map +1 -1
  3. package/dist/cli/commands/provision.js +233 -69
  4. package/dist/cli/commands/provision.js.map +1 -1
  5. package/dist/cli/index.d.ts.map +1 -1
  6. package/dist/cli/index.js +14 -1
  7. package/dist/cli/index.js.map +1 -1
  8. package/dist/core/operations/provision-deployment.d.ts +45 -0
  9. package/dist/core/operations/provision-deployment.d.ts.map +1 -0
  10. package/dist/core/operations/provision-deployment.js +151 -0
  11. package/dist/core/operations/provision-deployment.js.map +1 -0
  12. package/dist/core/operations/provision-operations.d.ts +39 -5
  13. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  14. package/dist/core/operations/provision-operations.js +609 -103
  15. package/dist/core/operations/provision-operations.js.map +1 -1
  16. package/dist/core/project/capabilities.d.ts +8 -0
  17. package/dist/core/project/capabilities.d.ts.map +1 -1
  18. package/dist/core/project/capabilities.js +11 -3
  19. package/dist/core/project/capabilities.js.map +1 -1
  20. package/dist/core/project/state.d.ts +7 -0
  21. package/dist/core/project/state.d.ts.map +1 -1
  22. package/dist/core/project/state.js +86 -0
  23. package/dist/core/project/state.js.map +1 -1
  24. package/dist/core/project/workflows.d.ts.map +1 -1
  25. package/dist/core/project/workflows.js +30 -2
  26. package/dist/core/project/workflows.js.map +1 -1
  27. package/dist/machine/index.d.ts.map +1 -1
  28. package/dist/machine/index.js +17 -11
  29. package/dist/machine/index.js.map +1 -1
  30. package/dist/mcp/index.d.ts +2 -2
  31. package/dist/mcp/index.d.ts.map +1 -1
  32. package/dist/mcp/index.js +58 -12
  33. package/dist/mcp/index.js.map +1 -1
  34. package/dist/utils/azure-cli.d.ts +16 -4
  35. package/dist/utils/azure-cli.d.ts.map +1 -1
  36. package/dist/utils/azure-cli.js +166 -4
  37. package/dist/utils/azure-cli.js.map +1 -1
  38. package/dist/utils/azure-diagnostics.d.ts +16 -0
  39. package/dist/utils/azure-diagnostics.d.ts.map +1 -0
  40. package/dist/utils/azure-diagnostics.js +68 -0
  41. package/dist/utils/azure-diagnostics.js.map +1 -0
  42. package/package.json +1 -1
  43. package/src/__tests__/agent-instructions.test.ts +5 -0
  44. package/src/__tests__/agent-loop-phase34.test.ts +19 -8
  45. package/src/__tests__/azure-cli.test.ts +5 -2
  46. package/src/__tests__/fixtures/az wrapper.cmd +2 -0
  47. package/src/__tests__/fixtures/azure-cli.cjs +138 -0
  48. package/src/__tests__/fixtures/provision-runner.cjs +28 -0
  49. package/src/__tests__/mcp.test.ts +56 -0
  50. package/src/__tests__/provision-runtime.test.ts +636 -0
  51. package/src/cli/commands/provision.ts +243 -70
  52. package/src/cli/index.ts +12 -2
  53. package/src/core/operations/provision-deployment.ts +167 -0
  54. package/src/core/operations/provision-operations.ts +597 -145
  55. package/src/core/project/agent-templates/AGENTS.md +22 -0
  56. package/src/core/project/agent-templates/CLAUDE.md +7 -0
  57. package/src/core/project/agent-templates/copilot-instructions.md +8 -0
  58. package/src/core/project/capabilities.ts +19 -3
  59. package/src/core/project/state.ts +70 -0
  60. package/src/core/project/workflows.ts +30 -2
  61. package/src/machine/index.ts +20 -14
  62. package/src/mcp/index.ts +50 -15
  63. package/src/utils/azure-cli.ts +167 -6
  64. package/src/utils/azure-diagnostics.ts +75 -0
@@ -1,12 +1,99 @@
1
- import { Command } from "commander";
1
+ import { Command, Option } from "commander";
2
2
  import prompts from "prompts";
3
3
  import { ensureSwallowKitProject } from "../../core/config";
4
4
  import {
5
5
  INTERACTIVE_PROVISION_RETRY_HINT,
6
+ ApplyProvisionResult,
6
7
  ProvisionPlanData,
8
+ applyProvisionOperation,
7
9
  executeProvisionPlan,
10
+ inspectProvisionOperation,
8
11
  planProvisionOperation,
9
12
  } from "../../core/operations/provision-operations";
13
+ import { MachineCommandError } from "../../machine/errors";
14
+ import { formatAzureDiagnostic } from "../../utils/azure-diagnostics";
15
+ import { runAzCliAsync } from "../../utils/azure-cli";
16
+
17
+ function humanProvisionMessage(message: string): string {
18
+ return message
19
+ .replace(/swallowkit machine inspect provision --plan/g, "swallowkit provision --status")
20
+ .replace(/swallowkit machine apply provision --plan/g, "swallowkit provision --resume")
21
+ .replace(/plan provision/g, "swallowkit provision")
22
+ .replace(/\b[Pp]lan\b/g, "Execution")
23
+ .replace(/preview/g, "deployment preparation")
24
+ .replace(/after approval/g, "")
25
+ .replace(/--approve/g, "")
26
+ .replace(/ {2,}/g, " ");
27
+ }
28
+
29
+ export function formatProvisionError(error: unknown): string {
30
+ if (!(error instanceof MachineCommandError)) return error instanceof Error ? error.message : String(error);
31
+ const message = error.code === "azure-deployment-failed" ? error.message : humanProvisionMessage(error.message);
32
+ const lines = [...new Set(message.split("\n"))];
33
+ const details = error.details as Partial<ApplyProvisionResult> | undefined;
34
+ if (details?.deployment) {
35
+ lines.push(`Deployment: ${details.deployment.reference.name}; state: ${details.deployment.state}`);
36
+ }
37
+ for (const diagnostic of details?.diagnostics ?? []) {
38
+ if (diagnostic.failureKind || diagnostic.outputTruncated) lines.push(formatAzureDiagnostic(diagnostic));
39
+ }
40
+ const runId = details?.runId;
41
+ if (runId) {
42
+ lines.push(`Check: swallowkit provision --status ${runId}`);
43
+ if (error.code !== "azure-deployment-failed") lines.push(`Resume: swallowkit provision --resume ${runId}`);
44
+ else lines.push("Azure reported a terminal failure. Resuming only observes that failed deployment; correct its cause before explicitly deploying again.");
45
+ } else {
46
+ for (const action of error.nextActions ?? []) {
47
+ const command = humanProvisionMessage(action.command);
48
+ if (!command.includes("machine ")) lines.push(command);
49
+ }
50
+ }
51
+ return lines.join("\n");
52
+ }
53
+
54
+ async function printProvisionSetup(result: ApplyProvisionResult): Promise<void> {
55
+ if (!result.deployment || result.status !== "complete") return;
56
+ const reference = result.deployment.reference;
57
+ const fields = ["staticWebAppName", "staticWebAppUrl", "functionsAppName", "functionsAppUrl", "cosmosDbAccountName", "cosmosDatabaseName"] as const;
58
+ let outputs: Record<string, unknown>;
59
+ try {
60
+ const response = await runAzCliAsync(
61
+ [
62
+ "deployment",
63
+ "group",
64
+ "show",
65
+ "--name",
66
+ reference.name.startsWith("swallowkit-migration-") ? "swallowkit-baseline" : reference.name,
67
+ "--resource-group",
68
+ reference.resourceGroup,
69
+ "--subscription",
70
+ reference.subscription,
71
+ "--query",
72
+ `{${fields.map((field) => `${field}:properties.outputs.${field}.value`).join(",")}}`,
73
+ "-o",
74
+ "json",
75
+ ],
76
+ { timeout: 30_000 }
77
+ );
78
+ if (response.status !== 0 || response.failureKind || response.stdoutTruncated) throw new Error("Output query did not complete.");
79
+ const parsed: unknown = JSON.parse(response.stdout);
80
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid resource output.");
81
+ outputs = parsed as Record<string, unknown>;
82
+ } catch {
83
+ console.warn(
84
+ "Deployment succeeded, but resource information could not be read. Inspect the deployment's named outputs in Azure Portal; do not redeploy just to retrieve them."
85
+ );
86
+ return;
87
+ }
88
+ console.log("\nResource information:");
89
+ for (const field of fields) {
90
+ if (typeof outputs[field] === "string") console.log(` ${field}: ${outputs[field]}`);
91
+ }
92
+ console.log("\nCI/CD settings:");
93
+ console.log(` AZURE_FUNCTIONAPP_NAME: ${typeof outputs.functionsAppName === "string" ? outputs.functionsAppName : "<functions-app-name>"}`);
94
+ console.log(" Retrieve AZURE_STATIC_WEB_APPS_API_TOKEN and AZURE_FUNCTIONAPP_PUBLISH_PROFILE privately from Azure Portal and save them in your CI/CD secrets.");
95
+ console.log(" Secret values are not printed or stored by SwallowKit. Trigger the first application deployment after configuring CI/CD.");
96
+ }
10
97
 
11
98
  export function describeProvisionAction(plan: Pick<ProvisionPlanData, "mode" | "resourceGroup" | "location" | "pendingMigrations">): string {
12
99
  switch (plan.mode) {
@@ -18,6 +105,8 @@ export function describeProvisionAction(plan: Pick<ProvisionPlanData, "mode" | "
18
105
  return `The existing resource group ${plan.resourceGroup} will be recorded as SwallowKit baseline 0. Nothing will be deployed.`;
19
106
  case "reconcile":
20
107
  return `infra/main.bicep will be redeployed to ${plan.resourceGroup}. Template-managed settings may be reset.`;
108
+ case "recover":
109
+ return `The existing baseline deployment in ${plan.resourceGroup} will be observed and its migration tag/ledger completed after Azure Succeeded. Bicep will not be submitted again.`;
21
110
  default:
22
111
  return `Resource group ${plan.resourceGroup}:`;
23
112
  }
@@ -44,7 +133,7 @@ const SWA_REGIONS = [
44
133
  ];
45
134
 
46
135
  interface ProvisionCliOptions {
47
- resourceGroup: string;
136
+ resourceGroup?: string;
48
137
  subscription?: string;
49
138
  location?: string;
50
139
  swaLocation?: string;
@@ -53,6 +142,40 @@ interface ProvisionCliOptions {
53
142
  baseline?: string;
54
143
  reconcile?: boolean;
55
144
  approve?: boolean;
145
+ resume?: string;
146
+ status?: string;
147
+ resumeDeployment?: string;
148
+ waitSeconds?: string;
149
+ }
150
+
151
+ export function formatProvisionResult(result: ApplyProvisionResult): string {
152
+ const outcome =
153
+ result.outcome === "succeeded"
154
+ ? "✅ Provisioning completed, including migration tags and the local ledger."
155
+ : result.outcome === "in-progress"
156
+ ? "⏳ Azure deployment is still in progress. The local wait ended; this is not an Azure failure."
157
+ : result.outcome === "partial"
158
+ ? "⚠️ Provisioning is partially complete. Finish migration bookkeeping from the checkpoint; do not redeploy the baseline."
159
+ : result.outcome === "failed"
160
+ ? "❌ Azure deployment reached a failed/cancelled terminal state."
161
+ : "⚠️ Azure deployment result is unknown. A local az interruption does not prove an Azure failure.";
162
+ const lines = [outcome, `Resource group: ${result.resourceGroup}`];
163
+ if (result.deployment) {
164
+ const reference = result.deployment.reference;
165
+ lines.push(`Deployment: ${reference.name}; subscription: ${reference.subscription}; state: ${result.deployment.state}${reference.id ? `; ID: ${reference.id}` : ""}`);
166
+ for (const error of result.deployment.errors) lines.push(`${error.code}: ${error.message}`);
167
+ }
168
+ for (const warning of result.warnings) lines.push(`Warning: ${humanProvisionMessage(warning)}`);
169
+ for (const diagnostic of result.diagnostics.filter((entry) => entry.failureKind || entry.outputTruncated)) {
170
+ lines.push(formatAzureDiagnostic(diagnostic));
171
+ }
172
+ if (result.status !== "complete") {
173
+ lines.push(`Check/wait: swallowkit provision --status ${result.runId} --wait-seconds 300`);
174
+ if (result.outcome !== "failed") lines.push(`Resume: swallowkit provision --resume ${result.runId}`);
175
+ if (result.outcome !== "failed") lines.push("Azure may continue after Ctrl+C or process termination. No Azure cancellation or deletion is performed.");
176
+ else lines.push("Azure reported a terminal failure. Correct its cause before explicitly deploying again; resuming does not repair a failed deployment.");
177
+ }
178
+ return lines.join("\n");
56
179
  }
57
180
 
58
181
  async function resolveLocations(options: ProvisionCliOptions): Promise<{ location: string; swaLocation: string }> {
@@ -84,77 +207,127 @@ async function resolveLocations(options: ProvisionCliOptions): Promise<{ locatio
84
207
  return { location, swaLocation };
85
208
  }
86
209
 
87
- export const provisionCommand = new Command("provision")
88
- .description("Bootstrap, adopt, migrate, or explicitly reconcile Azure resources")
89
- .requiredOption("-g, --resource-group <name>", "Resource group name")
90
- .option("--subscription <id>", "Azure subscription ID")
91
- .option("--location <region>", "Primary location for Functions and Cosmos DB")
92
- .option("--swa-location <region>", "Static Web App location")
93
- .option("--what-if", "Include Azure what-if evidence in the preview", false)
94
- .option("--adopt-existing", "Adopt an existing untagged resource group without deploying main.bicep", false)
95
- .option("--baseline <version>", "Baseline version for adoption (currently 0)")
96
- .option("--reconcile", "Explicitly redeploy infra/main.bicep", false)
97
- .option("--approve", "Run the displayed Azure commands without the confirmation prompt", false)
98
- .action(async (options: ProvisionCliOptions) => {
99
- ensureSwallowKitProject("provision");
100
- const locations = await resolveLocations(options);
101
- const baseline = options.baseline === undefined ? undefined : Number(options.baseline);
102
- // Preview in memory: the same Azure checks as the agent workflow, but no saved plan file.
103
- const preview = await planProvisionOperation({
104
- resourceGroup: options.resourceGroup,
105
- subscription: options.subscription,
106
- location: locations.location,
107
- swaLocation: locations.swaLocation,
108
- whatIf: options.whatIf,
109
- adoptExisting: options.adoptExisting,
110
- baseline,
111
- reconcile: options.reconcile,
112
- }, { persist: false });
113
-
114
- for (const warning of preview.warnings) console.warn(`Warning: ${warning}`);
115
-
116
- if (preview.mode === "offline") {
117
- console.log([
118
- "",
119
- "❌ Azure CLI is unavailable, so the current state of Azure could not be checked. Nothing was changed in Azure.",
120
- " 1. Make sure `az --version` works in this terminal (install: https://aka.ms/azure-cli).",
121
- " 2. Sign in with `az login` (and `az account set --subscription <id>` if needed).",
122
- " 3. Run this command again.",
123
- ].join("\n"));
124
- process.exitCode = 1;
125
- return;
126
- }
210
+ export function createProvisionCommand(): Command {
211
+ return new Command("provision")
212
+ .description("Deploy Azure infrastructure and show resource information and CI/CD setup")
213
+ .option("-g, --resource-group <name>", "Resource group name (required for deployment)")
214
+ .option("--subscription <id>", "Azure subscription ID")
215
+ .option("--location <region>", "Primary location for Functions and Cosmos DB")
216
+ .option("--swa-location <region>", "Static Web App location")
217
+ .option("--what-if", "Check Azure what-if before deployment", false)
218
+ .option("--adopt-existing", "Adopt an existing untagged resource group without deploying main.bicep", false)
219
+ .option("--baseline <version>", "Baseline version for adoption (currently 0)")
220
+ .option("--reconcile", "Explicitly redeploy infra/main.bicep", false)
221
+ .addOption(new Option("--approve", "Compatibility option; invoking provision already requests execution").hideHelp())
222
+ .option("--status <runId>", "Read an existing execution checkpoint and Azure deployment state without writes")
223
+ .option("--resume <runId>", "Continue the original execution without resubmitting attempted deployments")
224
+ .option("--resume-deployment <name>", "Recover an older untracked baseline deployment after verifying its template and parameters")
225
+ .option("--wait-seconds <seconds>", "Bounded state wait, 0-3600 seconds (default: 300; status: 0)")
226
+ .action(async (options: ProvisionCliOptions) => {
227
+ try {
228
+ ensureSwallowKitProject("provision");
229
+ const waitSeconds = options.waitSeconds === undefined ? undefined : Number(options.waitSeconds);
230
+ if (
231
+ (options.resume && options.status) ||
232
+ ((options.resume || options.status) &&
233
+ (options.resourceGroup ||
234
+ options.resumeDeployment ||
235
+ options.reconcile ||
236
+ options.adoptExisting ||
237
+ options.subscription ||
238
+ options.location ||
239
+ options.swaLocation ||
240
+ options.whatIf ||
241
+ options.baseline !== undefined))
242
+ ) {
243
+ throw new MachineCommandError(
244
+ "invalid-arguments",
245
+ "Use --status or --resume with the checkpoint ID alone; its original Azure target and reviewed files are preserved.",
246
+ undefined,
247
+ "blocked"
248
+ );
249
+ }
250
+ if (options.status) {
251
+ const result = await inspectProvisionOperation({
252
+ planId: options.status,
253
+ waitSeconds,
254
+ });
255
+ console.log(formatProvisionResult(result));
256
+ if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
257
+ return;
258
+ }
259
+ if (options.resume) {
260
+ const result = await applyProvisionOperation({
261
+ planId: options.resume,
262
+ approve: true,
263
+ waitSeconds,
264
+ });
265
+ console.log(formatProvisionResult(result));
266
+ await printProvisionSetup(result);
267
+ if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
268
+ return;
269
+ }
270
+ if (!options.resourceGroup) throw new MachineCommandError("invalid-arguments", "Provision requires --resource-group.", undefined, "blocked");
271
+ const locations = await resolveLocations(options);
272
+ const baseline = options.baseline === undefined ? undefined : Number(options.baseline);
273
+ // Preview in memory: the same Azure checks as the agent workflow, but no saved plan file.
274
+ const preview = await planProvisionOperation(
275
+ {
276
+ resourceGroup: options.resourceGroup,
277
+ subscription: options.subscription,
278
+ location: locations.location,
279
+ swaLocation: locations.swaLocation,
280
+ whatIf: options.whatIf,
281
+ adoptExisting: options.adoptExisting,
282
+ baseline,
283
+ reconcile: options.reconcile,
284
+ resumeDeployment: options.resumeDeployment,
285
+ },
286
+ { persist: false }
287
+ );
127
288
 
128
- if (preview.mode === "adoption-required") {
129
- throw new Error('Existing resource group requires: migrations init --legacy-baseline, then provision --adopt-existing --baseline 0 --what-if.');
130
- }
289
+ for (const warning of preview.warnings) console.warn(`Warning: ${warning}`);
131
290
 
132
- if (preview.mode === "noop") {
133
- console.log(`Resource group ${preview.resourceGroup} is already up to date. Nothing to do.`);
134
- return;
135
- }
291
+ if (preview.mode === "offline") {
292
+ console.log(
293
+ [
294
+ "",
295
+ "❌ Azure CLI is unavailable, so the current state of Azure could not be checked. Nothing was changed in Azure.",
296
+ " 1. Make sure `az --version` works in this terminal (install: https://aka.ms/azure-cli).",
297
+ " 2. Sign in with `az login` (and `az account set --subscription <id>` if needed).",
298
+ " 3. Run this command again.",
299
+ ].join("\n")
300
+ );
301
+ process.exitCode = 1;
302
+ return;
303
+ }
304
+
305
+ if (preview.mode === "adoption-required") {
306
+ throw new Error(
307
+ "The existing resource group has no migration tag. For an interrupted baseline use --resume-deployment <original-name> to verify/observe it without redeploying. Legacy adoption applies only to a legacy manifest; do not replace an existing manifest."
308
+ );
309
+ }
310
+
311
+ if (preview.mode === "noop") {
312
+ console.log(`Resource group ${preview.resourceGroup} is already up to date. Nothing to do.`);
313
+ return;
314
+ }
136
315
 
137
- console.log(`\n${describeProvisionAction(preview)}`);
138
- console.log("The following Azure CLI commands will run:");
139
- for (const command of preview.commands) console.log(` ${command}`);
316
+ console.log(`\n${describeProvisionAction(preview)}`);
317
+ console.log("Deploying Azure infrastructure. Azure resources may incur charges.");
140
318
 
141
- let approved = options.approve === true;
142
- if (!approved) {
143
- if (!process.stdin.isTTY || !process.stdout.isTTY) {
144
- throw new Error("Non-interactive provision requires --approve after reviewing the commands above.");
319
+ const result = await executeProvisionPlan(preview, {
320
+ approve: true,
321
+ retryHint: INTERACTIVE_PROVISION_RETRY_HINT,
322
+ waitSeconds,
323
+ });
324
+ console.log(formatProvisionResult(result));
325
+ await printProvisionSetup(result);
326
+ if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
327
+ } catch (error) {
328
+ throw new Error(formatProvisionError(error));
145
329
  }
146
- const answer = await prompts({
147
- type: "confirm",
148
- name: "approved",
149
- message: "Run these Azure commands? This may create billable resources.",
150
- initial: false,
151
- });
152
- approved = answer.approved === true;
153
- }
154
- if (!approved) {
155
- console.log("Provision cancelled. Nothing was changed in Azure.");
156
- return;
157
- }
330
+ });
331
+ }
158
332
 
159
- const result = await executeProvisionPlan(preview, { approve: true, retryHint: INTERACTIVE_PROVISION_RETRY_HINT });
160
- console.log(`✅ Provisioning completed. ${result.executedCommands.length} Azure CLI command(s) ran.`); });
333
+ export const provisionCommand = createProvisionCommand();
package/src/cli/index.ts CHANGED
@@ -5,7 +5,8 @@ import { readFileSync } from "fs";
5
5
  import * as path from "path";
6
6
  import { Command } from "commander";
7
7
  import { initCommand, devCommand, devSeedsCommand, scaffoldCommand, createModelCommand } from "./commands";
8
- import { provisionCommand } from "./commands/provision";
8
+ import { createProvisionCommand } from "./commands/provision";
9
+ import { AzureCliDiagnostic, formatAzureDiagnostic } from "../utils/azure-diagnostics";
9
10
  import { createMigrationCommand } from "./commands/create-migration";
10
11
  import { migrationsCommand } from "./commands/migrations";
11
12
  import { configureMcpCommand } from "./commands/configure-mcp";
@@ -139,7 +140,7 @@ export function createProgram(devCommandOverride: Command = devCommand): Command
139
140
  program.addCommand(devCommandOverride);
140
141
  program.addCommand(devSeedsCommand);
141
142
 
142
- program.addCommand(provisionCommand);
143
+ program.addCommand(createProvisionCommand());
143
144
  program.addCommand(migrationsCommand);
144
145
  program.addCommand(createMigrationCommand);
145
146
  program.addCommand(configureMcpCommand);
@@ -365,7 +366,16 @@ export function formatCliError(error: unknown): string {
365
366
  if (details && typeof details === "object") {
366
367
  if (typeof details.nextAction === "string") lines.push(` Next: ${details.nextAction}`);
367
368
  if (Array.isArray(details.changedFiles)) lines.push(` Changed files: ${details.changedFiles.join(", ")}`);
369
+ if (typeof details.runId === "string") lines.push(` Run: ${details.runId}`);
370
+ if (details.deployment && typeof details.deployment === "object") {
371
+ const deployment = details.deployment as { state?: string; reference?: { name?: string; id?: string; resourceGroup?: string; subscription?: string } };
372
+ lines.push(` Deployment: ${deployment.reference?.id ?? deployment.reference?.name ?? "unknown"}; state: ${deployment.state ?? "unknown"}`);
373
+ }
374
+ if (Array.isArray(details.diagnostics)) {
375
+ for (const diagnostic of details.diagnostics as AzureCliDiagnostic[]) lines.push(formatAzureDiagnostic(diagnostic));
376
+ }
368
377
  }
378
+ for (const action of error.nextActions ?? []) lines.push(` Next: ${action.command} — ${action.description}`);
369
379
  return lines.join("\n");
370
380
  }
371
381
  if (error instanceof Error) {
@@ -0,0 +1,167 @@
1
+ import { runAzCliAsync } from "../../utils/azure-cli";
2
+ import { AzureCliDiagnostic, azureCliDiagnostic, redactAzureText } from "../../utils/azure-diagnostics";
3
+
4
+ export const DEPLOYMENT_QUERY = "{id:id,name:name,state:properties.provisioningState,correlationId:properties.correlationId,templateHash:properties.templateHash,timestamp:properties.timestamp,error:properties.error}";
5
+ export const DEFAULT_PROVISION_WAIT_SECONDS = 300;
6
+ export const MAX_PROVISION_WAIT_SECONDS = 3_600;
7
+
8
+ export interface DeploymentReference {
9
+ subscription: string;
10
+ resourceGroup: string;
11
+ name: string;
12
+ id?: string;
13
+ correlationId?: string;
14
+ templateHash?: string;
15
+ /** Do not mistake a previous deployment under the same name for this submission. */
16
+ previousCorrelationId?: string;
17
+ }
18
+
19
+ export interface AzureDeploymentError {
20
+ code: string;
21
+ message: string;
22
+ }
23
+
24
+ export interface DeploymentObservation {
25
+ state: "Accepted" | "Running" | "Succeeded" | "Failed" | "Canceled" | "unknown";
26
+ reference: DeploymentReference;
27
+ reason?: "not-found" | "query-failed" | "invalid-response" | "identity-changed" | "not-yet-observed" | "cancelled";
28
+ errors: AzureDeploymentError[];
29
+ diagnostic: AzureCliDiagnostic;
30
+ }
31
+
32
+ function record(value: unknown): Record<string, unknown> | null {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
34
+ }
35
+
36
+ export function extractAzureErrors(value: unknown, secretValues: readonly string[] = []): AzureDeploymentError[] {
37
+ const result: AzureDeploymentError[] = [];
38
+ const visit = (entry: unknown, depth: number) => {
39
+ if (depth > 8 || result.length >= 20) return;
40
+ if (typeof entry === "string") {
41
+ try { visit(JSON.parse(entry), depth + 1); } catch { /* Only structured Azure errors are projected. */ }
42
+ return;
43
+ }
44
+ const item = record(entry);
45
+ if (!item) return;
46
+ if (typeof item.code === "string" && typeof item.message === "string") {
47
+ result.push({ code: redactAzureText(item.code, secretValues).slice(0, 200), message: redactAzureText(item.message, secretValues).slice(0, 2_000) });
48
+ }
49
+ if (item.error) visit(item.error, depth + 1);
50
+ if (Array.isArray(item.details)) item.details.forEach((detail) => visit(detail, depth + 1));
51
+ };
52
+ visit(value, 0);
53
+ return result;
54
+ }
55
+
56
+ export function deploymentId(reference: DeploymentReference): string {
57
+ return reference.id ?? `/subscriptions/${reference.subscription}/resourceGroups/${reference.resourceGroup}/providers/Microsoft.Resources/deployments/${reference.name}`;
58
+ }
59
+
60
+ export async function queryAzureDeployment(
61
+ reference: DeploymentReference,
62
+ options: { timeoutMs?: number; signal?: AbortSignal; secretValues?: readonly string[] } = {}
63
+ ): Promise<DeploymentObservation> {
64
+ const result = await runAzCliAsync([
65
+ "deployment", "group", "show", "--name", reference.name,
66
+ "--resource-group", reference.resourceGroup, "--subscription", reference.subscription,
67
+ "--query", DEPLOYMENT_QUERY, "-o", "json",
68
+ ], { timeout: options.timeoutMs ?? 30_000, signal: options.signal });
69
+ const diagnostic = azureCliDiagnostic(result, "deployment-state", options.secretValues);
70
+ const unknown = (reason: DeploymentObservation["reason"]): DeploymentObservation => ({
71
+ state: "unknown", reference, reason, errors: [], diagnostic,
72
+ });
73
+ if (result.status !== 0 || result.failureKind) {
74
+ if (result.failureKind === "cancelled") return unknown("cancelled");
75
+ return unknown(/DeploymentNotFound|ResourceGroupNotFound/i.test(`${result.stderr}\n${result.stdout}`) ? "not-found" : "query-failed");
76
+ }
77
+ if (result.stdoutTruncated) return unknown("invalid-response");
78
+ let parsed: Record<string, unknown> | null;
79
+ try { parsed = record(JSON.parse(result.stdout)); } catch { return unknown("invalid-response"); }
80
+ if (!parsed || typeof parsed.id !== "string" || typeof parsed.state !== "string" || typeof parsed.correlationId !== "string") {
81
+ return unknown("invalid-response");
82
+ }
83
+ if (parsed.id.toLowerCase() !== deploymentId(reference).toLowerCase()
84
+ || (reference.correlationId && parsed.correlationId !== reference.correlationId)
85
+ || (reference.templateHash && parsed.templateHash !== reference.templateHash)) {
86
+ return unknown("identity-changed");
87
+ }
88
+ if (reference.previousCorrelationId && parsed.correlationId === reference.previousCorrelationId) {
89
+ return unknown("not-yet-observed");
90
+ }
91
+ const states: Record<string, DeploymentObservation["state"]> = {
92
+ accepted: "Accepted", running: "Running", creating: "Running", updating: "Running",
93
+ succeeded: "Succeeded", failed: "Failed", canceled: "Canceled", cancelled: "Canceled",
94
+ };
95
+ const state = states[parsed.state.toLowerCase()];
96
+ if (!state) return unknown("invalid-response");
97
+ return {
98
+ state,
99
+ reference: {
100
+ ...reference,
101
+ id: parsed.id,
102
+ correlationId: parsed.correlationId,
103
+ ...(typeof parsed.templateHash === "string" ? { templateHash: parsed.templateHash } : {}),
104
+ },
105
+ errors: extractAzureErrors(parsed.error, options.secretValues),
106
+ diagnostic,
107
+ };
108
+ }
109
+
110
+ export async function waitForAzureDeployment(
111
+ reference: DeploymentReference,
112
+ options: {
113
+ deadline: number;
114
+ signal?: AbortSignal;
115
+ pollIntervalMs?: number;
116
+ onObservation?: (observation: DeploymentObservation) => void;
117
+ secretValues?: readonly string[];
118
+ queryTimeoutMs?: number;
119
+ }
120
+ ): Promise<DeploymentObservation> {
121
+ let observation: DeploymentObservation;
122
+ do {
123
+ const remaining = options.deadline - Date.now();
124
+ // Even when the wait budget is exhausted, one bounded read can identify an accepted submission.
125
+ observation = await queryAzureDeployment(reference, {
126
+ timeoutMs: remaining > 0 ? Math.min(options.queryTimeoutMs ?? 30_000, remaining) : options.queryTimeoutMs ?? 30_000,
127
+ signal: options.signal,
128
+ secretValues: options.secretValues,
129
+ });
130
+ if (observation.state !== "unknown") reference = observation.reference;
131
+ options.onObservation?.(observation);
132
+ if (observation.state === "Succeeded" || observation.state === "Failed" || observation.state === "Canceled") return observation;
133
+ if (observation.state === "unknown" && observation.reason !== "not-yet-observed" && observation.reason !== "not-found") return observation;
134
+ if (Date.now() >= options.deadline || options.signal?.aborted) return observation;
135
+ await new Promise<void>((resolve) => {
136
+ const done = () => {
137
+ clearTimeout(timer);
138
+ options.signal?.removeEventListener("abort", done);
139
+ resolve();
140
+ };
141
+ const timer = setTimeout(done, Math.min(options.pollIntervalMs ?? 5_000, Math.max(options.deadline - Date.now(), 0)));
142
+ options.signal?.addEventListener("abort", done, { once: true });
143
+ if (options.signal?.aborted) done();
144
+ });
145
+ } while (!options.signal?.aborted);
146
+ return observation;
147
+ }
148
+
149
+ export async function queryDeploymentOperationErrors(reference: DeploymentReference, secretValues: readonly string[] = [], signal?: AbortSignal): Promise<{
150
+ errors: AzureDeploymentError[];
151
+ diagnostic: AzureCliDiagnostic;
152
+ }> {
153
+ const result = await runAzCliAsync([
154
+ "deployment", "operation", "group", "list",
155
+ "--resource-group", reference.resourceGroup, "--name", reference.name, "--subscription", reference.subscription,
156
+ "--query", "[?properties.provisioningState=='Failed' || properties.provisioningState=='Canceled'].{error:properties.statusMessage}",
157
+ "-o", "json",
158
+ ], { timeout: 30_000, signal });
159
+ const errors: AzureDeploymentError[] = [];
160
+ if (result.status === 0 && !result.failureKind && !result.stdoutTruncated) {
161
+ try {
162
+ const parsed: unknown = JSON.parse(result.stdout);
163
+ if (Array.isArray(parsed)) parsed.slice(0, 20).forEach((item) => errors.push(...extractAzureErrors(item, secretValues)));
164
+ } catch { /* The separate diagnostic explicitly records an unreadable operations response. */ }
165
+ }
166
+ return { errors: errors.slice(0, 20), diagnostic: azureCliDiagnostic(result, "deployment-operations", secretValues) };
167
+ }