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,274 @@
|
|
|
1
|
+
import * as crypto from "crypto";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { MachineCommandError } from "../../machine/errors";
|
|
5
|
+
import { hashContent } from "../operations/file-session";
|
|
6
|
+
|
|
7
|
+
export const MIGRATION_MANIFEST_VERSION = 1;
|
|
8
|
+
export const MIGRATION_MANIFEST_PATH = path.join("infra", "migrations", "manifest.json");
|
|
9
|
+
|
|
10
|
+
export interface MigrationBaselineFile {
|
|
11
|
+
path: string;
|
|
12
|
+
checksum: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MigrationBaseline {
|
|
16
|
+
version: 0;
|
|
17
|
+
legacy: boolean;
|
|
18
|
+
files: MigrationBaselineFile[];
|
|
19
|
+
checksum: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface InfrastructureMigration {
|
|
23
|
+
version: number;
|
|
24
|
+
slug: string;
|
|
25
|
+
template: string;
|
|
26
|
+
parameters?: string;
|
|
27
|
+
source: "model" | "custom";
|
|
28
|
+
sourceModel?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MigrationManifest {
|
|
32
|
+
schemaVersion: 1;
|
|
33
|
+
projectId: string;
|
|
34
|
+
baseline: MigrationBaseline;
|
|
35
|
+
migrations: InfrastructureMigration[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ResolvedMigration extends InfrastructureMigration {
|
|
39
|
+
checksum: string;
|
|
40
|
+
cumulativeChecksum: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MigrationState {
|
|
44
|
+
schemaVersion: 1;
|
|
45
|
+
version: number;
|
|
46
|
+
checksum: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function fail(code: string, message: string, details?: unknown): never {
|
|
50
|
+
throw new MachineCommandError(code, message, details, "blocked");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function normalizeRelativePath(value: string): string {
|
|
54
|
+
const normalized = value.replace(/\\/g, "/");
|
|
55
|
+
if (path.posix.isAbsolute(normalized) || normalized === ".." || normalized.startsWith("../") || normalized.includes("/../")) {
|
|
56
|
+
fail("invalid-migration-manifest", `Migration path must stay inside the project: ${value}`);
|
|
57
|
+
}
|
|
58
|
+
return normalized;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function readRequiredFile(projectRoot: string, relativePath: string): string {
|
|
62
|
+
const normalized = normalizeRelativePath(relativePath);
|
|
63
|
+
const absolutePath = path.resolve(projectRoot, normalized);
|
|
64
|
+
const rootWithSeparator = `${path.resolve(projectRoot)}${path.sep}`;
|
|
65
|
+
if (!absolutePath.startsWith(rootWithSeparator) || !fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
|
|
66
|
+
fail("migration-file-not-found", `Migration file not found: ${normalized}`);
|
|
67
|
+
}
|
|
68
|
+
const realPath = fs.realpathSync(absolutePath);
|
|
69
|
+
if (!realPath.startsWith(rootWithSeparator)) {
|
|
70
|
+
fail("invalid-migration-manifest", `Migration file resolves outside the project: ${normalized}`);
|
|
71
|
+
}
|
|
72
|
+
return fs.readFileSync(absolutePath, "utf-8");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checksumObject(value: unknown): string {
|
|
76
|
+
return hashContent(JSON.stringify(value));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function computeBaselineChecksum(files: MigrationBaselineFile[]): string {
|
|
80
|
+
return checksumObject([...files].sort((left, right) => left.path.localeCompare(right.path)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function findBaselineDrift(manifest: MigrationManifest, projectRoot: string = process.cwd()): string[] {
|
|
84
|
+
return manifest.baseline.files
|
|
85
|
+
.filter((file) => {
|
|
86
|
+
const absolutePath = path.join(projectRoot, normalizeRelativePath(file.path));
|
|
87
|
+
return !fs.existsSync(absolutePath) || hashContent(fs.readFileSync(absolutePath, "utf-8")) !== file.checksum;
|
|
88
|
+
})
|
|
89
|
+
.map((file) => file.path);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function collectLegacyBaselineFiles(projectRoot: string = process.cwd()): MigrationBaselineFile[] {
|
|
93
|
+
const infraRoot = path.join(projectRoot, "infra");
|
|
94
|
+
if (!fs.existsSync(path.join(infraRoot, "main.bicep"))) {
|
|
95
|
+
fail("infra-not-found", 'infra/main.bicep not found. Run this command from a SwallowKit project.');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const files: MigrationBaselineFile[] = [];
|
|
99
|
+
const visit = (directory: string): void => {
|
|
100
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
101
|
+
const absolutePath = path.join(directory, entry.name);
|
|
102
|
+
if (entry.isDirectory()) {
|
|
103
|
+
if (entry.name !== "migrations") visit(absolutePath);
|
|
104
|
+
} else if (entry.isFile() && (entry.name.endsWith(".bicep") || entry.name.endsWith(".json"))) {
|
|
105
|
+
const relativePath = path.relative(projectRoot, absolutePath).replace(/\\/g, "/");
|
|
106
|
+
files.push({ path: relativePath, checksum: hashContent(fs.readFileSync(absolutePath, "utf-8")) });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
visit(infraRoot);
|
|
111
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function createMigrationManifest(
|
|
115
|
+
projectRoot: string = process.cwd(),
|
|
116
|
+
options: { legacy?: boolean; projectId?: string } = {}
|
|
117
|
+
): MigrationManifest {
|
|
118
|
+
const files = collectLegacyBaselineFiles(projectRoot);
|
|
119
|
+
return {
|
|
120
|
+
schemaVersion: MIGRATION_MANIFEST_VERSION,
|
|
121
|
+
projectId: options.projectId ?? crypto.randomUUID(),
|
|
122
|
+
baseline: {
|
|
123
|
+
version: 0,
|
|
124
|
+
legacy: options.legacy ?? false,
|
|
125
|
+
files,
|
|
126
|
+
checksum: computeBaselineChecksum(files),
|
|
127
|
+
},
|
|
128
|
+
migrations: [],
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function saveMigrationManifest(manifest: MigrationManifest, projectRoot: string = process.cwd()): void {
|
|
133
|
+
const manifestPath = path.join(projectRoot, MIGRATION_MANIFEST_PATH);
|
|
134
|
+
if (fs.existsSync(manifestPath)) {
|
|
135
|
+
fail("migration-manifest-exists", `${MIGRATION_MANIFEST_PATH} already exists.`);
|
|
136
|
+
}
|
|
137
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
138
|
+
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function replaceMigrationManifest(manifest: MigrationManifest, projectRoot: string = process.cwd()): void {
|
|
142
|
+
validateMigrationManifest(manifest, projectRoot);
|
|
143
|
+
const manifestPath = path.join(projectRoot, MIGRATION_MANIFEST_PATH);
|
|
144
|
+
const temporaryPath = `${manifestPath}.tmp-${process.pid}`;
|
|
145
|
+
fs.mkdirSync(path.dirname(manifestPath), { recursive: true });
|
|
146
|
+
fs.writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8");
|
|
147
|
+
fs.renameSync(temporaryPath, manifestPath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function loadMigrationManifest(projectRoot: string = process.cwd()): MigrationManifest {
|
|
151
|
+
const manifestPath = path.join(projectRoot, MIGRATION_MANIFEST_PATH);
|
|
152
|
+
if (!fs.existsSync(manifestPath)) {
|
|
153
|
+
fail("migration-manifest-not-found", `${MIGRATION_MANIFEST_PATH} not found. Initialize migrations first.`);
|
|
154
|
+
}
|
|
155
|
+
let manifest: MigrationManifest;
|
|
156
|
+
try {
|
|
157
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as MigrationManifest;
|
|
158
|
+
} catch {
|
|
159
|
+
fail("invalid-migration-manifest", `${MIGRATION_MANIFEST_PATH} is not valid JSON.`);
|
|
160
|
+
}
|
|
161
|
+
validateMigrationManifest(manifest, projectRoot);
|
|
162
|
+
return manifest;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function validateMigrationManifest(manifest: MigrationManifest, projectRoot: string = process.cwd()): void {
|
|
166
|
+
if (
|
|
167
|
+
!manifest || typeof manifest !== "object" ||
|
|
168
|
+
manifest.schemaVersion !== MIGRATION_MANIFEST_VERSION ||
|
|
169
|
+
typeof manifest.projectId !== "string" || !manifest.projectId ||
|
|
170
|
+
!manifest.baseline || typeof manifest.baseline !== "object" ||
|
|
171
|
+
manifest.baseline.version !== 0 || typeof manifest.baseline.legacy !== "boolean" ||
|
|
172
|
+
!Array.isArray(manifest.baseline.files) || typeof manifest.baseline.checksum !== "string" ||
|
|
173
|
+
!Array.isArray(manifest.migrations)
|
|
174
|
+
) {
|
|
175
|
+
fail("invalid-migration-manifest", "Unsupported or incomplete migration manifest.");
|
|
176
|
+
}
|
|
177
|
+
for (const file of manifest.baseline.files) {
|
|
178
|
+
if (!file || typeof file.path !== "string" || !/^[a-f0-9]{64}$/.test(file.checksum)) {
|
|
179
|
+
fail("invalid-migration-manifest", "Migration baseline contains an invalid file record.");
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const baselineFiles = manifest.baseline.files.map((file) => ({
|
|
183
|
+
path: normalizeRelativePath(file.path),
|
|
184
|
+
checksum: file.checksum,
|
|
185
|
+
}));
|
|
186
|
+
if (new Set(baselineFiles.map((file) => file.path)).size !== baselineFiles.length) {
|
|
187
|
+
fail("invalid-migration-manifest", "Migration baseline contains duplicate paths.");
|
|
188
|
+
}
|
|
189
|
+
if (computeBaselineChecksum(baselineFiles) !== manifest.baseline.checksum) {
|
|
190
|
+
fail("migration-checksum-mismatch", "The migration baseline inventory was modified.");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const sorted = [...manifest.migrations].sort((left, right) => left.version - right.version);
|
|
194
|
+
for (let index = 0; index < sorted.length; index += 1) {
|
|
195
|
+
const migration = sorted[index];
|
|
196
|
+
if (!migration || !Number.isSafeInteger(migration.version) || typeof migration.slug !== "string" || typeof migration.template !== "string") {
|
|
197
|
+
fail("invalid-migration-manifest", "Migration manifest contains an invalid entry.");
|
|
198
|
+
}
|
|
199
|
+
const expectedVersion = index + 1;
|
|
200
|
+
if (migration.version !== expectedVersion || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(migration.slug)) {
|
|
201
|
+
fail("invalid-migration-manifest", `Migration versions must be contiguous and slugs must be kebab-case (expected version ${expectedVersion}).`);
|
|
202
|
+
}
|
|
203
|
+
if (migration.source !== "model" && migration.source !== "custom") {
|
|
204
|
+
fail("invalid-migration-manifest", `Migration ${migration.version} has an invalid source.`);
|
|
205
|
+
}
|
|
206
|
+
const template = normalizeRelativePath(migration.template);
|
|
207
|
+
if (!template.startsWith("infra/migrations/") || !template.endsWith(".bicep")) {
|
|
208
|
+
fail("invalid-migration-manifest", `Migration template must be a .bicep file under infra/migrations: ${template}`);
|
|
209
|
+
}
|
|
210
|
+
readRequiredFile(projectRoot, template);
|
|
211
|
+
if (migration.parameters) {
|
|
212
|
+
const parameters = normalizeRelativePath(migration.parameters);
|
|
213
|
+
if (!parameters.startsWith("infra/migrations/") || !parameters.endsWith(".json")) {
|
|
214
|
+
fail("invalid-migration-manifest", `Migration parameters must be JSON under infra/migrations: ${parameters}`);
|
|
215
|
+
}
|
|
216
|
+
readRequiredFile(projectRoot, parameters);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function resolveMigrations(manifest: MigrationManifest, projectRoot: string = process.cwd()): ResolvedMigration[] {
|
|
222
|
+
validateMigrationManifest(manifest, projectRoot);
|
|
223
|
+
let cumulativeChecksum = manifest.baseline.checksum;
|
|
224
|
+
return [...manifest.migrations]
|
|
225
|
+
.sort((left, right) => left.version - right.version)
|
|
226
|
+
.map((migration) => {
|
|
227
|
+
const templateContent = readRequiredFile(projectRoot, migration.template);
|
|
228
|
+
const parametersContent = migration.parameters ? readRequiredFile(projectRoot, migration.parameters) : null;
|
|
229
|
+
const checksum = checksumObject({ migration, templateContent, parametersContent });
|
|
230
|
+
cumulativeChecksum = checksumObject({ previous: cumulativeChecksum, version: migration.version, checksum });
|
|
231
|
+
return { ...migration, checksum, cumulativeChecksum };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function checksumAtVersion(manifest: MigrationManifest, version: number, projectRoot: string = process.cwd()): string {
|
|
236
|
+
if (version === 0) return manifest.baseline.checksum;
|
|
237
|
+
const migration = resolveMigrations(manifest, projectRoot).find((item) => item.version === version);
|
|
238
|
+
if (!migration) fail("migration-version-not-found", `Migration version ${version} does not exist locally.`);
|
|
239
|
+
return migration.cumulativeChecksum;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function getMigrationTagKey(projectId: string): string {
|
|
243
|
+
const projectHash = crypto.createHash("sha256").update(projectId).digest("hex").slice(0, 12);
|
|
244
|
+
return `swallowkit-migration-${projectHash}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export function encodeMigrationState(state: MigrationState): string {
|
|
248
|
+
if (state.schemaVersion !== 1 || !Number.isSafeInteger(state.version) || state.version < 0 || !/^[a-f0-9]{64}$/.test(state.checksum)) {
|
|
249
|
+
fail("invalid-migration-state", "Cannot encode an invalid migration state.");
|
|
250
|
+
}
|
|
251
|
+
return `1:${state.version}:${state.checksum}`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function decodeMigrationState(value: string): MigrationState {
|
|
255
|
+
const match = /^1:(0|[1-9]\d*):([a-f0-9]{64})$/.exec(value);
|
|
256
|
+
if (!match) fail("invalid-migration-state", "The Azure migration state tag is invalid.");
|
|
257
|
+
return { schemaVersion: 1, version: Number(match[1]), checksum: match[2] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function selectPendingMigrations(
|
|
261
|
+
manifest: MigrationManifest,
|
|
262
|
+
remoteState: MigrationState,
|
|
263
|
+
projectRoot: string = process.cwd()
|
|
264
|
+
): ResolvedMigration[] {
|
|
265
|
+
const latestVersion = manifest.migrations.length;
|
|
266
|
+
if (remoteState.version > latestVersion) {
|
|
267
|
+
fail("migration-state-ahead", `Azure is at migration ${remoteState.version}, but local latest is ${latestVersion}.`);
|
|
268
|
+
}
|
|
269
|
+
const expectedChecksum = checksumAtVersion(manifest, remoteState.version, projectRoot);
|
|
270
|
+
if (remoteState.checksum !== expectedChecksum) {
|
|
271
|
+
fail("migration-checksum-mismatch", `Azure migration ${remoteState.version} does not match the local history.`);
|
|
272
|
+
}
|
|
273
|
+
return resolveMigrations(manifest, projectRoot).filter((migration) => migration.version > remoteState.version);
|
|
274
|
+
}
|
|
@@ -187,7 +187,7 @@ Stop and ask a human when:
|
|
|
187
187
|
"Provision Azure resources (Static Web Apps, Functions, Cosmos DB) for a SwallowKit project using the two-phase plan/apply flow with a mandatory human approval gate. Use when asked to deploy infrastructure, provision Azure, create resource groups, or run Bicep deployments.",
|
|
188
188
|
content: `# Workflow: Provision Azure resources
|
|
189
189
|
|
|
190
|
-
Goal:
|
|
190
|
+
Goal: bootstrap Azure once, then apply only pending versioned infrastructure migrations with an explicit human approval gate.
|
|
191
191
|
|
|
192
192
|
## Rules
|
|
193
193
|
|
|
@@ -196,6 +196,8 @@ Goal: deploy the Bicep infrastructure with an explicit human approval gate.
|
|
|
196
196
|
- Never pass \`--approve\` to \`apply provision\` unless a human explicitly
|
|
197
197
|
instructed you to provision in this session.
|
|
198
198
|
- Inspect infra locally first — no \`az\` calls are needed for inspection.
|
|
199
|
+
- Never edit an applied migration. Create a corrective migration instead.
|
|
200
|
+
- Never use \`--reconcile\` for an ordinary model or resource addition.
|
|
199
201
|
|
|
200
202
|
## Steps
|
|
201
203
|
|
|
@@ -204,8 +206,10 @@ Goal: deploy the Bicep infrastructure with an explicit human approval gate.
|
|
|
204
206
|
- review parameters, modules, outputs, and container wiring warnings.
|
|
205
207
|
2. Compute the provisioning plan (no resources are created):
|
|
206
208
|
\`${runCmd} swallowkit machine plan provision -g <resource-group> --location <region>\`
|
|
207
|
-
|
|
208
|
-
|
|
209
|
+
- the plan reports \`bootstrap\`, \`migrate\`, \`noop\`, or \`adoption-required\`.
|
|
210
|
+
- it lists only pending migrations and the exact \`az\` commands that would run.
|
|
211
|
+
- for an older untagged environment, first run \`${runCmd} swallowkit migrations init --legacy-baseline\`, then plan with \`--adopt-existing --baseline 0 --what-if\`.
|
|
212
|
+
- only intentional baseline changes use \`--reconcile --what-if\`; this can reset template-managed settings.
|
|
209
213
|
3. Present the plan to the human and wait for explicit approval.
|
|
210
214
|
4. Only after approval:
|
|
211
215
|
\`${runCmd} swallowkit machine apply provision --plan <planId> --approve\`
|
|
@@ -101,6 +101,57 @@ function toPascalIdentifier(value: string): string {
|
|
|
101
101
|
return value.charAt(0).toUpperCase() + value.slice(1);
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
interface CSharpEnumMember {
|
|
105
|
+
value: string;
|
|
106
|
+
name: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const CSHARP_RESERVED_IDENTIFIERS = new Set([
|
|
110
|
+
"abstract", "as", "base", "bool", "break", "byte", "case", "catch", "char", "checked",
|
|
111
|
+
"class", "const", "continue", "decimal", "default", "delegate", "do", "double", "else",
|
|
112
|
+
"enum", "event", "explicit", "extern", "false", "finally", "fixed", "float", "for", "foreach",
|
|
113
|
+
"goto", "if", "implicit", "in", "int", "interface", "internal", "is", "lock", "long",
|
|
114
|
+
"namespace", "new", "null", "object", "operator", "out", "override", "params", "private",
|
|
115
|
+
"protected", "public", "readonly", "ref", "return", "sbyte", "sealed", "short", "sizeof",
|
|
116
|
+
"stackalloc", "static", "string", "struct", "switch", "this", "throw", "true", "try",
|
|
117
|
+
"typeof", "uint", "ulong", "unchecked", "unsafe", "ushort", "using", "virtual", "void",
|
|
118
|
+
"volatile", "while",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
function toCSharpEnumMemberBase(value: string): string {
|
|
122
|
+
const encoded = Array.from(value)
|
|
123
|
+
.map((character) => {
|
|
124
|
+
if (/^[A-Za-z0-9_-]$/.test(character)) return character;
|
|
125
|
+
if (/^\s$/.test(character)) return "-";
|
|
126
|
+
return `-U${character.codePointAt(0)!.toString(16).toUpperCase()}-`;
|
|
127
|
+
})
|
|
128
|
+
.join("");
|
|
129
|
+
let identifier = toPascalIdentifier(encoded).replace(/[^A-Za-z0-9_]/g, "");
|
|
130
|
+
if (!identifier) identifier = "Value";
|
|
131
|
+
if (/^[0-9]/.test(identifier)) identifier = `Value${identifier}`;
|
|
132
|
+
if (CSHARP_RESERVED_IDENTIFIERS.has(identifier)) identifier = `Value${identifier}`;
|
|
133
|
+
return identifier;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildCSharpEnumMemberMap(values: string[], enumType: string): CSharpEnumMember[] {
|
|
137
|
+
const uniqueValues = [...new Set(values)];
|
|
138
|
+
const bases = uniqueValues.map((value) => ({ value, base: toCSharpEnumMemberBase(value) }));
|
|
139
|
+
const baseCounts = new Map<string, number>();
|
|
140
|
+
for (const entry of bases) baseCounts.set(entry.base, (baseCounts.get(entry.base) ?? 0) + 1);
|
|
141
|
+
|
|
142
|
+
return bases.map(({ value, base }) => {
|
|
143
|
+
let name = base;
|
|
144
|
+
if ((baseCounts.get(base) ?? 0) > 1 || name === enumType) {
|
|
145
|
+
name = `${base}_${Buffer.from(value, "utf-8").toString("hex").toUpperCase() || "Empty"}`;
|
|
146
|
+
}
|
|
147
|
+
return { value, name };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function csharpStringLiteral(value: string): string {
|
|
152
|
+
return JSON.stringify(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
104
155
|
function isDateLikeField(field: Pick<ModelInfo["fields"][number], "name" | "type">): boolean {
|
|
105
156
|
return field.type === "date" || (field.type === "string" && field.name.toLowerCase().endsWith("at"));
|
|
106
157
|
}
|
|
@@ -256,23 +307,27 @@ namespace SwallowKitBackendModels.Client
|
|
|
256
307
|
`;
|
|
257
308
|
}
|
|
258
309
|
|
|
259
|
-
function buildCSharpEnumMembers(
|
|
260
|
-
return
|
|
261
|
-
.map((
|
|
310
|
+
function buildCSharpEnumMembers(members: CSharpEnumMember[]): string {
|
|
311
|
+
return members
|
|
312
|
+
.map((member, index) => ` ${member.name} = ${index + 1}`)
|
|
262
313
|
.join(",\n\n");
|
|
263
314
|
}
|
|
264
315
|
|
|
265
|
-
function buildCSharpEnumFromStringCases(
|
|
316
|
+
function buildCSharpEnumFromStringCases(
|
|
317
|
+
field: ModelInfo["fields"][number],
|
|
318
|
+
members: CSharpEnumMember[],
|
|
319
|
+
nullable: boolean
|
|
320
|
+
): string {
|
|
266
321
|
const enumType = `${toPascalIdentifier(field.name)}Enum`;
|
|
267
|
-
return
|
|
268
|
-
.map((
|
|
322
|
+
return members
|
|
323
|
+
.map((member) => ` if (value.Equals(${csharpStringLiteral(member.value)}, StringComparison.Ordinal))\n return ${enumType}.${member.name};`)
|
|
269
324
|
.join("\n\n") + (nullable ? `\n\n return null;` : `\n\n throw new NotImplementedException($"Could not convert value to type ${enumType}: '{value}'");`);
|
|
270
325
|
}
|
|
271
326
|
|
|
272
|
-
function buildCSharpEnumToJsonCases(field: ModelInfo["fields"][number]): string {
|
|
327
|
+
function buildCSharpEnumToJsonCases(field: ModelInfo["fields"][number], members: CSharpEnumMember[]): string {
|
|
273
328
|
const enumType = `${toPascalIdentifier(field.name)}Enum`;
|
|
274
|
-
return
|
|
275
|
-
.map((
|
|
329
|
+
return members
|
|
330
|
+
.map((member) => ` if (value == ${enumType}.${member.name})\n return ${csharpStringLiteral(member.value)};`)
|
|
276
331
|
.join("\n\n");
|
|
277
332
|
}
|
|
278
333
|
|
|
@@ -295,28 +350,29 @@ function generateLegacyCompatibleCSharpModelSource(model: ModelInfo): string {
|
|
|
295
350
|
.filter((field) => field.enumValues?.length)
|
|
296
351
|
.map((field) => {
|
|
297
352
|
const enumType = `${toPascalIdentifier(field.name)}Enum`;
|
|
353
|
+
const enumMembers = buildCSharpEnumMemberMap(field.enumValues!, enumType);
|
|
298
354
|
return ` /// <summary>
|
|
299
355
|
/// Defines ${toPascalIdentifier(field.name)}
|
|
300
356
|
/// </summary>
|
|
301
357
|
[JsonConverter(typeof(JsonStringEnumConverter))]
|
|
302
358
|
public enum ${enumType}
|
|
303
359
|
{
|
|
304
|
-
${buildCSharpEnumMembers(
|
|
360
|
+
${buildCSharpEnumMembers(enumMembers)}
|
|
305
361
|
}
|
|
306
362
|
|
|
307
363
|
public static ${enumType} ${enumType}FromString(string value)
|
|
308
364
|
{
|
|
309
|
-
${buildCSharpEnumFromStringCases(field, false)}
|
|
365
|
+
${buildCSharpEnumFromStringCases(field, enumMembers, false)}
|
|
310
366
|
}
|
|
311
367
|
|
|
312
368
|
public static ${enumType}? ${enumType}FromStringOrDefault(string value)
|
|
313
369
|
{
|
|
314
|
-
${buildCSharpEnumFromStringCases(field, true)}
|
|
370
|
+
${buildCSharpEnumFromStringCases(field, enumMembers, true)}
|
|
315
371
|
}
|
|
316
372
|
|
|
317
373
|
public static string ${enumType}ToJsonValue(${enumType}? value)
|
|
318
374
|
{
|
|
319
|
-
${buildCSharpEnumToJsonCases(field)}
|
|
375
|
+
${buildCSharpEnumToJsonCases(field, enumMembers)}
|
|
320
376
|
|
|
321
377
|
throw new NotImplementedException($"Value could not be handled: '{value}'");
|
|
322
378
|
}`;
|
package/src/machine/index.ts
CHANGED
|
@@ -366,7 +366,10 @@ function createMachineProgram(): Command {
|
|
|
366
366
|
.requiredOption("--swa-location <region>", "Static Web App location")
|
|
367
367
|
.option("--subscription <id>", "Azure subscription GUID")
|
|
368
368
|
.option("--what-if", "Run az deployment group what-if (requires az login)", false)
|
|
369
|
-
.
|
|
369
|
+
.option("--adopt-existing", "Adopt an existing untagged resource group without deploying main.bicep", false)
|
|
370
|
+
.option("--baseline <version>", "Baseline version for adoption", (value) => Number(value))
|
|
371
|
+
.option("--reconcile", "Explicitly redeploy infra/main.bicep", false)
|
|
372
|
+
.action(async (options: { resourceGroup: string; location: string; swaLocation: string; subscription?: string; whatIf?: boolean; adoptExisting?: boolean; baseline?: number; reconcile?: boolean }) => {
|
|
370
373
|
await handleMachineAction(
|
|
371
374
|
"plan-provision",
|
|
372
375
|
async () => {
|
|
@@ -377,6 +380,9 @@ function createMachineProgram(): Command {
|
|
|
377
380
|
swaLocation: options.swaLocation,
|
|
378
381
|
subscription: options.subscription,
|
|
379
382
|
whatIf: options.whatIf,
|
|
383
|
+
adoptExisting: options.adoptExisting,
|
|
384
|
+
baseline: options.baseline,
|
|
385
|
+
reconcile: options.reconcile,
|
|
380
386
|
});
|
|
381
387
|
},
|
|
382
388
|
(planData) => ({
|
package/src/mcp/index.ts
CHANGED
|
@@ -352,18 +352,24 @@ export function buildSwallowKitToolDefinitions(
|
|
|
352
352
|
},
|
|
353
353
|
{
|
|
354
354
|
name: "swallowkit_plan_provision",
|
|
355
|
-
description: "
|
|
355
|
+
description: "Plan Azure bootstrap or pending infrastructure migrations. Existing untagged environments require explicit adoption with what-if; full main.bicep redeployment requires reconcile=true. Never deploys and always requires human approval.",
|
|
356
356
|
inputSchema: z.object({
|
|
357
357
|
resourceGroup: z.string(),
|
|
358
358
|
location: z.string(),
|
|
359
359
|
swaLocation: z.string(),
|
|
360
360
|
subscription: z.string().optional(),
|
|
361
361
|
whatIf: z.boolean().optional(),
|
|
362
|
+
adoptExisting: z.boolean().optional(),
|
|
363
|
+
baseline: z.number().int().nonnegative().optional(),
|
|
364
|
+
reconcile: z.boolean().optional(),
|
|
362
365
|
}),
|
|
363
|
-
handler: async ({ resourceGroup, location, swaLocation, subscription, whatIf }: { resourceGroup: string; location: string; swaLocation: string; subscription?: string; whatIf?: boolean }) => {
|
|
366
|
+
handler: async ({ resourceGroup, location, swaLocation, subscription, whatIf, adoptExisting, baseline, reconcile }: { resourceGroup: string; location: string; swaLocation: string; subscription?: string; whatIf?: boolean; adoptExisting?: boolean; baseline?: number; reconcile?: boolean }) => {
|
|
364
367
|
const args = ["plan", "provision", "--resource-group", resourceGroup, "--location", location, "--swa-location", swaLocation];
|
|
365
368
|
if (subscription) args.push("--subscription", subscription);
|
|
366
369
|
if (whatIf) args.push("--what-if");
|
|
370
|
+
if (adoptExisting) args.push("--adopt-existing");
|
|
371
|
+
if (baseline !== undefined) args.push("--baseline", String(baseline));
|
|
372
|
+
if (reconcile) args.push("--reconcile");
|
|
367
373
|
|
|
368
374
|
const response = await executeMachineCommand(args, runMachineCli);
|
|
369
375
|
return jsonTextContent(withOperationStatus(response));
|