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.
Files changed (133) hide show
  1. package/dist/cli/commands/configure-mcp.d.ts +12 -0
  2. package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
  3. package/dist/cli/commands/configure-mcp.js +42 -0
  4. package/dist/cli/commands/configure-mcp.js.map +1 -0
  5. package/dist/cli/commands/init.d.ts +4 -1
  6. package/dist/cli/commands/init.d.ts.map +1 -1
  7. package/dist/cli/commands/init.js +16 -4
  8. package/dist/cli/commands/init.js.map +1 -1
  9. package/dist/cli/commands/migrations.d.ts.map +1 -1
  10. package/dist/cli/commands/migrations.js +25 -0
  11. package/dist/cli/commands/migrations.js.map +1 -1
  12. package/dist/cli/commands/provision.d.ts +2 -0
  13. package/dist/cli/commands/provision.d.ts.map +1 -1
  14. package/dist/cli/commands/provision.js +45 -16
  15. package/dist/cli/commands/provision.js.map +1 -1
  16. package/dist/cli/commands/scaffold.d.ts +4 -0
  17. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  18. package/dist/cli/commands/scaffold.js +40 -2
  19. package/dist/cli/commands/scaffold.js.map +1 -1
  20. package/dist/cli/index.d.ts +2 -0
  21. package/dist/cli/index.d.ts.map +1 -1
  22. package/dist/cli/index.js +27 -2
  23. package/dist/cli/index.js.map +1 -1
  24. package/dist/core/operations/model-migration.d.ts +22 -0
  25. package/dist/core/operations/model-migration.d.ts.map +1 -0
  26. package/dist/core/operations/model-migration.js +36 -0
  27. package/dist/core/operations/model-migration.js.map +1 -0
  28. package/dist/core/operations/provision-operations.d.ts +19 -1
  29. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  30. package/dist/core/operations/provision-operations.js +62 -35
  31. package/dist/core/operations/provision-operations.js.map +1 -1
  32. package/dist/core/operations/scaffold-apply.d.ts +1 -0
  33. package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
  34. package/dist/core/operations/scaffold-apply.js +1 -0
  35. package/dist/core/operations/scaffold-apply.js.map +1 -1
  36. package/dist/core/operations/scaffold-machine.d.ts +1 -0
  37. package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
  38. package/dist/core/operations/scaffold-machine.js.map +1 -1
  39. package/dist/core/operations/scaffold-plan.d.ts +2 -0
  40. package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
  41. package/dist/core/operations/scaffold-plan.js +3 -1
  42. package/dist/core/operations/scaffold-plan.js.map +1 -1
  43. package/dist/core/project/boundaries.d.ts +5 -2
  44. package/dist/core/project/boundaries.d.ts.map +1 -1
  45. package/dist/core/project/boundaries.js +49 -18
  46. package/dist/core/project/boundaries.js.map +1 -1
  47. package/dist/core/project/capabilities.d.ts +1 -0
  48. package/dist/core/project/capabilities.d.ts.map +1 -1
  49. package/dist/core/project/capabilities.js +20 -2
  50. package/dist/core/project/capabilities.js.map +1 -1
  51. package/dist/core/project/mcp-config.d.ts +26 -0
  52. package/dist/core/project/mcp-config.d.ts.map +1 -0
  53. package/dist/core/project/mcp-config.js +117 -0
  54. package/dist/core/project/mcp-config.js.map +1 -0
  55. package/dist/core/project/workflows.d.ts.map +1 -1
  56. package/dist/core/project/workflows.js +9 -1
  57. package/dist/core/project/workflows.js.map +1 -1
  58. package/dist/core/scaffold/model-parser.d.ts +23 -0
  59. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  60. package/dist/core/scaffold/model-parser.js +407 -137
  61. package/dist/core/scaffold/model-parser.js.map +1 -1
  62. package/dist/core/scaffold/native-schema-generator.d.ts +11 -0
  63. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  64. package/dist/core/scaffold/native-schema-generator.js +273 -75
  65. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  66. package/dist/core/scaffold/openapi-generator.d.ts +5 -0
  67. package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
  68. package/dist/core/scaffold/openapi-generator.js +53 -3
  69. package/dist/core/scaffold/openapi-generator.js.map +1 -1
  70. package/dist/core/scaffold/ui-generator.d.ts +0 -3
  71. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  72. package/dist/core/scaffold/ui-generator.js +68 -7
  73. package/dist/core/scaffold/ui-generator.js.map +1 -1
  74. package/dist/core/verify/index.d.ts.map +1 -1
  75. package/dist/core/verify/index.js +24 -1
  76. package/dist/core/verify/index.js.map +1 -1
  77. package/dist/machine/index.d.ts.map +1 -1
  78. package/dist/machine/index.js +39 -15
  79. package/dist/machine/index.js.map +1 -1
  80. package/dist/mcp/index.d.ts +3 -2
  81. package/dist/mcp/index.d.ts.map +1 -1
  82. package/dist/mcp/index.js +137 -13
  83. package/dist/mcp/index.js.map +1 -1
  84. package/dist/mcp/version-selection.d.ts +55 -0
  85. package/dist/mcp/version-selection.d.ts.map +1 -0
  86. package/dist/mcp/version-selection.js +152 -0
  87. package/dist/mcp/version-selection.js.map +1 -0
  88. package/dist/utils/azure-cli.d.ts +44 -0
  89. package/dist/utils/azure-cli.d.ts.map +1 -0
  90. package/dist/utils/azure-cli.js +164 -0
  91. package/dist/utils/azure-cli.js.map +1 -0
  92. package/dist/utils/semver.d.ts +14 -0
  93. package/dist/utils/semver.d.ts.map +1 -0
  94. package/dist/utils/semver.js +61 -0
  95. package/dist/utils/semver.js.map +1 -0
  96. package/package.json +1 -1
  97. package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
  98. package/src/__tests__/agent-loop-phase34.test.ts +29 -0
  99. package/src/__tests__/azure-cli.test.ts +199 -0
  100. package/src/__tests__/boundaries.test.ts +42 -0
  101. package/src/__tests__/csharp-schema.test.ts +261 -0
  102. package/src/__tests__/mcp-version.test.ts +262 -0
  103. package/src/__tests__/mcp.test.ts +49 -3
  104. package/src/__tests__/plan-apply.test.ts +59 -0
  105. package/src/__tests__/schema-analysis.test.ts +329 -0
  106. package/src/cli/commands/configure-mcp.ts +54 -0
  107. package/src/cli/commands/init.ts +19 -4
  108. package/src/cli/commands/migrations.ts +22 -0
  109. package/src/cli/commands/provision.ts +53 -17
  110. package/src/cli/commands/scaffold.ts +40 -2
  111. package/src/cli/index.ts +26 -3
  112. package/src/core/operations/model-migration.ts +52 -0
  113. package/src/core/operations/provision-operations.ts +86 -42
  114. package/src/core/operations/scaffold-apply.ts +2 -0
  115. package/src/core/operations/scaffold-machine.ts +1 -0
  116. package/src/core/operations/scaffold-plan.ts +5 -1
  117. package/src/core/project/agent-templates/AGENTS.md +39 -3
  118. package/src/core/project/agent-templates/CLAUDE.md +3 -1
  119. package/src/core/project/agent-templates/copilot-instructions.md +3 -1
  120. package/src/core/project/boundaries.ts +60 -18
  121. package/src/core/project/capabilities.ts +22 -2
  122. package/src/core/project/mcp-config.ts +84 -0
  123. package/src/core/project/workflows.ts +9 -1
  124. package/src/core/scaffold/model-parser.ts +413 -129
  125. package/src/core/scaffold/native-schema-generator.ts +297 -78
  126. package/src/core/scaffold/openapi-generator.ts +63 -5
  127. package/src/core/scaffold/ui-generator.ts +70 -8
  128. package/src/core/verify/index.ts +23 -1
  129. package/src/machine/index.ts +42 -18
  130. package/src/mcp/index.ts +156 -14
  131. package/src/mcp/version-selection.ts +163 -0
  132. package/src/utils/azure-cli.ts +154 -0
  133. 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
+ });
@@ -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: "latest",
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 { applyProvisionOperation, planProvisionOperation } from "../../core/operations/provision-operations";
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", "Apply the displayed plan without an interactive confirmation", false)
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
- const plan = await planProvisionOperation({
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.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(", ")}`);
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 (plan.mode === "adoption-required") {
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 plan.");
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: `Apply the ${plan.mode} plan?`,
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 applyProvisionOperation({ planId: plan.planId, approve: true });
123
- console.log(`Provision completed (${plan.mode}). ${result.executedCommands.length} command(s) executed.`);
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.`); });