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.
- package/dist/cli/commands/create-migration.d.ts +3 -0
- package/dist/cli/commands/create-migration.d.ts.map +1 -0
- package/dist/cli/commands/create-migration.js +16 -0
- package/dist/cli/commands/create-migration.js.map +1 -0
- package/dist/cli/commands/index.d.ts +2 -0
- package/dist/cli/commands/index.d.ts.map +1 -1
- package/dist/cli/commands/index.js +5 -1
- package/dist/cli/commands/index.js.map +1 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +2 -0
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/migrations.d.ts +3 -0
- package/dist/cli/commands/migrations.d.ts.map +1 -0
- package/dist/cli/commands/migrations.js +22 -0
- package/dist/cli/commands/migrations.js.map +1 -0
- package/dist/cli/commands/provision.d.ts +1 -1
- package/dist/cli/commands/provision.d.ts.map +1 -1
- package/dist/cli/commands/provision.js +95 -185
- package/dist/cli/commands/provision.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +6 -130
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/core/operations/migration-generator.d.ts +10 -0
- package/dist/core/operations/migration-generator.d.ts.map +1 -0
- package/dist/core/operations/migration-generator.js +194 -0
- package/dist/core/operations/migration-generator.js.map +1 -0
- package/dist/core/operations/provision-operations.d.ts +19 -0
- package/dist/core/operations/provision-operations.d.ts.map +1 -1
- package/dist/core/operations/provision-operations.js +299 -30
- package/dist/core/operations/provision-operations.js.map +1 -1
- package/dist/core/project/capabilities.js +1 -1
- package/dist/core/project/capabilities.js.map +1 -1
- package/dist/core/project/infra.d.ts +4 -0
- package/dist/core/project/infra.d.ts.map +1 -1
- package/dist/core/project/infra.js +19 -1
- package/dist/core/project/infra.js.map +1 -1
- package/dist/core/project/migrations.d.ts +53 -0
- package/dist/core/project/migrations.d.ts.map +1 -0
- package/dist/core/project/migrations.js +262 -0
- package/dist/core/project/migrations.js.map +1 -0
- package/dist/core/project/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +7 -3
- package/dist/core/project/workflows.js.map +1 -1
- package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
- package/dist/core/scaffold/native-schema-generator.js +61 -13
- package/dist/core/scaffold/native-schema-generator.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +6 -0
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +11 -2
- package/dist/mcp/index.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/agent-loop-phase34.test.ts +139 -0
- package/src/__tests__/init.test.ts +8 -0
- package/src/__tests__/migration.test.ts +116 -0
- package/src/__tests__/plan-apply.test.ts +41 -11
- package/src/__tests__/scaffold.test.ts +7 -4
- package/src/cli/commands/create-migration.ts +13 -0
- package/src/cli/commands/index.ts +2 -0
- package/src/cli/commands/init.ts +2 -0
- package/src/cli/commands/migrations.ts +20 -0
- package/src/cli/commands/provision.ts +115 -184
- package/src/cli/commands/scaffold.ts +6 -145
- package/src/cli/index.ts +4 -0
- package/src/core/operations/migration-generator.ts +188 -0
- package/src/core/operations/provision-operations.ts +391 -44
- package/src/core/project/capabilities.ts +1 -1
- package/src/core/project/infra.ts +23 -1
- package/src/core/project/migrations.ts +274 -0
- package/src/core/project/workflows.ts +7 -3
- package/src/core/scaffold/native-schema-generator.ts +69 -13
- package/src/machine/index.ts +7 -1
- 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', '
|
|
333
|
-
|
|
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", "
|
|
359
|
-
expect(properties.
|
|
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
|
|
362
|
-
|
|
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
|
|
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>
|
|
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
|
|
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", "
|
|
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).
|
|
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";
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -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
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
});
|