swallowkit 2.0.0-alpha.5 → 2.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/cli/commands/provision.d.ts +2 -1
  2. package/dist/cli/commands/provision.d.ts.map +1 -1
  3. package/dist/cli/commands/provision.js +66 -4
  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 +13 -0
  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 +28 -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 +3 -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 +130 -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 +562 -0
  51. package/src/cli/commands/provision.ts +69 -5
  52. package/src/cli/index.ts +10 -0
  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 +18 -0
  56. package/src/core/project/agent-templates/CLAUDE.md +5 -0
  57. package/src/core/project/agent-templates/copilot-instructions.md +6 -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 +28 -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
@@ -3,10 +3,15 @@ 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";
10
15
 
11
16
  export function describeProvisionAction(plan: Pick<ProvisionPlanData, "mode" | "resourceGroup" | "location" | "pendingMigrations">): string {
12
17
  switch (plan.mode) {
@@ -18,6 +23,8 @@ export function describeProvisionAction(plan: Pick<ProvisionPlanData, "mode" | "
18
23
  return `The existing resource group ${plan.resourceGroup} will be recorded as SwallowKit baseline 0. Nothing will be deployed.`;
19
24
  case "reconcile":
20
25
  return `infra/main.bicep will be redeployed to ${plan.resourceGroup}. Template-managed settings may be reset.`;
26
+ case "recover":
27
+ 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
28
  default:
22
29
  return `Resource group ${plan.resourceGroup}:`;
23
30
  }
@@ -44,7 +51,7 @@ const SWA_REGIONS = [
44
51
  ];
45
52
 
46
53
  interface ProvisionCliOptions {
47
- resourceGroup: string;
54
+ resourceGroup?: string;
48
55
  subscription?: string;
49
56
  location?: string;
50
57
  swaLocation?: string;
@@ -53,6 +60,34 @@ interface ProvisionCliOptions {
53
60
  baseline?: string;
54
61
  reconcile?: boolean;
55
62
  approve?: boolean;
63
+ resume?: string;
64
+ status?: string;
65
+ resumeDeployment?: string;
66
+ waitSeconds?: string;
67
+ }
68
+
69
+ export function formatProvisionResult(result: ApplyProvisionResult): string {
70
+ const outcome = result.outcome === "succeeded" ? "✅ Provisioning completed, including migration tags and the local ledger."
71
+ : result.outcome === "in-progress" ? "⏳ Azure deployment is still in progress. The local wait ended; this is not an Azure failure."
72
+ : result.outcome === "partial" ? "⚠️ Provisioning is partially complete. Finish migration bookkeeping from the checkpoint; do not redeploy the baseline."
73
+ : result.outcome === "failed" ? "❌ Azure deployment reached a failed/cancelled terminal state."
74
+ : "⚠️ Azure deployment result is unknown. A local az interruption does not prove an Azure failure.";
75
+ const lines = [outcome, `Run: ${result.runId}; resource group: ${result.resourceGroup}`];
76
+ if (result.deployment) {
77
+ const reference = result.deployment.reference;
78
+ lines.push(`Deployment: ${reference.name}; subscription: ${reference.subscription}; state: ${result.deployment.state}${reference.id ? `; ID: ${reference.id}` : ""}`);
79
+ for (const error of result.deployment.errors) lines.push(`${error.code}: ${error.message}`);
80
+ }
81
+ for (const warning of result.warnings) lines.push(`Warning: ${warning}`);
82
+ for (const diagnostic of result.diagnostics.filter((entry) => entry.failureKind || entry.outputTruncated)) {
83
+ lines.push(formatAzureDiagnostic(diagnostic));
84
+ }
85
+ if (result.status !== "complete") {
86
+ lines.push(`Check/wait: swallowkit provision --status ${result.runId} --wait-seconds 300`);
87
+ lines.push(`Resume after approval: swallowkit provision --resume ${result.runId} --approve`);
88
+ lines.push("Azure may continue after Ctrl+C or process termination. No Azure cancellation or deletion is performed.");
89
+ }
90
+ return lines.join("\n");
56
91
  }
57
92
 
58
93
  async function resolveLocations(options: ProvisionCliOptions): Promise<{ location: string; swaLocation: string }> {
@@ -86,7 +121,7 @@ async function resolveLocations(options: ProvisionCliOptions): Promise<{ locatio
86
121
 
87
122
  export const provisionCommand = new Command("provision")
88
123
  .description("Bootstrap, adopt, migrate, or explicitly reconcile Azure resources")
89
- .requiredOption("-g, --resource-group <name>", "Resource group name")
124
+ .option("-g, --resource-group <name>", "Resource group name (required for a new preview)")
90
125
  .option("--subscription <id>", "Azure subscription ID")
91
126
  .option("--location <region>", "Primary location for Functions and Cosmos DB")
92
127
  .option("--swa-location <region>", "Static Web App location")
@@ -95,8 +130,34 @@ export const provisionCommand = new Command("provision")
95
130
  .option("--baseline <version>", "Baseline version for adoption (currently 0)")
96
131
  .option("--reconcile", "Explicitly redeploy infra/main.bicep", false)
97
132
  .option("--approve", "Run the displayed Azure commands without the confirmation prompt", false)
133
+ .option("--status <runId>", "Read an existing execution checkpoint and Azure deployment state without writes")
134
+ .option("--resume <runId>", "Resume an approved checkpoint without resubmitting attempted deployments")
135
+ .option("--resume-deployment <name>", "Recover an older untracked baseline deployment after verifying its template and parameters")
136
+ .option("--wait-seconds <seconds>", "Bounded state wait, 0-3600 seconds (default: 300; status: 0)")
98
137
  .action(async (options: ProvisionCliOptions) => {
99
138
  ensureSwallowKitProject("provision");
139
+ const waitSeconds = options.waitSeconds === undefined ? undefined : Number(options.waitSeconds);
140
+ if ((options.resume && options.status) || ((options.resume || options.status) && (options.resourceGroup || options.resumeDeployment || options.reconcile || options.adoptExisting || options.subscription || options.location || options.swaLocation || options.whatIf || options.baseline !== undefined))) {
141
+ throw new MachineCommandError("invalid-arguments", "Use --status or --resume with the checkpoint ID alone; its original Azure target and reviewed files are preserved.", undefined, "blocked");
142
+ }
143
+ if (options.status) {
144
+ const result = await inspectProvisionOperation({ planId: options.status, waitSeconds });
145
+ console.log(formatProvisionResult(result));
146
+ if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
147
+ return;
148
+ }
149
+ if (options.resume) {
150
+ let approved = options.approve === true;
151
+ if (!approved && process.stdin.isTTY && process.stdout.isTTY) {
152
+ const answer = await prompts({ type: "confirm", name: "approved", message: "Resume the original reviewed run and finish pending steps? This requires explicit approval.", initial: false });
153
+ approved = answer.approved === true;
154
+ }
155
+ const result = await applyProvisionOperation({ planId: options.resume, approve: approved, waitSeconds });
156
+ console.log(formatProvisionResult(result));
157
+ if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
158
+ return;
159
+ }
160
+ if (!options.resourceGroup) throw new MachineCommandError("invalid-arguments", "New provision previews require --resource-group.", undefined, "blocked");
100
161
  const locations = await resolveLocations(options);
101
162
  const baseline = options.baseline === undefined ? undefined : Number(options.baseline);
102
163
  // Preview in memory: the same Azure checks as the agent workflow, but no saved plan file.
@@ -109,6 +170,7 @@ export const provisionCommand = new Command("provision")
109
170
  adoptExisting: options.adoptExisting,
110
171
  baseline,
111
172
  reconcile: options.reconcile,
173
+ resumeDeployment: options.resumeDeployment,
112
174
  }, { persist: false });
113
175
 
114
176
  for (const warning of preview.warnings) console.warn(`Warning: ${warning}`);
@@ -126,7 +188,7 @@ export const provisionCommand = new Command("provision")
126
188
  }
127
189
 
128
190
  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.');
191
+ throw new Error('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.');
130
192
  }
131
193
 
132
194
  if (preview.mode === "noop") {
@@ -156,5 +218,7 @@ export const provisionCommand = new Command("provision")
156
218
  return;
157
219
  }
158
220
 
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.`); });
221
+ const result = await executeProvisionPlan(preview, { approve: true, retryHint: INTERACTIVE_PROVISION_RETRY_HINT, waitSeconds });
222
+ console.log(formatProvisionResult(result));
223
+ if (result.status === "blocked" || result.status === "failed") process.exitCode = 1;
224
+ });
package/src/cli/index.ts CHANGED
@@ -6,6 +6,7 @@ import * as path from "path";
6
6
  import { Command } from "commander";
7
7
  import { initCommand, devCommand, devSeedsCommand, scaffoldCommand, createModelCommand } from "./commands";
8
8
  import { provisionCommand } 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";
@@ -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
+ }