swallowkit 2.0.0-alpha.3 → 2.0.0-alpha.4
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/configure-mcp.d.ts +12 -0
- package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
- package/dist/cli/commands/configure-mcp.js +42 -0
- package/dist/cli/commands/configure-mcp.js.map +1 -0
- package/dist/cli/commands/init.d.ts +4 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +16 -4
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/migrations.d.ts.map +1 -1
- package/dist/cli/commands/migrations.js +25 -0
- package/dist/cli/commands/migrations.js.map +1 -1
- package/dist/cli/commands/provision.d.ts +2 -0
- package/dist/cli/commands/provision.d.ts.map +1 -1
- package/dist/cli/commands/provision.js +45 -16
- package/dist/cli/commands/provision.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +4 -0
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +40 -2
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +27 -2
- package/dist/cli/index.js.map +1 -1
- package/dist/core/operations/model-migration.d.ts +22 -0
- package/dist/core/operations/model-migration.d.ts.map +1 -0
- package/dist/core/operations/model-migration.js +36 -0
- package/dist/core/operations/model-migration.js.map +1 -0
- package/dist/core/operations/provision-operations.d.ts +19 -1
- package/dist/core/operations/provision-operations.d.ts.map +1 -1
- package/dist/core/operations/provision-operations.js +62 -35
- package/dist/core/operations/provision-operations.js.map +1 -1
- package/dist/core/operations/scaffold-apply.d.ts +1 -0
- package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
- package/dist/core/operations/scaffold-apply.js +1 -0
- package/dist/core/operations/scaffold-apply.js.map +1 -1
- package/dist/core/operations/scaffold-machine.d.ts +1 -0
- package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
- package/dist/core/operations/scaffold-machine.js.map +1 -1
- package/dist/core/operations/scaffold-plan.d.ts +2 -0
- package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
- package/dist/core/operations/scaffold-plan.js +3 -1
- package/dist/core/operations/scaffold-plan.js.map +1 -1
- package/dist/core/project/boundaries.d.ts +5 -2
- package/dist/core/project/boundaries.d.ts.map +1 -1
- package/dist/core/project/boundaries.js +49 -18
- package/dist/core/project/boundaries.js.map +1 -1
- package/dist/core/project/capabilities.d.ts +1 -0
- package/dist/core/project/capabilities.d.ts.map +1 -1
- package/dist/core/project/capabilities.js +20 -2
- package/dist/core/project/capabilities.js.map +1 -1
- package/dist/core/project/mcp-config.d.ts +26 -0
- package/dist/core/project/mcp-config.d.ts.map +1 -0
- package/dist/core/project/mcp-config.js +117 -0
- package/dist/core/project/mcp-config.js.map +1 -0
- package/dist/core/project/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +9 -1
- package/dist/core/project/workflows.js.map +1 -1
- package/dist/core/scaffold/model-parser.d.ts +23 -0
- package/dist/core/scaffold/model-parser.d.ts.map +1 -1
- package/dist/core/scaffold/model-parser.js +407 -137
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/dist/core/scaffold/native-schema-generator.d.ts +11 -0
- package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
- package/dist/core/scaffold/native-schema-generator.js +273 -75
- package/dist/core/scaffold/native-schema-generator.js.map +1 -1
- package/dist/core/scaffold/openapi-generator.d.ts +5 -0
- package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
- package/dist/core/scaffold/openapi-generator.js +53 -3
- package/dist/core/scaffold/openapi-generator.js.map +1 -1
- package/dist/core/scaffold/ui-generator.d.ts +0 -3
- package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
- package/dist/core/scaffold/ui-generator.js +68 -7
- package/dist/core/scaffold/ui-generator.js.map +1 -1
- package/dist/core/verify/index.d.ts.map +1 -1
- package/dist/core/verify/index.js +24 -1
- package/dist/core/verify/index.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +39 -15
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts +3 -2
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +137 -13
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/version-selection.d.ts +55 -0
- package/dist/mcp/version-selection.d.ts.map +1 -0
- package/dist/mcp/version-selection.js +152 -0
- package/dist/mcp/version-selection.js.map +1 -0
- package/dist/utils/azure-cli.d.ts +44 -0
- package/dist/utils/azure-cli.d.ts.map +1 -0
- package/dist/utils/azure-cli.js +164 -0
- package/dist/utils/azure-cli.js.map +1 -0
- package/dist/utils/semver.d.ts +14 -0
- package/dist/utils/semver.d.ts.map +1 -0
- package/dist/utils/semver.js +61 -0
- package/dist/utils/semver.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
- package/src/__tests__/agent-loop-phase34.test.ts +29 -0
- package/src/__tests__/azure-cli.test.ts +199 -0
- package/src/__tests__/boundaries.test.ts +42 -0
- package/src/__tests__/csharp-schema.test.ts +261 -0
- package/src/__tests__/mcp-version.test.ts +262 -0
- package/src/__tests__/mcp.test.ts +49 -3
- package/src/__tests__/plan-apply.test.ts +59 -0
- package/src/__tests__/schema-analysis.test.ts +329 -0
- package/src/cli/commands/configure-mcp.ts +54 -0
- package/src/cli/commands/init.ts +19 -4
- package/src/cli/commands/migrations.ts +22 -0
- package/src/cli/commands/provision.ts +53 -17
- package/src/cli/commands/scaffold.ts +40 -2
- package/src/cli/index.ts +26 -3
- package/src/core/operations/model-migration.ts +52 -0
- package/src/core/operations/provision-operations.ts +86 -42
- package/src/core/operations/scaffold-apply.ts +2 -0
- package/src/core/operations/scaffold-machine.ts +1 -0
- package/src/core/operations/scaffold-plan.ts +5 -1
- package/src/core/project/agent-templates/AGENTS.md +39 -3
- package/src/core/project/agent-templates/CLAUDE.md +3 -1
- package/src/core/project/agent-templates/copilot-instructions.md +3 -1
- package/src/core/project/boundaries.ts +60 -18
- package/src/core/project/capabilities.ts +22 -2
- package/src/core/project/mcp-config.ts +84 -0
- package/src/core/project/workflows.ts +9 -1
- package/src/core/scaffold/model-parser.ts +413 -129
- package/src/core/scaffold/native-schema-generator.ts +297 -78
- package/src/core/scaffold/openapi-generator.ts +63 -5
- package/src/core/scaffold/ui-generator.ts +70 -8
- package/src/core/verify/index.ts +23 -1
- package/src/machine/index.ts +42 -18
- package/src/mcp/index.ts +156 -14
- package/src/mcp/version-selection.ts +163 -0
- package/src/utils/azure-cli.ts +154 -0
- package/src/utils/semver.ts +59 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { parseModelFile } from "../core/scaffold/model-parser";
|
|
5
|
+
import { generateOpenApiDocument } from "../core/scaffold/openapi-generator";
|
|
6
|
+
import { generateListPage, generateFormComponent } from "../core/scaffold/ui-generator";
|
|
7
|
+
import { generateLanguageSchemaArtifacts, getPythonRegexWarnings, toPythonRegex } from "../core/scaffold/native-schema-generator";
|
|
8
|
+
import { generateDetailPage } from "../core/scaffold/ui-generator";
|
|
9
|
+
import { spawnSync } from "child_process";
|
|
10
|
+
import { FileOperationSession } from "../core/operations/file-session";
|
|
11
|
+
import { buildUserScopedDataWarning } from "../cli/commands/scaffold";
|
|
12
|
+
import { inspectCapabilities } from "../core/project/capabilities";
|
|
13
|
+
|
|
14
|
+
const repoRoot = path.resolve(__dirname, "..", "..");
|
|
15
|
+
|
|
16
|
+
function model(name: string, fields: string, extra = ""): string {
|
|
17
|
+
const pascal = name.replace(/(^|-)([a-z])/g, (_, __, char: string) => char.toUpperCase());
|
|
18
|
+
return `import { z } from 'zod/v4';
|
|
19
|
+
export const ${pascal} = z.object({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
${fields}
|
|
22
|
+
});
|
|
23
|
+
export type ${pascal} = z.infer<typeof ${pascal}>;
|
|
24
|
+
${extra}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("foreign-key inference", () => {
|
|
28
|
+
let root: string;
|
|
29
|
+
const write = (name: string, source: string) => {
|
|
30
|
+
const file = path.join(root, "shared", "models", `${name}.ts`);
|
|
31
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
32
|
+
fs.writeFileSync(file, source);
|
|
33
|
+
return file;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-fk-"));
|
|
38
|
+
});
|
|
39
|
+
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
40
|
+
|
|
41
|
+
it("does not treat userId as a foreign key when no User model exists", async () => {
|
|
42
|
+
const info = await parseModelFile(write("probe-item", model("probe-item", " userId: z.string(),\n title: z.string(),", "export const partitionKey = '/userId';\n")));
|
|
43
|
+
const userId = info.fields.find((field) => field.name === "userId")!;
|
|
44
|
+
expect(userId.isForeignKey).toBeUndefined();
|
|
45
|
+
expect(userId.referencedModel).toBeUndefined();
|
|
46
|
+
expect(info.parserWarnings).toEqual([]);
|
|
47
|
+
|
|
48
|
+
const list = generateListPage(info, "@sample/shared");
|
|
49
|
+
const form = generateFormComponent(info, "@sample/shared");
|
|
50
|
+
expect(list).not.toMatch(/api\/user|userMap/);
|
|
51
|
+
expect(form).not.toMatch(/api\/user|userOptions/);
|
|
52
|
+
const schema = JSON.parse(generateOpenApiDocument([info], info)).components.schemas;
|
|
53
|
+
expect(schema.ProbeItem.properties.userId).toEqual({ type: "string" });
|
|
54
|
+
expect(Object.keys(schema)).toEqual(["ProbeItem"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("infers a foreign key when the referenced model exists", async () => {
|
|
58
|
+
write("category", model("category", " name: z.string(),"));
|
|
59
|
+
const info = await parseModelFile(write("todo", model("todo", " categoryId: z.string(),")));
|
|
60
|
+
expect(info.fields.find((field) => field.name === "categoryId")).toMatchObject({ isForeignKey: true, referencedModel: "Category" });
|
|
61
|
+
expect(generateListPage(info, "@sample/shared")).toContain("/api/category");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("supports explicit references, opt-out, and warns about missing targets", async () => {
|
|
65
|
+
write("user", model("user", " name: z.string(),"));
|
|
66
|
+
const info = await parseModelFile(write("post", model(
|
|
67
|
+
"post",
|
|
68
|
+
" authorId: z.string(),\n userId: z.string(),\n reviewerId: z.string(),\n tagIds: z.array(z.string()),",
|
|
69
|
+
"export const references = { authorId: 'User', userId: false, reviewerId: 'Reviewer', tagIds: 'Tag', missingId: 'User' };\n"
|
|
70
|
+
)));
|
|
71
|
+
const field = (name: string) => info.fields.find((candidate) => candidate.name === name)!;
|
|
72
|
+
expect(field("authorId")).toMatchObject({ isForeignKey: true, referencedModel: "User" });
|
|
73
|
+
expect(field("userId").isForeignKey).toBeUndefined();
|
|
74
|
+
expect(field("reviewerId").isForeignKey).toBeUndefined();
|
|
75
|
+
expect(field("tagIds").isForeignKey).toBeUndefined();
|
|
76
|
+
expect(info.parserWarnings).toEqual([
|
|
77
|
+
"reference-model-not-found: Post.reviewerId references Reviewer, but shared/models has no Reviewer model; it is treated as a plain field.",
|
|
78
|
+
"reference-field-unsupported: Post.tagIds must be a single string id to reference Tag.",
|
|
79
|
+
"reference-field-not-found: Post.references declares missingId, but the schema has no such field.",
|
|
80
|
+
]);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("declares one lookup per referenced model when several fields reference it", async () => {
|
|
84
|
+
write("user", model("user", " name: z.string(),"));
|
|
85
|
+
const info = await parseModelFile(write("review", model(
|
|
86
|
+
"review",
|
|
87
|
+
" authorId: z.string(),\n reviewerId: z.string(),",
|
|
88
|
+
"export const references = { authorId: 'User', reviewerId: 'User' };\n"
|
|
89
|
+
)));
|
|
90
|
+
const count = (source: string, text: string) => source.split(text).length - 1;
|
|
91
|
+
for (const source of [generateListPage(info, "@sample/shared"), generateDetailPage(info, "@sample/shared")]) {
|
|
92
|
+
expect(count(source, "const [userMap, setUserMap]")).toBe(1);
|
|
93
|
+
expect(count(source, "fetch('/api/user')")).toBe(1);
|
|
94
|
+
expect(source).toContain("userMap[item.authorId]".replace("item", source.includes("ListPage") ? "item" : "review"));
|
|
95
|
+
}
|
|
96
|
+
const form = generateFormComponent(info, "@sample/shared");
|
|
97
|
+
expect(count(form, "const [userOptions, setUserOptions]")).toBe(1);
|
|
98
|
+
expect(count(form, "fetch('/api/user')")).toBe(1);
|
|
99
|
+
expect(form).toContain('id="authorId"');
|
|
100
|
+
expect(form).toContain('id="reviewerId"');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("documents the references declaration in capabilities", () => {
|
|
104
|
+
expect(inspectCapabilities().modelDeclarations.map((declaration) => declaration.name)).toContain("references");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("dynamic schema evaluation metadata", () => {
|
|
109
|
+
let root: string;
|
|
110
|
+
beforeEach(() => {
|
|
111
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-dynamic-"));
|
|
112
|
+
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "sample", type: "module" }));
|
|
113
|
+
fs.mkdirSync(path.join(root, "node_modules"), { recursive: true });
|
|
114
|
+
fs.symlinkSync(path.join(repoRoot, "node_modules", "zod"), path.join(root, "node_modules", "zod"), "junction");
|
|
115
|
+
});
|
|
116
|
+
afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
117
|
+
|
|
118
|
+
it("extracts formats, integers, constraints, and inline objects from Zod v4 at runtime", async () => {
|
|
119
|
+
const file = path.join(root, "shared", "models", "probe-item.ts");
|
|
120
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
121
|
+
// startsWith() is outside the static subset, which forces runtime evaluation.
|
|
122
|
+
fs.writeFileSync(file, model("probe-item", [
|
|
123
|
+
" userId: z.string().startsWith('u'),",
|
|
124
|
+
" title: z.string().min(1).max(5),",
|
|
125
|
+
" code: z.string().regex(/^a\\d$/i),",
|
|
126
|
+
" tags: z.array(z.string().min(2)).min(1),",
|
|
127
|
+
" score: z.number().gt(0).lte(1),",
|
|
128
|
+
" edge: z.number().gt(0).min(0).lt(1).max(1),",
|
|
129
|
+
" count: z.int(),",
|
|
130
|
+
" publishedAt: z.iso.datetime().optional(),",
|
|
131
|
+
" createdAt: z.string().optional(),",
|
|
132
|
+
" nested: z.object({ x: z.string() }).optional(),",
|
|
133
|
+
].join("\n")));
|
|
134
|
+
|
|
135
|
+
const info = await parseModelFile(file);
|
|
136
|
+
const field = (name: string) => info.fields.find((candidate) => candidate.name === name)!;
|
|
137
|
+
expect(info.parserMode).toBe("dynamic");
|
|
138
|
+
expect(field("userId").isForeignKey).toBeUndefined();
|
|
139
|
+
expect(field("title").constraints).toEqual({ minLength: 1, maxLength: 5 });
|
|
140
|
+
expect(field("code").constraints).toEqual({ pattern: "^a\\d$", patternFlags: "i" });
|
|
141
|
+
expect(field("tags")).toMatchObject({ isArray: true, constraints: { minLength: 2, minItems: 1 } });
|
|
142
|
+
expect(field("score").constraints).toEqual({ minimum: 0, exclusiveMinimum: true, maximum: 1 });
|
|
143
|
+
expect(field("edge").constraints).toEqual({ minimum: 0, exclusiveMinimum: true, maximum: 1, exclusiveMaximum: true });
|
|
144
|
+
expect(field("count").isInteger).toBe(true);
|
|
145
|
+
expect(field("publishedAt")).toMatchObject({ format: "date-time", isOptional: true });
|
|
146
|
+
expect(field("createdAt").format).toBeUndefined();
|
|
147
|
+
expect(field("nested")).toMatchObject({ type: "object", isOptional: true, objectFields: [expect.objectContaining({ name: "x", type: "string" })] });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("extracts the same metadata from Zod v3 schemas", async () => {
|
|
151
|
+
const file = path.join(root, "shared", "models", "legacy-item.ts");
|
|
152
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
153
|
+
fs.writeFileSync(file, model("legacy-item", [
|
|
154
|
+
" title: z.string().startsWith('t').min(2),",
|
|
155
|
+
" score: z.number().int().positive(),",
|
|
156
|
+
" publishedAt: z.string().datetime().optional(),",
|
|
157
|
+
" kind: z.enum(['a', 'b']),",
|
|
158
|
+
" nested: z.object({ x: z.string().max(3) }),",
|
|
159
|
+
].join("\n")).replace("from 'zod/v4'", "from 'zod'"));
|
|
160
|
+
|
|
161
|
+
const info = await parseModelFile(file);
|
|
162
|
+
const field = (name: string) => info.fields.find((candidate) => candidate.name === name)!;
|
|
163
|
+
expect(info.parserMode).toBe("dynamic");
|
|
164
|
+
expect(field("title").constraints).toEqual({ minLength: 2 });
|
|
165
|
+
expect(field("score")).toMatchObject({ isInteger: true, constraints: { minimum: 0, exclusiveMinimum: true } });
|
|
166
|
+
expect(field("publishedAt").format).toBe("date-time");
|
|
167
|
+
expect(field("kind").enumValues).toEqual(["a", "b"]);
|
|
168
|
+
expect(field("nested").objectFields).toEqual([expect.objectContaining({ name: "x", constraints: { maxLength: 3 } })]);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("Python schema assets", () => {
|
|
173
|
+
let root: string;
|
|
174
|
+
const originalCwd = process.cwd();
|
|
175
|
+
beforeEach(() => {
|
|
176
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-python-schema-"));
|
|
177
|
+
});
|
|
178
|
+
afterEach(() => {
|
|
179
|
+
process.chdir(originalCwd);
|
|
180
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("keeps declared types and constraints instead of guessing from names", async () => {
|
|
184
|
+
const file = path.join(root, "shared", "models", "probe-item.ts");
|
|
185
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
186
|
+
fs.writeFileSync(file, model("probe-item", [
|
|
187
|
+
" title: z.string().min(1),",
|
|
188
|
+
" code: z.string().regex(/^a\\d$/i).optional(),",
|
|
189
|
+
" tags: z.array(z.string().min(2)).max(3),",
|
|
190
|
+
" score: z.number().min(0).max(1),",
|
|
191
|
+
" count: z.number().int(),",
|
|
192
|
+
" publishedAt: z.iso.datetime().optional(),",
|
|
193
|
+
" createdAt: z.string().optional(),",
|
|
194
|
+
" nested: z.object({ x: z.string() }).optional(),",
|
|
195
|
+
].join("\n")));
|
|
196
|
+
const info = await parseModelFile(file);
|
|
197
|
+
process.chdir(root);
|
|
198
|
+
await generateLanguageSchemaArtifacts([info], info, "functions", "python", new FileOperationSession("commit", root));
|
|
199
|
+
const modelsDir = path.join(root, "functions", "generated", "python-models", "backend_models", "models");
|
|
200
|
+
const source = fs.readFileSync(path.join(modelsDir, "probe_item.py"), "utf8");
|
|
201
|
+
|
|
202
|
+
expect(source).toContain("created_at: Optional[StrictStr] = Field(default=None, alias=\"createdAt\")");
|
|
203
|
+
expect(source).toContain("published_at: Optional[datetime] = Field(default=None, alias=\"publishedAt\")");
|
|
204
|
+
expect(source).toContain("title: StrictStr = Field(min_length=1)");
|
|
205
|
+
expect(source).toContain("tags: List[Annotated[StrictStr, Field(min_length=2)]] = Field(max_length=3)");
|
|
206
|
+
expect(source).toContain("score: Union[StrictFloat, StrictInt] = Field(ge=0, le=1)");
|
|
207
|
+
expect(source).toContain("count: StrictInt");
|
|
208
|
+
expect(source).toContain("nested: Optional[ProbeItemNested] = Field(default=None)");
|
|
209
|
+
expect(source).toContain('re.search("^a\\\\d$", item, re.IGNORECASE)');
|
|
210
|
+
expect(source).toContain("return self.model_dump_json(by_alias=True, exclude_none=True)");
|
|
211
|
+
expect(fs.readFileSync(path.join(modelsDir, "probe_item_nested.py"), "utf8")).toContain("class ProbeItemNested(BaseModel):");
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe("Python regex translation", () => {
|
|
216
|
+
it("translates JavaScript named groups and back-references", () => {
|
|
217
|
+
expect(toPythonRegex("^(?<code>[A-Z]+)-\\k<code>$")).toEqual({ pattern: "^(?P<code>[A-Z]+)-(?P=code)$" });
|
|
218
|
+
expect(toPythonRegex("^a\\d$", "i")).toEqual({ pattern: "^a\\d$" });
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it.each([
|
|
222
|
+
["\\p{L}+", "", /Unicode property/],
|
|
223
|
+
["(?<=a)b", "", /lookbehind/],
|
|
224
|
+
["[^]", "", /empty character classes/],
|
|
225
|
+
["\\u{1F600}", "u", /flag\(s\) u|code point/],
|
|
226
|
+
["abc", "y", /flag\(s\) y/],
|
|
227
|
+
])("reports %s /%s as not enforceable", (pattern, flags, reason) => {
|
|
228
|
+
const result = toPythonRegex(pattern, flags);
|
|
229
|
+
expect("unsupported" in result && result.unsupported).toMatch(reason);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("skips unenforceable patterns with a warning and validates raw strings before conversion", async () => {
|
|
233
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-python-regex-"));
|
|
234
|
+
const originalCwd = process.cwd();
|
|
235
|
+
try {
|
|
236
|
+
const file = path.join(root, "shared", "models", "regex-item.ts");
|
|
237
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
238
|
+
fs.writeFileSync(file, model("regex-item", [
|
|
239
|
+
" code: z.string().regex(/^(?<code>[A-Z]+)$/),",
|
|
240
|
+
" letters: z.string().regex(/^\\p{L}+$/u),",
|
|
241
|
+
" when: z.string().datetime().regex(/Z$/),",
|
|
242
|
+
].join("\n")));
|
|
243
|
+
const info = await parseModelFile(file);
|
|
244
|
+
expect(getPythonRegexWarnings(info)).toEqual([
|
|
245
|
+
expect.stringMatching(/^python-regex-not-enforced: RegexItem\.letters uses Unicode property escapes/),
|
|
246
|
+
]);
|
|
247
|
+
process.chdir(root);
|
|
248
|
+
const session = new FileOperationSession("commit", root);
|
|
249
|
+
await generateLanguageSchemaArtifacts([info], info, "functions", "python", session);
|
|
250
|
+
expect(session.warnings).toEqual(getPythonRegexWarnings(info));
|
|
251
|
+
const source = fs.readFileSync(path.join(root, "functions", "generated", "python-models", "backend_models", "models", "regex_item.py"), "utf8");
|
|
252
|
+
expect(source).toContain('re.search("^(?P<code>[A-Z]+)$", item, 0)');
|
|
253
|
+
expect(source).not.toContain("letters_validate_pattern");
|
|
254
|
+
expect(source).toContain("@field_validator('when', mode='before')");
|
|
255
|
+
expect(source).toContain("if isinstance(item, str) and not re.search");
|
|
256
|
+
} finally {
|
|
257
|
+
process.chdir(originalCwd);
|
|
258
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const pydanticPython = ["python", "python3"].find((command) => spawnSync(command, ["-c", "import pydantic"], { stdio: "ignore" }).status === 0);
|
|
264
|
+
const itWithPydantic = pydanticPython ? it : it.skip;
|
|
265
|
+
|
|
266
|
+
describe("generated Python model runtime", () => {
|
|
267
|
+
itWithPydantic("accepts valid regex and timestamp values and rejects invalid ones", async () => {
|
|
268
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-python-runtime-"));
|
|
269
|
+
const originalCwd = process.cwd();
|
|
270
|
+
try {
|
|
271
|
+
const file = path.join(root, "shared", "models", "regex-item.ts");
|
|
272
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
273
|
+
fs.writeFileSync(file, model("regex-item", [
|
|
274
|
+
" code: z.string().regex(/^(?<code>[A-Z]+)$/),",
|
|
275
|
+
" when: z.iso.datetime().regex(/Z$/),",
|
|
276
|
+
" tags: z.array(z.string().regex(/^t/)),",
|
|
277
|
+
].join("\n")));
|
|
278
|
+
const info = await parseModelFile(file);
|
|
279
|
+
process.chdir(root);
|
|
280
|
+
await generateLanguageSchemaArtifacts([info], info, "functions", "python", new FileOperationSession("commit", root));
|
|
281
|
+
process.chdir(originalCwd);
|
|
282
|
+
const script = [
|
|
283
|
+
"import sys",
|
|
284
|
+
"sys.path.insert(0, 'generated/python-models')",
|
|
285
|
+
"from backend_models.models.regex_item import RegexItem",
|
|
286
|
+
"ok = {'id': '1', 'code': 'ABC', 'when': '2024-05-06T07:08:09Z', 'tags': ['t1']}",
|
|
287
|
+
"RegexItem.from_dict(ok)",
|
|
288
|
+
"for bad in ({**ok, 'code': 'abc'}, {**ok, 'when': '2024-05-06T07:08:09+09:00'}, {**ok, 'tags': ['x']}):",
|
|
289
|
+
" try:",
|
|
290
|
+
" RegexItem.from_dict(bad)",
|
|
291
|
+
" except ValueError:",
|
|
292
|
+
" continue",
|
|
293
|
+
" raise SystemExit('accepted ' + repr(bad))",
|
|
294
|
+
"print('ok')",
|
|
295
|
+
].join("\n");
|
|
296
|
+
const run = spawnSync(pydanticPython!, ["-c", script], { cwd: path.join(root, "functions"), encoding: "utf8" });
|
|
297
|
+
if (run.status !== 0) throw new Error(`${run.stdout}\n${run.stderr}`);
|
|
298
|
+
expect(run.stdout.trim()).toBe("ok");
|
|
299
|
+
} finally {
|
|
300
|
+
process.chdir(originalCwd);
|
|
301
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
302
|
+
}
|
|
303
|
+
}, 60000);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("user-scoped data warning", () => {
|
|
307
|
+
const info = (partitionKey: string, fields: string[]) => ({
|
|
308
|
+
name: "ProbeItem",
|
|
309
|
+
partitionKey,
|
|
310
|
+
fields: fields.map((name) => ({ name, type: "string", isOptional: false, isArray: false })),
|
|
311
|
+
}) as unknown as Parameters<typeof buildUserScopedDataWarning>[0];
|
|
312
|
+
|
|
313
|
+
it("warns when the partition key is a per-user id and auth is not configured", () => {
|
|
314
|
+
expect(buildUserScopedDataWarning(info("/userId", ["id", "userId"]), undefined))
|
|
315
|
+
.toMatch(/^user-scoped-data-without-auth: ProbeItem\.userId is the partition key and looks like a per-user owner id/);
|
|
316
|
+
expect(buildUserScopedDataWarning(info("/userId", ["id", "userId"]), { provider: "none" })).toBeDefined();
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("warns for owner-like fields that are not the partition key", () => {
|
|
320
|
+
expect(buildUserScopedDataWarning(info("/id", ["id", "learnerId"]), undefined))
|
|
321
|
+
.toMatch(/ProbeItem\.learnerId looks like a per-user owner id/);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("stays quiet when authentication is configured or nothing looks user-scoped", () => {
|
|
325
|
+
expect(buildUserScopedDataWarning(info("/userId", ["id", "userId"]), { provider: "swa" })).toBeUndefined();
|
|
326
|
+
expect(buildUserScopedDataWarning(info("/userId", ["id", "userId"]), { schemes: { admin: { provider: "swa" } } } as never)).toBeUndefined();
|
|
327
|
+
expect(buildUserScopedDataWarning(info("/tenantId", ["id", "tenantId", "categoryId"]), undefined)).toBeUndefined();
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { ensureSwallowKitProject } from "../../core/config";
|
|
3
|
+
import {
|
|
4
|
+
MCP_CONFIG_PATH,
|
|
5
|
+
MCP_LATEST,
|
|
6
|
+
lookupPublishedLatestVersion,
|
|
7
|
+
readMcpConfigVersion,
|
|
8
|
+
resolveMcpPinVersion,
|
|
9
|
+
writeMcpConfigVersion,
|
|
10
|
+
} from "../../core/project/mcp-config";
|
|
11
|
+
import { resolveProjectSwallowKit } from "../../mcp/version-selection";
|
|
12
|
+
import { parseSemver } from "../../utils/semver";
|
|
13
|
+
import { getSwallowKitVersion } from "../../version";
|
|
14
|
+
|
|
15
|
+
export interface ConfigureMcpResult {
|
|
16
|
+
previousVersion: string | null;
|
|
17
|
+
version: string;
|
|
18
|
+
reason: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function configureMcp(
|
|
22
|
+
options: { version?: string; cwd?: string },
|
|
23
|
+
lookupLatest: (packageName: string) => string | null = lookupPublishedLatestVersion
|
|
24
|
+
): ConfigureMcpResult {
|
|
25
|
+
const cwd = options.cwd ?? process.cwd();
|
|
26
|
+
const requested = (options.version ?? "auto").trim();
|
|
27
|
+
const previousVersion = readMcpConfigVersion(cwd);
|
|
28
|
+
|
|
29
|
+
let decision: { version: string; reason: string };
|
|
30
|
+
if (requested === "auto") {
|
|
31
|
+
const projectVersion = resolveProjectSwallowKit(cwd)?.version ?? getSwallowKitVersion();
|
|
32
|
+
decision = resolveMcpPinVersion(projectVersion, lookupLatest("swallowkit"));
|
|
33
|
+
} else if (requested === MCP_LATEST) {
|
|
34
|
+
decision = { version: MCP_LATEST, reason: "Explicitly requested latest." };
|
|
35
|
+
} else if (parseSemver(requested)) {
|
|
36
|
+
decision = { version: requested, reason: "Explicitly pinned version." };
|
|
37
|
+
} else {
|
|
38
|
+
throw new Error(`--version must be "auto", "latest", or a semantic version (got "${requested}").`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
writeMcpConfigVersion(cwd, decision.version);
|
|
42
|
+
return { previousVersion, ...decision };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const configureMcpCommand = new Command("configure-mcp")
|
|
46
|
+
.description(`Select the SwallowKit MCP version used by ${MCP_CONFIG_PATH} (auto: latest unless the project is newer)`)
|
|
47
|
+
.option("--version <version>", "auto | latest | <semver>", "auto")
|
|
48
|
+
.action((options: { version?: string }) => {
|
|
49
|
+
ensureSwallowKitProject("configure-mcp");
|
|
50
|
+
const result = configureMcp({ version: options.version });
|
|
51
|
+
console.log(`${MCP_CONFIG_PATH}: SWALLOWKIT_MCP_VERSION ${result.previousVersion ?? "(unset)"} -> ${result.version}`);
|
|
52
|
+
console.log(`Reason: ${result.reason}`);
|
|
53
|
+
console.log("Restart the MCP server (or reload your agent) to apply the change.");
|
|
54
|
+
});
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "../../core/scaffold/native-schema-generator";
|
|
28
28
|
import { DEFAULT_AZURE_PYTHON_VERSION } from "../../utils/python-uv";
|
|
29
29
|
import { buildHomePage } from "../../core/project/home-page";
|
|
30
|
+
import { MCP_LATEST, McpPinDecision, lookupPublishedLatestVersion, resolveMcpPinVersion } from "../../core/project/mcp-config";
|
|
30
31
|
|
|
31
32
|
interface InitOptions {
|
|
32
33
|
name: string;
|
|
@@ -802,7 +803,7 @@ export function ensureNextTailwindPostcssConfig(projectDir: string): void {
|
|
|
802
803
|
`);
|
|
803
804
|
}
|
|
804
805
|
|
|
805
|
-
export function buildSwallowKitMcpProjectConfigSource(pm: PackageManager = 'pnpm'): string {
|
|
806
|
+
export function buildSwallowKitMcpProjectConfigSource(pm: PackageManager = 'pnpm', mcpVersion: string = MCP_LATEST): string {
|
|
806
807
|
const { name } = getSwallowKitPackageMetadata();
|
|
807
808
|
return JSON.stringify(
|
|
808
809
|
{
|
|
@@ -814,7 +815,7 @@ export function buildSwallowKitMcpProjectConfigSource(pm: PackageManager = 'pnpm
|
|
|
814
815
|
: ["--yes", "--package", `${name}@\${SWALLOWKIT_MCP_VERSION}`, "swallowkit-mcp"],
|
|
815
816
|
cwd: ".",
|
|
816
817
|
env: {
|
|
817
|
-
SWALLOWKIT_MCP_VERSION:
|
|
818
|
+
SWALLOWKIT_MCP_VERSION: mcpVersion,
|
|
818
819
|
},
|
|
819
820
|
},
|
|
820
821
|
},
|
|
@@ -824,6 +825,15 @@ export function buildSwallowKitMcpProjectConfigSource(pm: PackageManager = 'pnpm
|
|
|
824
825
|
);
|
|
825
826
|
}
|
|
826
827
|
|
|
828
|
+
/** latest by default; pinned to this CLI version only when it is newer than published latest. */
|
|
829
|
+
export function selectInitialMcpVersion(
|
|
830
|
+
lookupLatest: (packageName: string) => string | null = lookupPublishedLatestVersion
|
|
831
|
+
): McpPinDecision {
|
|
832
|
+
const { name, version } = getSwallowKitPackageMetadata();
|
|
833
|
+
if (version === MCP_LATEST) return { version: MCP_LATEST, reason: "SwallowKit package metadata is unavailable." };
|
|
834
|
+
return resolveMcpPinVersion(version, lookupLatest(name));
|
|
835
|
+
}
|
|
836
|
+
|
|
827
837
|
async function addSwallowKitFiles(
|
|
828
838
|
projectDir: string,
|
|
829
839
|
options: InitOptions,
|
|
@@ -1939,11 +1949,16 @@ function createAiAgentFiles(
|
|
|
1939
1949
|
console.log(' ✅ .github/copilot-instructions.md (GitHub Copilot adapter)');
|
|
1940
1950
|
console.log(' ✅ .github/instructions/ (path-specific instructions)');
|
|
1941
1951
|
|
|
1952
|
+
const mcpVersion = selectInitialMcpVersion();
|
|
1942
1953
|
fs.writeFileSync(
|
|
1943
1954
|
path.join(projectDir, '.mcp.json'),
|
|
1944
|
-
buildSwallowKitMcpProjectConfigSource(pm)
|
|
1955
|
+
buildSwallowKitMcpProjectConfigSource(pm, mcpVersion.version)
|
|
1956
|
+
);
|
|
1957
|
+
console.log(
|
|
1958
|
+
mcpVersion.version === MCP_LATEST
|
|
1959
|
+
? ' ✅ .mcp.json (project-scoped MCP bootstrap, resolves latest)'
|
|
1960
|
+
: ` ✅ .mcp.json (project-scoped MCP bootstrap, pinned to ${mcpVersion.version}: ${mcpVersion.reason} Run "${runCmd} swallowkit configure-mcp" to re-select later.)`
|
|
1945
1961
|
);
|
|
1946
|
-
console.log(' ✅ .mcp.json (project-scoped MCP bootstrap)');
|
|
1947
1962
|
|
|
1948
1963
|
writeWorkflowDocs(projectDir, runCmd);
|
|
1949
1964
|
console.log(' ✅ .swallowkit/workflows/ (agent-agnostic autonomous-loop runbooks)');
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { ensureSwallowKitProject } from "../../core/config";
|
|
3
3
|
import { initializeLegacyMigrations } from "../../core/operations/migration-generator";
|
|
4
|
+
import { addModelContainerMigration } from "../../core/operations/model-migration";
|
|
4
5
|
|
|
5
6
|
export const migrationsCommand = new Command("migrations")
|
|
6
7
|
.description("Manage versioned Azure infrastructure migrations");
|
|
@@ -18,3 +19,24 @@ migrationsCommand
|
|
|
18
19
|
console.log(`Migration baseline 0 created for project ${manifest.projectId}.`);
|
|
19
20
|
console.log("Commit infra/migrations/manifest.json, then run provision with --adopt-existing --baseline 0.");
|
|
20
21
|
});
|
|
22
|
+
|
|
23
|
+
migrationsCommand
|
|
24
|
+
.command("add-model")
|
|
25
|
+
.description("Generate only the Cosmos DB container migration for a model (e.g. after scaffold --skip-infra)")
|
|
26
|
+
.argument("<model>", "Model file or model name")
|
|
27
|
+
.option("--dry-run", "Show the planned infra changes without writing anything", false)
|
|
28
|
+
.action(async (model: string, options: { dryRun?: boolean }) => {
|
|
29
|
+
ensureSwallowKitProject("migrations add-model");
|
|
30
|
+
const result = await addModelContainerMigration(model, { dryRun: options.dryRun });
|
|
31
|
+
for (const operation of result.operations) {
|
|
32
|
+
if (operation.action !== "skip") console.log(` ${operation.action.padEnd(7)} ${operation.path}`);
|
|
33
|
+
}
|
|
34
|
+
if (!result.migration) {
|
|
35
|
+
console.log(`No migration needed for ${result.model}: ${result.reason}`);
|
|
36
|
+
} else if (result.operations.every((operation) => operation.action === "skip")) {
|
|
37
|
+
console.log(`Migration ${result.migration.version} for ${result.model} already exists: ${result.migration.template}`);
|
|
38
|
+
} else {
|
|
39
|
+
console.log(`${result.dryRun ? "Would create" : "Created"} migration ${result.migration.version} for ${result.model}: ${result.migration.template}`);
|
|
40
|
+
if (!result.dryRun) console.log("Commit the migration, then run provision to plan and apply it.");
|
|
41
|
+
}
|
|
42
|
+
});
|
|
@@ -1,7 +1,27 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import prompts from "prompts";
|
|
3
3
|
import { ensureSwallowKitProject } from "../../core/config";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
INTERACTIVE_PROVISION_RETRY_HINT,
|
|
6
|
+
ProvisionPlanData,
|
|
7
|
+
executeProvisionPlan,
|
|
8
|
+
planProvisionOperation,
|
|
9
|
+
} from "../../core/operations/provision-operations";
|
|
10
|
+
|
|
11
|
+
export function describeProvisionAction(plan: Pick<ProvisionPlanData, "mode" | "resourceGroup" | "location" | "pendingMigrations">): string {
|
|
12
|
+
switch (plan.mode) {
|
|
13
|
+
case "bootstrap":
|
|
14
|
+
return `Resource group ${plan.resourceGroup} does not exist yet. It will be created in ${plan.location} and the infrastructure in infra/main.bicep will be deployed.`;
|
|
15
|
+
case "migrate":
|
|
16
|
+
return `${plan.pendingMigrations.length} infrastructure migration(s) will be deployed to ${plan.resourceGroup}: ${plan.pendingMigrations.map((migration) => `${migration.version}-${migration.slug}`).join(", ")}.`;
|
|
17
|
+
case "adopt":
|
|
18
|
+
return `The existing resource group ${plan.resourceGroup} will be recorded as SwallowKit baseline 0. Nothing will be deployed.`;
|
|
19
|
+
case "reconcile":
|
|
20
|
+
return `infra/main.bicep will be redeployed to ${plan.resourceGroup}. Template-managed settings may be reset.`;
|
|
21
|
+
default:
|
|
22
|
+
return `Resource group ${plan.resourceGroup}:`;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
5
25
|
|
|
6
26
|
const PRIMARY_REGIONS = [
|
|
7
27
|
{ title: "Japan East (japaneast)", value: "japaneast" },
|
|
@@ -70,16 +90,17 @@ export const provisionCommand = new Command("provision")
|
|
|
70
90
|
.option("--subscription <id>", "Azure subscription ID")
|
|
71
91
|
.option("--location <region>", "Primary location for Functions and Cosmos DB")
|
|
72
92
|
.option("--swa-location <region>", "Static Web App location")
|
|
73
|
-
.option("--what-if", "Include Azure what-if evidence", false)
|
|
93
|
+
.option("--what-if", "Include Azure what-if evidence in the preview", false)
|
|
74
94
|
.option("--adopt-existing", "Adopt an existing untagged resource group without deploying main.bicep", false)
|
|
75
95
|
.option("--baseline <version>", "Baseline version for adoption (currently 0)")
|
|
76
96
|
.option("--reconcile", "Explicitly redeploy infra/main.bicep", false)
|
|
77
|
-
.option("--approve", "
|
|
97
|
+
.option("--approve", "Run the displayed Azure commands without the confirmation prompt", false)
|
|
78
98
|
.action(async (options: ProvisionCliOptions) => {
|
|
79
99
|
ensureSwallowKitProject("provision");
|
|
80
100
|
const locations = await resolveLocations(options);
|
|
81
101
|
const baseline = options.baseline === undefined ? undefined : Number(options.baseline);
|
|
82
|
-
|
|
102
|
+
// Preview in memory: the same Azure checks as the agent workflow, but no saved plan file.
|
|
103
|
+
const preview = await planProvisionOperation({
|
|
83
104
|
resourceGroup: options.resourceGroup,
|
|
84
105
|
subscription: options.subscription,
|
|
85
106
|
location: locations.location,
|
|
@@ -88,37 +109,52 @@ export const provisionCommand = new Command("provision")
|
|
|
88
109
|
adoptExisting: options.adoptExisting,
|
|
89
110
|
baseline,
|
|
90
111
|
reconcile: options.reconcile,
|
|
91
|
-
});
|
|
112
|
+
}, { persist: false });
|
|
92
113
|
|
|
93
|
-
console.
|
|
94
|
-
|
|
95
|
-
if (
|
|
96
|
-
console.log(
|
|
114
|
+
for (const warning of preview.warnings) console.warn(`Warning: ${warning}`);
|
|
115
|
+
|
|
116
|
+
if (preview.mode === "offline") {
|
|
117
|
+
console.log([
|
|
118
|
+
"",
|
|
119
|
+
"❌ Azure CLI is unavailable, so the current state of Azure could not be checked. Nothing was changed in Azure.",
|
|
120
|
+
" 1. Make sure `az --version` works in this terminal (install: https://aka.ms/azure-cli).",
|
|
121
|
+
" 2. Sign in with `az login` (and `az account set --subscription <id>` if needed).",
|
|
122
|
+
" 3. Run this command again.",
|
|
123
|
+
].join("\n"));
|
|
124
|
+
process.exitCode = 1;
|
|
125
|
+
return;
|
|
97
126
|
}
|
|
98
|
-
for (const command of plan.commands) console.log(` ${command}`);
|
|
99
127
|
|
|
100
|
-
if (
|
|
128
|
+
if (preview.mode === "adoption-required") {
|
|
101
129
|
throw new Error('Existing resource group requires: migrations init --legacy-baseline, then provision --adopt-existing --baseline 0 --what-if.');
|
|
102
130
|
}
|
|
103
131
|
|
|
132
|
+
if (preview.mode === "noop") {
|
|
133
|
+
console.log(`Resource group ${preview.resourceGroup} is already up to date. Nothing to do.`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.log(`\n${describeProvisionAction(preview)}`);
|
|
138
|
+
console.log("The following Azure CLI commands will run:");
|
|
139
|
+
for (const command of preview.commands) console.log(` ${command}`);
|
|
140
|
+
|
|
104
141
|
let approved = options.approve === true;
|
|
105
142
|
if (!approved) {
|
|
106
143
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
107
|
-
throw new Error("Non-interactive provision requires --approve after reviewing the
|
|
144
|
+
throw new Error("Non-interactive provision requires --approve after reviewing the commands above.");
|
|
108
145
|
}
|
|
109
146
|
const answer = await prompts({
|
|
110
147
|
type: "confirm",
|
|
111
148
|
name: "approved",
|
|
112
|
-
message:
|
|
149
|
+
message: "Run these Azure commands? This may create billable resources.",
|
|
113
150
|
initial: false,
|
|
114
151
|
});
|
|
115
152
|
approved = answer.approved === true;
|
|
116
153
|
}
|
|
117
154
|
if (!approved) {
|
|
118
|
-
console.log("Provision cancelled.");
|
|
155
|
+
console.log("Provision cancelled. Nothing was changed in Azure.");
|
|
119
156
|
return;
|
|
120
157
|
}
|
|
121
158
|
|
|
122
|
-
const result = await
|
|
123
|
-
console.log(
|
|
124
|
-
});
|
|
159
|
+
const result = await executeProvisionPlan(preview, { approve: true, retryHint: INTERACTIVE_PROVISION_RETRY_HINT });
|
|
160
|
+
console.log(`✅ Provisioning completed. ${result.executedCommands.length} Azure CLI command(s) ran.`); });
|