swallowkit 1.0.2 → 1.1.0

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 (77) hide show
  1. package/dist/cli/commands/create-migration.d.ts +3 -0
  2. package/dist/cli/commands/create-migration.d.ts.map +1 -0
  3. package/dist/cli/commands/create-migration.js +16 -0
  4. package/dist/cli/commands/create-migration.js.map +1 -0
  5. package/dist/cli/commands/index.d.ts +2 -0
  6. package/dist/cli/commands/index.d.ts.map +1 -1
  7. package/dist/cli/commands/index.js +5 -1
  8. package/dist/cli/commands/index.js.map +1 -1
  9. package/dist/cli/commands/init.d.ts.map +1 -1
  10. package/dist/cli/commands/init.js +2 -0
  11. package/dist/cli/commands/init.js.map +1 -1
  12. package/dist/cli/commands/migrations.d.ts +3 -0
  13. package/dist/cli/commands/migrations.d.ts.map +1 -0
  14. package/dist/cli/commands/migrations.js +22 -0
  15. package/dist/cli/commands/migrations.js.map +1 -0
  16. package/dist/cli/commands/provision.d.ts +1 -1
  17. package/dist/cli/commands/provision.d.ts.map +1 -1
  18. package/dist/cli/commands/provision.js +95 -185
  19. package/dist/cli/commands/provision.js.map +1 -1
  20. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  21. package/dist/cli/commands/scaffold.js +6 -130
  22. package/dist/cli/commands/scaffold.js.map +1 -1
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +4 -0
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/core/operations/migration-generator.d.ts +10 -0
  27. package/dist/core/operations/migration-generator.d.ts.map +1 -0
  28. package/dist/core/operations/migration-generator.js +194 -0
  29. package/dist/core/operations/migration-generator.js.map +1 -0
  30. package/dist/core/operations/provision-operations.d.ts +19 -0
  31. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  32. package/dist/core/operations/provision-operations.js +299 -30
  33. package/dist/core/operations/provision-operations.js.map +1 -1
  34. package/dist/core/project/capabilities.js +1 -1
  35. package/dist/core/project/capabilities.js.map +1 -1
  36. package/dist/core/project/infra.d.ts +4 -0
  37. package/dist/core/project/infra.d.ts.map +1 -1
  38. package/dist/core/project/infra.js +19 -1
  39. package/dist/core/project/infra.js.map +1 -1
  40. package/dist/core/project/migrations.d.ts +53 -0
  41. package/dist/core/project/migrations.d.ts.map +1 -0
  42. package/dist/core/project/migrations.js +262 -0
  43. package/dist/core/project/migrations.js.map +1 -0
  44. package/dist/core/project/workflows.d.ts.map +1 -1
  45. package/dist/core/project/workflows.js +7 -3
  46. package/dist/core/project/workflows.js.map +1 -1
  47. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  48. package/dist/core/scaffold/native-schema-generator.js +61 -13
  49. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  50. package/dist/machine/index.d.ts.map +1 -1
  51. package/dist/machine/index.js +6 -0
  52. package/dist/machine/index.js.map +1 -1
  53. package/dist/mcp/index.d.ts.map +1 -1
  54. package/dist/mcp/index.js +11 -2
  55. package/dist/mcp/index.js.map +1 -1
  56. package/package.json +1 -1
  57. package/src/__tests__/agent-loop-phase34.test.ts +139 -0
  58. package/src/__tests__/init.test.ts +8 -0
  59. package/src/__tests__/migration.test.ts +116 -0
  60. package/src/__tests__/plan-apply.test.ts +41 -11
  61. package/src/__tests__/scaffold.test.ts +7 -4
  62. package/src/cli/commands/create-migration.ts +13 -0
  63. package/src/cli/commands/index.ts +2 -0
  64. package/src/cli/commands/init.ts +2 -0
  65. package/src/cli/commands/migrations.ts +20 -0
  66. package/src/cli/commands/provision.ts +115 -184
  67. package/src/cli/commands/scaffold.ts +6 -145
  68. package/src/cli/index.ts +4 -0
  69. package/src/core/operations/migration-generator.ts +188 -0
  70. package/src/core/operations/provision-operations.ts +391 -44
  71. package/src/core/project/capabilities.ts +1 -1
  72. package/src/core/project/infra.ts +23 -1
  73. package/src/core/project/migrations.ts +274 -0
  74. package/src/core/project/workflows.ts +7 -3
  75. package/src/core/scaffold/native-schema-generator.ts +69 -13
  76. package/src/machine/index.ts +7 -1
  77. package/src/mcp/index.ts +8 -2
@@ -0,0 +1,116 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import {
5
+ checksumAtVersion,
6
+ collectLegacyBaselineFiles,
7
+ createMigrationManifest,
8
+ decodeMigrationState,
9
+ encodeMigrationState,
10
+ getMigrationTagKey,
11
+ findBaselineDrift,
12
+ loadMigrationManifest,
13
+ resolveMigrations,
14
+ saveMigrationManifest,
15
+ selectPendingMigrations,
16
+ } from "../core/project/migrations";
17
+
18
+ function writeFile(filePath: string, content: string): void {
19
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
20
+ fs.writeFileSync(filePath, content, "utf-8");
21
+ }
22
+
23
+ describe("infrastructure migrations", () => {
24
+ let projectRoot: string;
25
+
26
+ beforeEach(() => {
27
+ projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-migrations-"));
28
+ writeFile(path.join(projectRoot, "infra", "main.bicep"), "targetScope = 'resourceGroup'\n");
29
+ writeFile(path.join(projectRoot, "infra", "main.parameters.json"), '{"parameters":{}}\n');
30
+ writeFile(path.join(projectRoot, "infra", "modules", "functions.bicep"), "// functions\n");
31
+ });
32
+
33
+ afterEach(() => fs.rmSync(projectRoot, { recursive: true, force: true }));
34
+
35
+ it("records a deterministic legacy baseline without including migrations", () => {
36
+ writeFile(path.join(projectRoot, "infra", "migrations", "ignored.bicep"), "// ignored\n");
37
+
38
+ const first = createMigrationManifest(projectRoot, { legacy: true, projectId: "project-one" });
39
+ const second = createMigrationManifest(projectRoot, { legacy: true, projectId: "project-one" });
40
+
41
+ expect(first.baseline.legacy).toBe(true);
42
+ expect(first.baseline.files.map((file) => file.path)).toEqual([
43
+ "infra/main.bicep",
44
+ "infra/main.parameters.json",
45
+ "infra/modules/functions.bicep",
46
+ ]);
47
+ expect(first.baseline.checksum).toBe(second.baseline.checksum);
48
+ });
49
+
50
+ it("saves and loads a manifest without overwriting it", () => {
51
+ const manifest = createMigrationManifest(projectRoot, { legacy: true, projectId: "project-one" });
52
+ saveMigrationManifest(manifest, projectRoot);
53
+
54
+ expect(loadMigrationManifest(projectRoot)).toEqual(manifest);
55
+ expect(() => saveMigrationManifest(manifest, projectRoot)).toThrow(/already exists/);
56
+ });
57
+
58
+ it("computes cumulative checksums and selects only pending migrations", () => {
59
+ const manifest = createMigrationManifest(projectRoot, { projectId: "project-one" });
60
+ writeFile(path.join(projectRoot, "infra", "migrations", "0001-products", "main.bicep"), "resource product 'Example/items@2020-01-01' = {}\n");
61
+ writeFile(path.join(projectRoot, "infra", "migrations", "0002-search", "main.bicep"), "resource search 'Example/search@2020-01-01' = {}\n");
62
+ manifest.migrations = [
63
+ { version: 1, slug: "products", template: "infra/migrations/0001-products/main.bicep", source: "model", sourceModel: "Product" },
64
+ { version: 2, slug: "search", template: "infra/migrations/0002-search/main.bicep", source: "custom" },
65
+ ];
66
+
67
+ const resolved = resolveMigrations(manifest, projectRoot);
68
+ const pending = selectPendingMigrations(manifest, {
69
+ schemaVersion: 1,
70
+ version: 1,
71
+ checksum: resolved[0].cumulativeChecksum,
72
+ }, projectRoot);
73
+
74
+ expect(resolved[0].checksum).toHaveLength(64);
75
+ expect(resolved[1].cumulativeChecksum).not.toBe(resolved[0].cumulativeChecksum);
76
+ expect(pending.map((migration) => migration.version)).toEqual([2]);
77
+ expect(checksumAtVersion(manifest, 0, projectRoot)).toBe(manifest.baseline.checksum);
78
+ });
79
+
80
+ it("rejects gaps, traversal, remote-ahead state, and checksum drift", () => {
81
+ const manifest = createMigrationManifest(projectRoot, { projectId: "project-one" });
82
+ manifest.migrations = [{ version: 2, slug: "bad", template: "../bad.bicep", source: "custom" }];
83
+ expect(() => resolveMigrations(manifest, projectRoot)).toThrow(/contiguous/);
84
+
85
+ manifest.migrations = [];
86
+ expect(() => selectPendingMigrations(manifest, {
87
+ schemaVersion: 1,
88
+ version: 1,
89
+ checksum: manifest.baseline.checksum,
90
+ }, projectRoot)).toThrow(/Azure is at migration 1/);
91
+ expect(() => selectPendingMigrations(manifest, {
92
+ schemaVersion: 1,
93
+ version: 0,
94
+ checksum: "0".repeat(64),
95
+ }, projectRoot)).toThrow(/does not match/);
96
+ });
97
+
98
+ it("round-trips project-specific Azure tag state", () => {
99
+ const state = { schemaVersion: 1 as const, version: 12, checksum: "a".repeat(64) };
100
+ expect(decodeMigrationState(encodeMigrationState(state))).toEqual(state);
101
+ expect(getMigrationTagKey("project-one")).toMatch(/^swallowkit-migration-[a-f0-9]{12}$/);
102
+ expect(getMigrationTagKey("project-one")).not.toBe(getMigrationTagKey("project-two"));
103
+ expect(() => decodeMigrationState("broken")).toThrow(/invalid/);
104
+ });
105
+
106
+ it("collects current baseline file hashes", () => {
107
+ expect(collectLegacyBaselineFiles(projectRoot)).toHaveLength(3);
108
+ });
109
+
110
+ it("detects edits to baseline files after initialization", () => {
111
+ const manifest = createMigrationManifest(projectRoot, { projectId: "project-one" });
112
+ writeFile(path.join(projectRoot, "infra", "main.bicep"), "// changed\n");
113
+
114
+ expect(findBaselineDrift(manifest, projectRoot)).toEqual(["infra/main.bicep"]);
115
+ });
116
+ });
@@ -4,6 +4,7 @@ import * as path from "path";
4
4
  import * as childProcess from "child_process";
5
5
  import { EventEmitter } from "events";
6
6
  import { runMachineCli } from "../machine";
7
+ import { getCSharpSchemaModelPath } from "../core/scaffold/native-schema-generator";
7
8
 
8
9
  jest.mock("child_process", () => {
9
10
  const actual = jest.requireActual<typeof import("child_process")>("child_process");
@@ -329,8 +330,8 @@ describe("plan / apply / drift / verify machine commands", () => {
329
330
  `import { z } from 'zod/v4';
330
331
  export const LearningQuestion = z.object({
331
332
  id: z.string(),
332
- kind: z.enum(['grammar', 'vocabulary']),
333
- level: z.literal('5'),
333
+ kind: z.enum(['grammar', 'in_progress', '123', '5級', 'a-b', 'a_b', 'class']),
334
+ examLevel: z.literal('5'),
334
335
  createdAt: z.string().optional(),
335
336
  updatedAt: z.string().optional(),
336
337
  });
@@ -355,21 +356,50 @@ export type LearningQuestion = z.infer<typeof LearningQuestion>;
355
356
  fs.readFileSync(path.join(tempDir, "functions", "openapi", "learning-question.openapi.json"), "utf-8")
356
357
  );
357
358
  const properties = openApi.components.schemas.LearningQuestion.properties;
358
- expect(properties.kind.enum).toEqual(["grammar", "vocabulary"]);
359
- expect(properties.level.enum).toEqual(["5"]);
359
+ expect(properties.kind.enum).toEqual(["grammar", "in_progress", "123", "5級", "a-b", "a_b", "class"]);
360
+ expect(properties.examLevel.enum).toEqual(["5"]);
360
361
 
361
- const csharp = fs.readFileSync(
362
- path.join(tempDir, "functions", "generated", "csharp-models", "src", "SwallowKitBackendModels", "Model", "LearningQuestion.cs"),
363
- "utf-8"
364
- );
362
+ const csharpOutputDir = path.join(tempDir, "functions", "generated", "csharp-models");
363
+ const csharp = fs.readFileSync(getCSharpSchemaModelPath(csharpOutputDir, "LearningQuestion"), "utf-8");
365
364
  expect(csharp).toContain("public enum KindEnum");
366
- expect(csharp).toContain("public enum LevelEnum");
365
+ expect(csharp).toContain("public enum ExamLevelEnum");
366
+ expect(csharp).toContain("Grammar = 1");
367
+ expect(csharp).toContain("InProgress = 2");
368
+ expect(csharp).toContain("Value123 = 3");
369
+ expect(csharp).toContain("Value5U7D1A = 4");
370
+ expect(csharp).toContain("AB_612D62 = 5");
371
+ expect(csharp).toContain("AB_615F62 = 6");
372
+ expect(csharp).toContain("Class = 7");
373
+ expect(csharp).toContain("Value5 = 1");
374
+ expect(csharp).toContain('value.Equals("5", StringComparison.Ordinal)');
375
+ expect(csharp).toContain("return ExamLevelEnum.Value5;");
376
+ expect(csharp).toContain('return "5";');
377
+ expect(csharp).toContain('value.Equals("a-b", StringComparison.Ordinal)');
378
+ expect(csharp).toContain("return KindEnum.AB_612D62;");
379
+ expect(csharp).toContain("if (value == KindEnum.AB_615F62)");
380
+ expect(csharp).toContain('return "a_b";');
367
381
  expect(csharp).not.toContain("Dictionary<string, object> Kind");
368
- expect(csharp).not.toContain("Dictionary<string, object> Level");
382
+ expect(csharp).not.toContain("Dictionary<string, object> ExamLevel");
383
+
384
+ writeFile(
385
+ path.join(csharpOutputDir, "SwallowKitBackendModels.csproj"),
386
+ `<Project Sdk="Microsoft.NET.Sdk">
387
+ <PropertyGroup>
388
+ <TargetFramework>net10.0</TargetFramework>
389
+ <Nullable>enable</Nullable>
390
+ </PropertyGroup>
391
+ </Project>
392
+ `
393
+ );
394
+ expect(() => childProcess.execFileSync("dotnet", ["build", "--nologo", "--verbosity", "quiet"], {
395
+ cwd: csharpOutputDir,
396
+ stdio: "pipe",
397
+ timeout: 120_000,
398
+ })).not.toThrow();
369
399
  } finally {
370
400
  restoreCodegenMocks();
371
401
  }
372
- });
402
+ }, 150_000);
373
403
 
374
404
  it("leaves the project untouched when the native toolchain validation fails on apply", async () => {
375
405
  createNativeBackendFixture(tempDir, "csharp");
@@ -16,6 +16,7 @@ import {
16
16
  getPythonSchemaModelPath,
17
17
  } from "../core/scaffold/native-schema-generator";
18
18
  import { createBasicModelInfo } from "./fixtures";
19
+ import { createMigrationManifest, saveMigrationManifest } from "../core/project/migrations";
19
20
 
20
21
  describe("authenticated UI scaffold layouts", () => {
21
22
  const originalCwd = process.cwd();
@@ -215,6 +216,7 @@ describe("generateCosmosContainer", () => {
215
216
  process.chdir(tempDir);
216
217
  fs.mkdirSync(path.join(tempDir, "infra", "containers"), { recursive: true });
217
218
  fs.writeFileSync(path.join(tempDir, "infra", "main.bicep"), "module cosmosDbServerless 'modules/cosmosdb-serverless.bicep' = if (cosmosDbMode == 'serverless') {\n name: 'cosmosDb'\n}\n");
219
+ saveMigrationManifest(createMigrationManifest(tempDir, { projectId: "test-project" }), tempDir);
218
220
  });
219
221
 
220
222
  afterEach(() => {
@@ -222,18 +224,19 @@ describe("generateCosmosContainer", () => {
222
224
  fs.rmSync(tempDir, { recursive: true, force: true });
223
225
  });
224
226
 
225
- it("generates container templates without container-level throughput settings", async () => {
227
+ it("generates a container migration without changing main.bicep", async () => {
228
+ const mainBefore = fs.readFileSync(path.join(tempDir, "infra", "main.bicep"), "utf-8");
226
229
  await generateCosmosContainer({ name: "SurveyResponse", partitionKey: "/id" });
227
230
 
228
- const containerFilePath = path.join(tempDir, "infra", "containers", "survey-response-container.bicep");
231
+ const containerFilePath = path.join(tempDir, "infra", "migrations", "0001-create-survey-response-container", "main.bicep");
229
232
  const containerContent = fs.readFileSync(containerFilePath, "utf-8");
230
233
  const mainContent = fs.readFileSync(path.join(tempDir, "infra", "main.bicep"), "utf-8");
231
234
 
232
235
  expect(containerContent).toContain("resource container");
236
+ expect(containerContent).toContain("existing");
233
237
  expect(containerContent).not.toContain("throughput");
234
238
  expect(containerContent).not.toContain("options:");
235
- expect(mainContent).toContain("module surveyResponseContainer 'containers/survey-response-container.bicep'");
236
- expect(mainContent).not.toContain("dependsOn");
239
+ expect(mainContent).toBe(mainBefore);
237
240
  });
238
241
  });
239
242
  describe("non-destructive native schema generation", () => {
@@ -0,0 +1,13 @@
1
+ import { Command } from "commander";
2
+ import { ensureSwallowKitProject } from "../../core/config";
3
+ import { createCustomMigration } from "../../core/operations/migration-generator";
4
+
5
+ export const createMigrationCommand = new Command("create-migration")
6
+ .description("Create a versioned Bicep migration for additional Azure resources")
7
+ .argument("<slug>", "Kebab-case migration name")
8
+ .action((slug: string) => {
9
+ ensureSwallowKitProject("create-migration");
10
+ const migration = createCustomMigration(slug);
11
+ console.log(`Created migration ${migration.version}: ${migration.template}`);
12
+ console.log("Edit the Bicep file, then run provision to plan and apply it.");
13
+ });
@@ -7,3 +7,5 @@ export { devCommand } from "./dev";
7
7
  export { devSeedsCommand } from "./dev-seeds";
8
8
  export { scaffoldCommand } from "./scaffold";
9
9
  export { createModelCommand } from "./create-model";
10
+ export { createMigrationCommand } from "./create-migration";
11
+ export { migrationsCommand } from "./migrations";
@@ -19,6 +19,7 @@ import {
19
19
  import { syncProjectManifest } from "../../core/project/manifest";
20
20
  import { writeWorkflowDocs, writeAgentSkills } from "../../core/project/workflows";
21
21
  import { writeAgentInstructionFiles } from "../../core/project/agent-instructions";
22
+ import { initializeNewProjectMigrations } from "../../core/operations/migration-generator";
22
23
  import {
23
24
  buildCSharpCodegenToolManifestSource,
24
25
  buildPythonCodegenRequirementsSource,
@@ -2759,6 +2760,7 @@ output privateDnsZoneId string = privateDnsZone.id
2759
2760
 
2760
2761
  console.log('✅ VNet module definitions created\n');
2761
2762
 
2763
+ initializeNewProjectMigrations(projectDir);
2762
2764
  console.log('✅ Infrastructure files created\n');
2763
2765
  }
2764
2766
 
@@ -0,0 +1,20 @@
1
+ import { Command } from "commander";
2
+ import { ensureSwallowKitProject } from "../../core/config";
3
+ import { initializeLegacyMigrations } from "../../core/operations/migration-generator";
4
+
5
+ export const migrationsCommand = new Command("migrations")
6
+ .description("Manage versioned Azure infrastructure migrations");
7
+
8
+ migrationsCommand
9
+ .command("init")
10
+ .description("Initialize migration tracking for an existing SwallowKit project")
11
+ .option("--legacy-baseline", "Record the current infra directory as version 0")
12
+ .action((options: { legacyBaseline?: boolean }) => {
13
+ ensureSwallowKitProject("migrations init");
14
+ if (!options.legacyBaseline) {
15
+ throw new Error("Existing projects must be initialized with --legacy-baseline.");
16
+ }
17
+ const manifest = initializeLegacyMigrations();
18
+ console.log(`Migration baseline 0 created for project ${manifest.projectId}.`);
19
+ console.log("Commit infra/migrations/manifest.json, then run provision with --adopt-existing --baseline 0.");
20
+ });
@@ -1,193 +1,124 @@
1
- import { Command } from 'commander';
2
- import { execSync } from 'child_process';
3
- import * as fs from 'fs';
4
- import * as path from 'path';
5
- import prompts from 'prompts';
6
- import { ensureSwallowKitProject } from '../../core/config';
7
-
8
- export const provisionCommand = new Command('provision')
9
- .description('Provision Azure resources using Bicep templates')
10
- .requiredOption('-g, --resource-group <name>', 'Resource group name')
11
- .option('--subscription <id>', 'Azure subscription ID (optional)')
12
- .action(async (options) => {
13
- // SwallowKit プロジェクトディレクトリかどうかを検証
1
+ import { Command } from "commander";
2
+ import prompts from "prompts";
3
+ import { ensureSwallowKitProject } from "../../core/config";
4
+ import { applyProvisionOperation, planProvisionOperation } from "../../core/operations/provision-operations";
5
+
6
+ const PRIMARY_REGIONS = [
7
+ { title: "Japan East (japaneast)", value: "japaneast" },
8
+ { title: "Japan West (japanwest)", value: "japanwest" },
9
+ { title: "East Asia (eastasia)", value: "eastasia" },
10
+ { title: "Southeast Asia (southeastasia)", value: "southeastasia" },
11
+ { title: "East US (eastus)", value: "eastus" },
12
+ { title: "East US 2 (eastus2)", value: "eastus2" },
13
+ { title: "West US 2 (westus2)", value: "westus2" },
14
+ { title: "Central US (centralus)", value: "centralus" },
15
+ { title: "West Europe (westeurope)", value: "westeurope" },
16
+ ];
17
+
18
+ const SWA_REGIONS = [
19
+ { title: "East Asia (eastasia) - Recommended for Japan", value: "eastasia" },
20
+ { title: "West US 2 (westus2)", value: "westus2" },
21
+ { title: "Central US (centralus)", value: "centralus" },
22
+ { title: "East US 2 (eastus2)", value: "eastus2" },
23
+ { title: "West Europe (westeurope)", value: "westeurope" },
24
+ ];
25
+
26
+ interface ProvisionCliOptions {
27
+ resourceGroup: string;
28
+ subscription?: string;
29
+ location?: string;
30
+ swaLocation?: string;
31
+ whatIf?: boolean;
32
+ adoptExisting?: boolean;
33
+ baseline?: string;
34
+ reconcile?: boolean;
35
+ approve?: boolean;
36
+ }
37
+
38
+ async function resolveLocations(options: ProvisionCliOptions): Promise<{ location: string; swaLocation: string }> {
39
+ if (options.location && options.swaLocation) {
40
+ return { location: options.location, swaLocation: options.swaLocation };
41
+ }
42
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
43
+ throw new Error("Non-interactive provision requires --location and --swa-location.");
44
+ }
45
+ const answer = await prompts([
46
+ {
47
+ type: options.location ? null : "select",
48
+ name: "location",
49
+ message: "Primary location for Functions and Cosmos DB",
50
+ choices: PRIMARY_REGIONS,
51
+ initial: 0,
52
+ },
53
+ {
54
+ type: options.swaLocation ? null : "select",
55
+ name: "swaLocation",
56
+ message: "Static Web App location",
57
+ choices: SWA_REGIONS,
58
+ initial: 0,
59
+ },
60
+ ]);
61
+ const location = options.location ?? answer.location;
62
+ const swaLocation = options.swaLocation ?? answer.swaLocation;
63
+ if (!location || !swaLocation) throw new Error("Region selection cancelled.");
64
+ return { location, swaLocation };
65
+ }
66
+
67
+ export const provisionCommand = new Command("provision")
68
+ .description("Bootstrap, adopt, migrate, or explicitly reconcile Azure resources")
69
+ .requiredOption("-g, --resource-group <name>", "Resource group name")
70
+ .option("--subscription <id>", "Azure subscription ID")
71
+ .option("--location <region>", "Primary location for Functions and Cosmos DB")
72
+ .option("--swa-location <region>", "Static Web App location")
73
+ .option("--what-if", "Include Azure what-if evidence", false)
74
+ .option("--adopt-existing", "Adopt an existing untagged resource group without deploying main.bicep", false)
75
+ .option("--baseline <version>", "Baseline version for adoption (currently 0)")
76
+ .option("--reconcile", "Explicitly redeploy infra/main.bicep", false)
77
+ .option("--approve", "Apply the displayed plan without an interactive confirmation", false)
78
+ .action(async (options: ProvisionCliOptions) => {
14
79
  ensureSwallowKitProject("provision");
15
-
16
- console.log('🚀 Starting Azure resource provisioning...\n');
17
-
18
- // Check if Azure CLI is installed
19
- try {
20
- execSync('az --version', { stdio: 'ignore' });
21
- } catch {
22
- console.error('❌ Azure CLI is not installed. Please install it first: https://aka.ms/azure-cli');
23
- process.exit(1);
80
+ const locations = await resolveLocations(options);
81
+ const baseline = options.baseline === undefined ? undefined : Number(options.baseline);
82
+ const plan = await planProvisionOperation({
83
+ resourceGroup: options.resourceGroup,
84
+ subscription: options.subscription,
85
+ location: locations.location,
86
+ swaLocation: locations.swaLocation,
87
+ whatIf: options.whatIf,
88
+ adoptExisting: options.adoptExisting,
89
+ baseline,
90
+ reconcile: options.reconcile,
91
+ });
92
+
93
+ console.log(`Provision mode: ${plan.mode}`);
94
+ for (const warning of plan.warnings) console.warn(`Warning: ${warning}`);
95
+ if (plan.pendingMigrations.length > 0) {
96
+ console.log(`Pending migrations: ${plan.pendingMigrations.map((migration) => `${migration.version}-${migration.slug}`).join(", ")}`);
24
97
  }
98
+ for (const command of plan.commands) console.log(` ${command}`);
25
99
 
26
- // Check if Bicep files exist
27
- const infraDir = path.join(process.cwd(), 'infra');
28
- const mainBicepPath = path.join(infraDir, 'main.bicep');
29
- const parametersPath = path.join(infraDir, 'main.parameters.json');
30
-
31
- if (!fs.existsSync(mainBicepPath)) {
32
- console.error('❌ Bicep files not found. Run "swallowkit init" first to generate infrastructure files.');
33
- process.exit(1);
100
+ if (plan.mode === "adoption-required") {
101
+ throw new Error('Existing resource group requires: migrations init --legacy-baseline, then provision --adopt-existing --baseline 0 --what-if.');
34
102
  }
35
103
 
36
- try {
37
- // Prompt for region selection
38
- console.log('📍 Select Azure regions for your resources:\n');
39
-
40
- const regionChoices = await prompts([
41
- {
42
- type: 'select',
43
- name: 'primaryLocation',
44
- message: 'Primary location for Functions and Cosmos DB',
45
- choices: [
46
- { title: 'Japan East (japaneast)', value: 'japaneast' },
47
- { title: 'Japan West (japanwest)', value: 'japanwest' },
48
- { title: 'East Asia (eastasia)', value: 'eastasia' },
49
- { title: 'Southeast Asia (southeastasia)', value: 'southeastasia' },
50
- { title: 'East US (eastus)', value: 'eastus' },
51
- { title: 'East US 2 (eastus2)', value: 'eastus2' },
52
- { title: 'West US 2 (westus2)', value: 'westus2' },
53
- { title: 'Central US (centralus)', value: 'centralus' },
54
- { title: 'West Europe (westeurope)', value: 'westeurope' },
55
- ],
56
- initial: 0, // Default to japaneast
57
- },
58
- {
59
- type: 'select',
60
- name: 'swaLocation',
61
- message: 'Static Web App location (limited availability)',
62
- choices: [
63
- { title: 'East Asia (eastasia) - Recommended for Japan', value: 'eastasia' },
64
- { title: 'West US 2 (westus2)', value: 'westus2' },
65
- { title: 'Central US (centralus)', value: 'centralus' },
66
- { title: 'East US 2 (eastus2)', value: 'eastus2' },
67
- { title: 'West Europe (westeurope)', value: 'westeurope' },
68
- ],
69
- initial: 0, // Default to eastasia
70
- },
71
- ], {
72
- onCancel: () => {
73
- throw new Error('User cancelled');
74
- }
75
- });
76
-
77
- if (!regionChoices.primaryLocation || !regionChoices.swaLocation) {
78
- console.log('\n❌ Region selection cancelled.');
79
- process.exit(1);
104
+ let approved = options.approve === true;
105
+ if (!approved) {
106
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
107
+ throw new Error("Non-interactive provision requires --approve after reviewing the plan.");
80
108
  }
81
-
82
- const primaryLocation = regionChoices.primaryLocation;
83
- const swaLocation = regionChoices.swaLocation;
84
-
85
- console.log(`\n✓ Primary location: ${primaryLocation}`);
86
- console.log(`✓ Static Web App location: ${swaLocation}`);
87
-
88
- // Confirmation prompt to prevent accidental wrong selection
89
- const confirmation = await prompts({
90
- type: 'confirm',
91
- name: 'proceed',
92
- message: `Proceed with deployment to ${primaryLocation} (primary) and ${swaLocation} (SWA)?`,
93
- initial: true
94
- }, {
95
- onCancel: () => {
96
- throw new Error('User cancelled');
97
- }
109
+ const answer = await prompts({
110
+ type: "confirm",
111
+ name: "approved",
112
+ message: `Apply the ${plan.mode} plan?`,
113
+ initial: false,
98
114
  });
99
-
100
- if (!confirmation.proceed) {
101
- console.log('\n❌ Deployment cancelled by user.');
102
- process.exit(0);
103
- }
104
-
105
- console.log('');
106
- // Set subscription if provided
107
- if (options.subscription) {
108
- console.log(`🔧 Setting subscription: ${options.subscription}`);
109
- execSync(`az account set --subscription ${options.subscription}`, { stdio: 'inherit' });
110
- }
111
-
112
- // Create resource group if it doesn't exist (use primary location)
113
- console.log(`🔧 Ensuring resource group exists: ${options.resourceGroup}`);
114
- execSync(
115
- `az group create --name ${options.resourceGroup} --location ${primaryLocation}`,
116
- { stdio: 'inherit' }
117
- );
118
-
119
- // Deploy Bicep template with both locations
120
- console.log('\n📦 Deploying resources (this may take several minutes)...\n');
121
- const deployCommand = `az deployment group create --resource-group ${options.resourceGroup} --template-file "${mainBicepPath}" --parameters "${parametersPath}" --parameters location=${primaryLocation} --parameters swaLocation=${swaLocation}`;
122
-
123
- const output = execSync(deployCommand, { encoding: 'utf-8', stdio: 'pipe' });
124
- const deployment = JSON.parse(output);
125
-
126
- // Display outputs
127
- console.log('\n✅ Deployment completed successfully!\n');
128
- console.log('📋 Resource Information:');
129
-
130
- let swaName = '<swa-name>';
131
- let functionAppName = '<function-name>';
132
- if (deployment.properties?.outputs) {
133
- const outputs = deployment.properties.outputs;
134
- if (outputs.staticWebAppName) {
135
- swaName = outputs.staticWebAppName.value;
136
- console.log(` - Static Web App: ${swaName}`);
137
- console.log(` - URL: https://${outputs.staticWebAppUrl.value}`);
138
- }
139
- if (outputs.functionsAppName) {
140
- functionAppName = outputs.functionsAppName.value;
141
- console.log(` - Function App: ${functionAppName}`);
142
- console.log(` - URL: https://${outputs.functionsAppUrl.value}`);
143
- }
144
- if (outputs.cosmosDbAccountName) {
145
- console.log(` - Cosmos DB: ${outputs.cosmosDbAccountName.value}`);
146
- console.log(` - Database: ${outputs.cosmosDatabaseName.value}`);
147
- }
148
- }
149
-
150
- // Next steps guidance
151
- console.log('\n📝 Next Steps:');
152
- console.log(' 1. Configure CI/CD secrets/variables:\n');
153
-
154
- // Fetch SWA deployment token
155
- console.log(' [AZURE_STATIC_WEB_APPS_API_TOKEN]');
156
- try {
157
- const swaToken = execSync(
158
- `az staticwebapp secrets list --name ${swaName} --resource-group ${options.resourceGroup} --query "properties.apiKey" -o tsv`,
159
- { encoding: 'utf-8', stdio: 'pipe' }
160
- ).trim();
161
- console.log(` ${swaToken}\n`);
162
- } catch {
163
- console.log(' ⚠️ Failed to retrieve. Run manually:');
164
- console.log(` az staticwebapp secrets list --name ${swaName} --resource-group ${options.resourceGroup} --query "properties.apiKey" -o tsv\n`);
165
- }
166
-
167
- // Function App name (already known)
168
- console.log(' [AZURE_FUNCTIONAPP_NAME]');
169
- console.log(` ${functionAppName}\n`);
170
-
171
- // Fetch Function App publish profile
172
- console.log(' [AZURE_FUNCTIONAPP_PUBLISH_PROFILE]');
173
- try {
174
- const publishProfile = execSync(
175
- `az webapp deployment list-publishing-profiles --name ${functionAppName} --resource-group ${options.resourceGroup} --xml`,
176
- { encoding: 'utf-8', stdio: 'pipe' }
177
- ).trim();
178
- console.log(` ${publishProfile}\n`);
179
- } catch {
180
- console.log(' ⚠️ Failed to retrieve. Run manually:');
181
- console.log(` az webapp deployment list-publishing-profiles --name ${functionAppName} --resource-group ${options.resourceGroup} --xml\n`);
182
- }
183
-
184
- console.log(' 2. Set up your CI/CD pipeline (GitHub Actions or Azure Pipelines)');
185
- console.log(' 3. Manually trigger the first deployment in your CI/CD pipeline');
186
- console.log(' (Automatic deployments will run on subsequent pushes)\n');
187
-
188
- } catch (error: any) {
189
- console.error('❌ Deployment failed:');
190
- console.error(error.message);
191
- process.exit(1);
115
+ approved = answer.approved === true;
116
+ }
117
+ if (!approved) {
118
+ console.log("Provision cancelled.");
119
+ return;
192
120
  }
121
+
122
+ const result = await applyProvisionOperation({ planId: plan.planId, approve: true });
123
+ console.log(`Provision completed (${plan.mode}). ${result.executedCommands.length} command(s) executed.`);
193
124
  });