swallowkit 2.0.0-alpha.7 → 2.0.0-alpha.9

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 (29) hide show
  1. package/dist/cli/commands/init.d.ts.map +1 -1
  2. package/dist/cli/commands/init.js +21 -9
  3. package/dist/cli/commands/init.js.map +1 -1
  4. package/dist/cli/commands/provision.d.ts.map +1 -1
  5. package/dist/cli/commands/provision.js +6 -3
  6. package/dist/cli/commands/provision.js.map +1 -1
  7. package/dist/core/operations/migration-generator.d.ts.map +1 -1
  8. package/dist/core/operations/migration-generator.js +12 -1
  9. package/dist/core/operations/migration-generator.js.map +1 -1
  10. package/dist/core/operations/provision-deployment.d.ts +7 -0
  11. package/dist/core/operations/provision-deployment.d.ts.map +1 -1
  12. package/dist/core/operations/provision-deployment.js +16 -3
  13. package/dist/core/operations/provision-deployment.js.map +1 -1
  14. package/dist/core/operations/provision-operations.d.ts +4 -0
  15. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  16. package/dist/core/operations/provision-operations.js +8 -3
  17. package/dist/core/operations/provision-operations.js.map +1 -1
  18. package/dist/core/project/capabilities.js +1 -1
  19. package/dist/core/project/capabilities.js.map +1 -1
  20. package/package.json +1 -1
  21. package/src/__tests__/fixtures/azure-cli.cjs +2 -1
  22. package/src/__tests__/init.test.ts +59 -0
  23. package/src/__tests__/provision-runtime.test.ts +61 -0
  24. package/src/cli/commands/init.ts +21 -9
  25. package/src/cli/commands/provision.ts +6 -3
  26. package/src/core/operations/migration-generator.ts +14 -1
  27. package/src/core/operations/provision-deployment.ts +17 -1
  28. package/src/core/operations/provision-operations.ts +17 -5
  29. package/src/core/project/capabilities.ts +1 -1
@@ -1825,10 +1825,12 @@ ${pmCmd.exec} swallowkit provision --resource-group <rg-name>
1825
1825
 
1826
1826
  This creates:
1827
1827
  - Static Web App (\`swa-${projectName}\`)
1828
- - Azure Functions (\`func-${projectName}\`)
1829
- - Cosmos DB (\`cosmos-${projectName}\`)
1828
+ - Azure Functions (\`func-${projectName}-<suffix>\`)
1829
+ - Cosmos DB (\`cosmos-${projectName}-<suffix>\`)
1830
1830
  - Storage Account
1831
1831
 
1832
+ Cosmos DB and Function App names must be globally unique, so they include a short suffix derived from the resource group. When provisioning completes, the command prints the actual resource names and the CI/CD settings to use.
1833
+
1832
1834
  You will be prompted to select Azure regions:
1833
1835
  1. **Primary location**: For Functions and Cosmos DB (default: Japan East)
1834
1836
  2. **Static Web App location**: Limited availability (default: East Asia)
@@ -1844,12 +1846,12 @@ ${cicdChoice === 'github' ? `#### GitHub Actions
1844
1846
 
1845
1847
  2. Get Function App publish profile:
1846
1848
  \`\`\`bash
1847
- az webapp deployment list-publishing-profiles --name func-${projectName} --resource-group <rg-name> --xml
1849
+ az webapp deployment list-publishing-profiles --name <functions-app-name> --resource-group <rg-name> --xml
1848
1850
  \`\`\`
1849
1851
 
1850
1852
  3. Add secrets to GitHub repository:
1851
1853
  - \`AZURE_STATIC_WEB_APPS_API_TOKEN\`: SWA deployment token (from step 1)
1852
- - \`AZURE_FUNCTIONAPP_NAME\`: \`func-${projectName}\`
1854
+ - \`AZURE_FUNCTIONAPP_NAME\`: Functions app name printed by \`swallowkit provision\` (\`func-${projectName}-<suffix>\`)
1853
1855
  - \`AZURE_FUNCTIONAPP_PUBLISH_PROFILE\`: Functions publish profile (from step 2)
1854
1856
 
1855
1857
  4. Push to \`main\` branch to trigger deployment (or use **Actions** → **Run workflow** for manual deployment)` : cicdChoice === 'azure' ? `#### Azure Pipelines
@@ -1871,8 +1873,10 @@ az staticwebapp deploy --name swa-${projectName} --resource-group <rg-name> --ap
1871
1873
  \`\`\`bash
1872
1874
  cd functions
1873
1875
  ${pmCmd.run} build
1874
- func azure functionapp publish func-${projectName}
1875
- \`\`\``}
1876
+ func azure functionapp publish <functions-app-name>
1877
+ \`\`\`
1878
+
1879
+ Use the Functions app name printed by \`swallowkit provision\`.`}
1876
1880
 
1877
1881
  ## 🔧 Available Commands
1878
1882
 
@@ -2049,6 +2053,14 @@ param cosmosDbMode string = '${azureConfig.cosmosDbMode}'
2049
2053
  @description('Enable VNet integration')
2050
2054
  param enableVNet bool = ${enableVNet}
2051
2055
 
2056
+ // Cosmos DB account and Function App names are globally unique across Azure.
2057
+ // A suffix derived from the resource group keeps names stable for redeployments
2058
+ // to the same resource group while avoiding collisions with other environments
2059
+ // and with names Azure still reserves after a previous resource group is deleted.
2060
+ var resourceSuffix = take(uniqueString(resourceGroup().id, projectName), 6)
2061
+ var cosmosAccountName = 'cosmos-\${take(toLower(projectName), 30)}-\${resourceSuffix}'
2062
+ var functionsAppName = 'func-\${take(projectName, 40)}-\${resourceSuffix}'
2063
+
2052
2064
  // Shared Log Analytics Workspace (in Functions region for data residency)
2053
2065
  module logAnalytics 'modules/loganalytics.bicep' = {
2054
2066
  name: 'logAnalytics'
@@ -2102,7 +2114,7 @@ module vnet 'modules/vnet.bicep' = if (enableVNet) {
2102
2114
  module cosmosDbFreeTier 'modules/cosmosdb-freetier.bicep' = if (cosmosDbMode == 'freetier') {
2103
2115
  name: 'cosmosDb'
2104
2116
  params: {
2105
- accountName: 'cosmos-\${projectName}'
2117
+ accountName: cosmosAccountName
2106
2118
  databaseName: '\${projectName}Database'
2107
2119
  location: location
2108
2120
  publicNetworkAccess: enableVNet ? 'Disabled' : 'Enabled'
@@ -2112,7 +2124,7 @@ module cosmosDbFreeTier 'modules/cosmosdb-freetier.bicep' = if (cosmosDbMode ==
2112
2124
  module cosmosDbServerless 'modules/cosmosdb-serverless.bicep' = if (cosmosDbMode == 'serverless') {
2113
2125
  name: 'cosmosDb'
2114
2126
  params: {
2115
- accountName: 'cosmos-\${projectName}'
2127
+ accountName: cosmosAccountName
2116
2128
  databaseName: '\${projectName}Database'
2117
2129
  location: location
2118
2130
  publicNetworkAccess: enableVNet ? 'Disabled' : 'Enabled'
@@ -2136,7 +2148,7 @@ module cosmosPrivateEndpoint 'modules/private-endpoint-cosmos.bicep' = if (enabl
2136
2148
  module functionsFlex 'modules/functions-flex.bicep' = {
2137
2149
  name: 'functionsApp'
2138
2150
  params: {
2139
- name: 'func-\${projectName}'
2151
+ name: functionsAppName
2140
2152
  location: location
2141
2153
  storageAccountName: 'stg\${uniqueString(resourceGroup().id, projectName)}'
2142
2154
  appInsightsConnectionString: appInsightsFunctions.outputs.connectionString
@@ -170,8 +170,8 @@ export function formatProvisionResult(result: ApplyProvisionResult): string {
170
170
  lines.push(formatAzureDiagnostic(diagnostic));
171
171
  }
172
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}`);
173
+ lines.push(`Check status: swallowkit provision --status ${result.runId}`);
174
+ if (result.outcome !== "failed") lines.push(`Resume and wait until completion: swallowkit provision --resume ${result.runId}`);
175
175
  if (result.outcome !== "failed") lines.push("Azure may continue after Ctrl+C or process termination. No Azure cancellation or deletion is performed.");
176
176
  else lines.push("Azure reported a terminal failure. Correct its cause before explicitly deploying again; resuming does not repair a failed deployment.");
177
177
  }
@@ -222,7 +222,7 @@ export function createProvisionCommand(): Command {
222
222
  .option("--status <runId>", "Read an existing execution checkpoint and Azure deployment state without writes")
223
223
  .option("--resume <runId>", "Continue the original execution without resubmitting attempted deployments")
224
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)")
225
+ .addOption(new Option("--wait-seconds <seconds>", "Optional limit on waiting, 0-3600 seconds (default: wait until Azure finishes; status: 0)").hideHelp())
226
226
  .action(async (options: ProvisionCliOptions) => {
227
227
  try {
228
228
  ensureSwallowKitProject("provision");
@@ -261,6 +261,7 @@ export function createProvisionCommand(): Command {
261
261
  planId: options.resume,
262
262
  approve: true,
263
263
  waitSeconds,
264
+ waitUntilComplete: waitSeconds === undefined,
264
265
  });
265
266
  console.log(formatProvisionResult(result));
266
267
  await printProvisionSetup(result);
@@ -315,11 +316,13 @@ export function createProvisionCommand(): Command {
315
316
 
316
317
  console.log(`\n${describeProvisionAction(preview)}`);
317
318
  console.log("Deploying Azure infrastructure. Azure resources may incur charges.");
319
+ console.log("Waiting for the Azure deployment to finish. This often takes 10-20 minutes; resource information and CI/CD settings are shown when it completes.");
318
320
 
319
321
  const result = await executeProvisionPlan(preview, {
320
322
  approve: true,
321
323
  retryHint: INTERACTIVE_PROVISION_RETRY_HINT,
322
324
  waitSeconds,
325
+ waitUntilComplete: waitSeconds === undefined,
323
326
  });
324
327
  console.log(formatProvisionResult(result));
325
328
  await printProvisionSetup(result);
@@ -152,6 +152,19 @@ param location string
152
152
  return migration;
153
153
  }
154
154
 
155
+ const SUFFIXED_COSMOS_ACCOUNT_NAME = "'cosmos-${take(toLower(projectName), 30)}-${take(uniqueString(resourceGroup().id, projectName), 6)}'";
156
+ const LEGACY_COSMOS_ACCOUNT_NAME = "'cosmos-${projectName}'";
157
+
158
+ // Projects initialized before resource-group suffixes keep their original
159
+ // account name so model migrations target the account their baseline created.
160
+ function cosmosAccountNameExpression(session: FileOperationSession): string {
161
+ const mainBicep = path.join(session.rootDirectory, "infra", "main.bicep");
162
+ if (session.fileExists(mainBicep) && /var\s+resourceSuffix\s*=/.test(session.readFile(mainBicep))) {
163
+ return SUFFIXED_COSMOS_ACCOUNT_NAME;
164
+ }
165
+ return LEGACY_COSMOS_ACCOUNT_NAME;
166
+ }
167
+
155
168
  export function generateModelContainerMigration(
156
169
  model: { name: string; partitionKey: string },
157
170
  session: FileOperationSession = new FileOperationSession("commit")
@@ -175,7 +188,7 @@ param projectName string
175
188
  param location string
176
189
 
177
190
  resource cosmosAccount 'Microsoft.DocumentDB/databaseAccounts@2023-11-15' existing = {
178
- name: 'cosmos-\${projectName}'
191
+ name: ${cosmosAccountNameExpression(session)}
179
192
  }
180
193
 
181
194
  resource database 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases@2023-11-15' existing = {
@@ -4,6 +4,8 @@ import { AzureCliDiagnostic, azureCliDiagnostic, redactAzureText } from "../../u
4
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
5
  export const DEFAULT_PROVISION_WAIT_SECONDS = 300;
6
6
  export const MAX_PROVISION_WAIT_SECONDS = 3_600;
7
+ /** Consecutive transient state-read failures tolerated while the human CLI waits for a terminal state. */
8
+ export const UNBOUNDED_WAIT_TRANSIENT_RETRIES = 24;
7
9
 
8
10
  export interface DeploymentReference {
9
11
  subscription: string;
@@ -116,9 +118,15 @@ export async function waitForAzureDeployment(
116
118
  onObservation?: (observation: DeploymentObservation) => void;
117
119
  secretValues?: readonly string[];
118
120
  queryTimeoutMs?: number;
121
+ /**
122
+ * Consecutive non-terminal "unknown" reads (query failure, not yet visible) to keep polling through.
123
+ * Required for an unbounded deadline so a missing deployment cannot be polled forever.
124
+ */
125
+ transientRetries?: number;
119
126
  }
120
127
  ): Promise<DeploymentObservation> {
121
128
  let observation: DeploymentObservation;
129
+ let unknownReads = 0;
122
130
  do {
123
131
  const remaining = options.deadline - Date.now();
124
132
  // Even when the wait budget is exhausted, one bounded read can identify an accepted submission.
@@ -130,7 +138,15 @@ export async function waitForAzureDeployment(
130
138
  if (observation.state !== "unknown") reference = observation.reference;
131
139
  options.onObservation?.(observation);
132
140
  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;
141
+ if (observation.state === "unknown") {
142
+ unknownReads += 1;
143
+ const retryable = observation.reason === "not-yet-observed" || observation.reason === "not-found"
144
+ || (options.transientRetries !== undefined && (observation.reason === "query-failed" || observation.reason === "invalid-response"));
145
+ if (!retryable) return observation;
146
+ if (!Number.isFinite(options.deadline) && unknownReads > (options.transientRetries ?? 0)) return observation;
147
+ } else {
148
+ unknownReads = 0;
149
+ }
134
150
  if (Date.now() >= options.deadline || options.signal?.aborted) return observation;
135
151
  await new Promise<void>((resolve) => {
136
152
  const done = () => {
@@ -20,7 +20,7 @@ import { deletePlanState, listProvisionRuns, loadPlanState, loadProvisionRun, lo
20
20
  import { hashFileIfExists } from "./file-session";
21
21
  import {
22
22
  DEFAULT_PROVISION_WAIT_SECONDS, DEPLOYMENT_QUERY, DeploymentObservation, DeploymentReference,
23
- MAX_PROVISION_WAIT_SECONDS, queryAzureDeployment, queryDeploymentOperationErrors, waitForAzureDeployment,
23
+ MAX_PROVISION_WAIT_SECONDS, queryAzureDeployment, queryDeploymentOperationErrors, UNBOUNDED_WAIT_TRANSIENT_RETRIES, waitForAzureDeployment,
24
24
  } from "./provision-deployment";
25
25
  import {
26
26
  MIGRATION_MANIFEST_PATH,
@@ -625,6 +625,8 @@ export interface ApplyProvisionOptions {
625
625
  planId: string;
626
626
  approve?: boolean;
627
627
  waitSeconds?: number;
628
+ /** Human CLI: keep polling until Azure reaches a terminal state instead of using the bounded wait budget. */
629
+ waitUntilComplete?: boolean;
628
630
  }
629
631
 
630
632
  export interface ApplyProvisionResult {
@@ -848,7 +850,10 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
848
850
  );
849
851
  }
850
852
 
851
- const result = await executeProvisionPlan(plan, { approve: options.approve, waitSeconds: options.waitSeconds, retryHint: MACHINE_RETRY_HINT, projectRoot });
853
+ const result = await executeProvisionPlan(plan, {
854
+ approve: options.approve, waitSeconds: options.waitSeconds, waitUntilComplete: options.waitUntilComplete,
855
+ retryHint: MACHINE_RETRY_HINT, projectRoot,
856
+ });
852
857
  if (result.status === "complete") {
853
858
  try { deletePlanState(plan.planId, projectRoot); } catch {
854
859
  result.warnings.push("Azure and migration bookkeeping completed, but the old preview could not be removed. The completed execution checkpoint prevents replay.");
@@ -863,11 +868,17 @@ export async function applyProvisionOperation(options: ApplyProvisionOptions): P
863
868
  */
864
869
  export async function executeProvisionPlan(
865
870
  plan: ProvisionPlanData,
866
- options: { approve?: boolean; retryHint?: string; projectRoot?: string; waitSeconds?: number; signal?: AbortSignal; pollIntervalMs?: number } = {}
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
+ } = {}
867
877
  ): Promise<ApplyProvisionResult> {
868
878
  const projectRoot = options.projectRoot ?? process.cwd();
869
879
  const retryHint = options.retryHint ?? MACHINE_RETRY_HINT;
870
- const waitSeconds = validateWaitSeconds(options.waitSeconds);
880
+ const waitUntilComplete = options.waitUntilComplete === true && options.waitSeconds === undefined;
881
+ const waitSeconds = waitUntilComplete ? 0 : validateWaitSeconds(options.waitSeconds);
871
882
  if (!options.approve) {
872
883
  throw new MachineCommandError(
873
884
  "approval-required",
@@ -937,7 +948,7 @@ export async function executeProvisionPlan(
937
948
  options.signal?.addEventListener("abort", externalAbort, { once: true });
938
949
  if (options.signal?.aborted) controller.abort();
939
950
  const signal = controller.signal;
940
- const deadline = Date.now() + waitSeconds * 1_000;
951
+ const deadline = waitUntilComplete ? Number.POSITIVE_INFINITY : Date.now() + waitSeconds * 1_000;
941
952
  const persist = () => {
942
953
  if (!run) return;
943
954
  run.warnings = run.warnings.slice(-20);
@@ -958,6 +969,7 @@ export async function executeProvisionPlan(
958
969
  ...(signal.aborted ? {} : { signal }),
959
970
  ...(signal.aborted ? { queryTimeoutMs: 5_000 } : {}),
960
971
  pollIntervalMs: options.pollIntervalMs,
972
+ ...(waitUntilComplete ? { transientRetries: UNBOUNDED_WAIT_TRANSIENT_RETRIES } : {}),
961
973
  secretValues,
962
974
  onObservation: (value) => {
963
975
  run!.observation = value;
@@ -233,7 +233,7 @@ export function inspectCapabilities(): CapabilitiesContract {
233
233
  "(regular CLI) configure-mcp [--version auto|latest|<semver>] — re-select the MCP launcher version in .mcp.json",
234
234
  ],
235
235
  provisioning: {
236
- execution: "Submit Bicep once with az deployment group create --no-wait, then bounded state polling (default 300 seconds, configurable 0-3600). Azure CLI invocations are bounded separately. Warning-only stderr with exit 0 is not a failure; output capture limits do not terminate az.",
236
+ execution: "Submit Bicep once with az deployment group create --no-wait, then bounded state polling (default 300 seconds, configurable 0-3600) for machine/MCP; the human `swallowkit provision` command waits until Azure reaches a terminal state. Azure CLI invocations are bounded separately. Warning-only stderr with exit 0 is not a failure; output capture limits do not terminate az.",
237
237
  completion: "status complete/outcome succeeded requires Azure Succeeded plus verified migration tags and local ledger completion. Running/Accepted -> in-progress; unreadable/not-found state -> blocked/outcome unknown; bookkeeping interruption after Azure Succeeded -> blocked/outcome partial; Failed/Canceled -> failed with Azure causes.",
238
238
  tracking: "Approved runs persist before Azure writes under .swallowkit/state/provision/. runId equals the original planId and preserves the subscription, RG, deployment ID/correlation, fingerprints and migration state. inspect provision is read-only; apply provision --plan resumes without re-submitting attempted deployments. Ctrl+C/termination may leave Azure running; never cancel/delete without explicit consent.",
239
239
  recovery: "For an older untracked baseline, plan provision --resume-deployment <name> compares the existing ARM template and non-secure parameters to the local Bicep/parameters. It observes that deployment and only tags/records it after Succeeded. Mismatches, unreadable evidence, secure parameters or non-baseline migrations block; do not use reconcile just to repair bookkeeping.",