swallowkit 2.0.0-alpha.4 → 2.0.0-alpha.5

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 (76) hide show
  1. package/dist/cli/commands/add-auth.js +37 -6
  2. package/dist/cli/commands/add-auth.js.map +1 -1
  3. package/dist/cli/commands/init.d.ts +2 -0
  4. package/dist/cli/commands/init.d.ts.map +1 -1
  5. package/dist/cli/commands/init.js +3 -1
  6. package/dist/cli/commands/init.js.map +1 -1
  7. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  8. package/dist/cli/commands/scaffold.js +45 -1
  9. package/dist/cli/commands/scaffold.js.map +1 -1
  10. package/dist/cli/index.js +2 -2
  11. package/dist/cli/index.js.map +1 -1
  12. package/dist/core/operations/create-model.d.ts +2 -0
  13. package/dist/core/operations/create-model.d.ts.map +1 -1
  14. package/dist/core/operations/create-model.js +60 -8
  15. package/dist/core/operations/create-model.js.map +1 -1
  16. package/dist/core/project/boundaries.js +3 -3
  17. package/dist/core/project/boundaries.js.map +1 -1
  18. package/dist/core/project/capabilities.d.ts +1 -0
  19. package/dist/core/project/capabilities.d.ts.map +1 -1
  20. package/dist/core/project/capabilities.js +9 -3
  21. package/dist/core/project/capabilities.js.map +1 -1
  22. package/dist/core/project/react-project.js +1 -1
  23. package/dist/core/project/workflows.d.ts.map +1 -1
  24. package/dist/core/project/workflows.js +15 -5
  25. package/dist/core/project/workflows.js.map +1 -1
  26. package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
  27. package/dist/core/scaffold/auth-generator.js +18 -19
  28. package/dist/core/scaffold/auth-generator.js.map +1 -1
  29. package/dist/core/scaffold/native-schema-generator.d.ts +2 -0
  30. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  31. package/dist/core/scaffold/native-schema-generator.js +16 -1
  32. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  33. package/dist/core/verify/index.d.ts +4 -3
  34. package/dist/core/verify/index.d.ts.map +1 -1
  35. package/dist/core/verify/index.js +98 -4
  36. package/dist/core/verify/index.js.map +1 -1
  37. package/dist/machine/contracts.d.ts +1 -0
  38. package/dist/machine/contracts.d.ts.map +1 -1
  39. package/dist/machine/errors.d.ts +3 -2
  40. package/dist/machine/errors.d.ts.map +1 -1
  41. package/dist/machine/errors.js +2 -1
  42. package/dist/machine/errors.js.map +1 -1
  43. package/dist/machine/index.d.ts.map +1 -1
  44. package/dist/machine/index.js +3 -2
  45. package/dist/machine/index.js.map +1 -1
  46. package/dist/mcp/index.d.ts +1 -0
  47. package/dist/mcp/index.d.ts.map +1 -1
  48. package/dist/mcp/index.js +19 -4
  49. package/dist/mcp/index.js.map +1 -1
  50. package/package.json +6 -3
  51. package/src/__tests__/__snapshots__/boundaries.test.ts.snap +6 -6
  52. package/src/__tests__/agent-loop-phase34.test.ts +95 -0
  53. package/src/__tests__/generated-project-matrix.test.ts +789 -0
  54. package/src/__tests__/mcp.test.ts +15 -0
  55. package/src/__tests__/multi-auth.test.ts +115 -4
  56. package/src/__tests__/plan-apply.test.ts +111 -0
  57. package/src/cli/commands/add-auth.ts +36 -6
  58. package/src/cli/commands/init.ts +4 -4
  59. package/src/cli/commands/scaffold.ts +65 -1
  60. package/src/cli/index.ts +2 -2
  61. package/src/core/operations/create-model.ts +51 -13
  62. package/src/core/project/agent-templates/AGENTS.md +28 -3
  63. package/src/core/project/agent-templates/CLAUDE.md +4 -0
  64. package/src/core/project/agent-templates/copilot-instructions.md +4 -0
  65. package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
  66. package/src/core/project/boundaries.ts +3 -3
  67. package/src/core/project/capabilities.ts +10 -3
  68. package/src/core/project/react-project.ts +1 -1
  69. package/src/core/project/workflows.ts +15 -5
  70. package/src/core/scaffold/auth-generator.ts +18 -19
  71. package/src/core/scaffold/native-schema-generator.ts +16 -0
  72. package/src/core/verify/index.ts +95 -5
  73. package/src/machine/contracts.ts +1 -0
  74. package/src/machine/errors.ts +4 -2
  75. package/src/machine/index.ts +3 -2
  76. package/src/mcp/index.ts +19 -6
@@ -8,6 +8,21 @@ const builtMcpEntrypoint = path.resolve(__dirname, "..", "..", "dist", "mcp", "i
8
8
  const itWhenBuiltEntrypointExists = fs.existsSync(builtMcpEntrypoint) ? it : it.skip;
9
9
 
10
10
  describe("SwallowKit MCP tool definitions", () => {
11
+ it("preserves blocked auth prerequisites and nextActions as structured MCP errors", async () => {
12
+ const nextActions = [{ command: "swallowkit machine plan auth --provider swa", description: "Configure auth first." }];
13
+ const runner = jest.fn(async () => ({
14
+ stdout: JSON.stringify({ ok: false, command: "plan-scaffold", status: "blocked",
15
+ error: { code: "auth-policy-without-auth", message: "Authentication is not configured." }, nextActions }),
16
+ stderr: "", exitCode: 1,
17
+ }));
18
+ const tool = buildSwallowKitToolDefinitions(runner).find(candidate => candidate.name === "swallowkit_plan_scaffold")!;
19
+ const result = await tool.handler({ model: "probe-item" });
20
+ expect(result.isError).toBe(true);
21
+ expect(JSON.parse(result.content[0].text)).toMatchObject({
22
+ status: "blocked", error: { code: "auth-policy-without-auth" }, nextActions,
23
+ metadata: { swallowkitVersion: expect.any(String) },
24
+ });
25
+ });
11
26
  it("delegates inspect_project to the machine CLI", async () => {
12
27
  const runner = jest.fn(async () => ({
13
28
  stdout: JSON.stringify({
@@ -1,6 +1,7 @@
1
1
  import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
+ import { spawnSync } from "child_process";
4
5
  import { addAuthCommand } from "../cli/commands/add-auth";
5
6
  import { createUIAuthOptions } from "../cli/commands/scaffold";
6
7
  import {
@@ -9,12 +10,12 @@ import {
9
10
  generateNamedAuthRouterPython,
10
11
  generateNamedAuthRouterTS,
11
12
  } from "../core/scaffold/auth-generator";
12
- import { normalizeAuthConfig } from "../core/config";
13
+ import { normalizeAuthConfig, validateConfig } from "../core/config";
13
14
  import { parseAuthPolicy } from "../core/scaffold/model-parser";
14
15
  import { createBasicModelInfo } from "./fixtures";
15
16
  import { generateCSharpAzureFunctionsCRUD, generateCompactAzureFunctionsCRUD, generatePythonAzureFunctionsCRUD } from "../core/scaffold/functions-generator";
16
17
  import { generateAuthLayout, generateNewPage } from "../core/scaffold/ui-generator";
17
- import { AuthConfig } from "../types";
18
+ import { AuthConfig, SwallowKitConfig } from "../types";
18
19
 
19
20
  const namedAuthConfig: AuthConfig = {
20
21
  schemes: {
@@ -57,10 +58,24 @@ describe("named authentication routers", () => {
57
58
  it("generates valid branch structure for an SWA-only Python router", () => {
58
59
  const swaOnly = normalizeAuthConfig({ schemes: { admin: { provider: "swa", swa: { allowedProviders: ["github"] } } }, authorization: { defaultPolicy: "anonymous", policies: { adminOnly: { schemes: ["admin"] } } } })!;
59
60
  const python = generateNamedAuthRouterPython(swaOnly);
60
- expect(python).toContain(' if PROVIDERS[scheme] == "swa":');
61
- expect(python).not.toContain(' elif PROVIDERS[scheme] == "swa":');
61
+ expect(python).toContain('\n if PROVIDERS[scheme] == "swa":');
62
+ expect(python).not.toContain('\n elif PROVIDERS[scheme] == "swa":');
62
63
  expect(python).toContain("ALLOWED_PROVIDERS");
63
64
  expect(generateNamedAuthRouterCSharp(swaOnly)).toContain("AllowedProviders[scheme]");
65
+ expect(generateNamedAuthRouterCSharp(swaOnly)).not.toContain("using Functions.Auth.Schemes;");
66
+ });
67
+
68
+ const pythonCommand = ["python3", "python"].find(command => spawnSync(command, ["--version"], { encoding: "utf-8" }).status === 0);
69
+ (pythonCommand ? it : it.skip).each([false, true])("compiles named Python auth router syntax (external schemes: %s)", (external) => {
70
+ const config = external ? auth : normalizeAuthConfig({
71
+ schemes: { admin: { provider: "swa" } },
72
+ authorization: { defaultPolicy: "anonymous", policies: { adminOnly: { schemes: ["admin"] } } },
73
+ })!;
74
+ const result = spawnSync(pythonCommand!, ["-c", "import ast,sys; ast.parse(sys.stdin.read())"], {
75
+ encoding: "utf-8", input: generateNamedAuthRouterPython(config),
76
+ });
77
+ expect(result.stderr).toBe("");
78
+ expect(result.status).toBe(0);
64
79
  });
65
80
 
66
81
  it("emits Array.Empty<string>() instead of untyped new[] { } for empty C# arrays (CS0826)", () => {
@@ -157,4 +172,100 @@ describe("add-auth named scheme CLI", () => {
157
172
  new Function("module", content)(configModule);
158
173
  expect((configModule.exports as { auth: { schemes: { admin: { provider: string } } } }).auth.schemes.admin.provider).toBe("swa");
159
174
  });
175
+
176
+ it("writes named schemes to swallowkit.config.json when both config files exist (loadConfig precedence)", async () => {
177
+ const js = "module.exports = { backend: { language: 'typescript' } };\n";
178
+ fs.writeFileSync(path.join(root, "swallowkit.config.js"), js);
179
+ await addAuthCommand({ scheme: "admin", provider: "swa" });
180
+ expect(JSON.parse(fs.readFileSync(path.join(root, "swallowkit.config.json"), "utf-8")).auth.schemes.admin.provider).toBe("swa");
181
+ expect(fs.readFileSync(path.join(root, "swallowkit.config.js"), "utf-8")).toBe(js);
182
+ });
183
+
184
+ it.each(["json", "js"] as const)("disables auth in a %s config with an anonymous default that passes validation", async (format) => {
185
+ fs.rmSync(path.join(root, "swallowkit.config.json"));
186
+ if (format === "json") fs.writeFileSync(path.join(root, "swallowkit.config.json"), JSON.stringify({ backend: { language: "typescript" } }));
187
+ else fs.writeFileSync(path.join(root, "swallowkit.config.js"), "module.exports = {\n backend: { language: 'typescript' },\n}\n");
188
+ await addAuthCommand({ provider: "none" });
189
+ let config: SwallowKitConfig;
190
+ if (format === "json") {
191
+ config = JSON.parse(fs.readFileSync(path.join(root, "swallowkit.config.json"), "utf-8"));
192
+ } else {
193
+ const configModule = { exports: {} as SwallowKitConfig };
194
+ new Function("module", fs.readFileSync(path.join(root, "swallowkit.config.js"), "utf-8"))(configModule);
195
+ config = configModule.exports;
196
+ }
197
+ expect(config.auth).toMatchObject({ provider: "none", authorization: { defaultPolicy: "anonymous" } });
198
+ expect(validateConfig(config)).toEqual({ valid: true, errors: [] });
199
+ });
200
+
201
+ it("records single-provider auth in swallowkit.config.json instead of silently skipping it", async () => {
202
+ fs.writeFileSync(path.join(root, "swallowkit.config.json"), JSON.stringify({ backend: { language: "typescript" } }));
203
+ fs.mkdirSync(path.join(root, "app"), { recursive: true });
204
+ fs.writeFileSync(path.join(root, "app", "layout.tsx"), "export default function L({ children }: any) { return <html><body>{children}</body></html>; }\n");
205
+ await addAuthCommand({ provider: "swa", allowedProviders: ["github"] });
206
+ expect(JSON.parse(fs.readFileSync(path.join(root, "swallowkit.config.json"), "utf-8")).auth).toEqual({
207
+ provider: "swa",
208
+ swa: { allowedProviders: ["github"], roleSource: "swa-roles" },
209
+ authorization: { defaultPolicy: "authenticated" },
210
+ });
211
+ });
212
+
213
+ it.each(["typescript", "csharp", "python"] as const)("declares %s backend dependencies for a named custom-jwt scheme", async (language) => {
214
+ fs.writeFileSync(path.join(root, "swallowkit.config.json"), JSON.stringify({ backend: { language } }));
215
+ fs.mkdirSync(path.join(root, "functions"), { recursive: true });
216
+ if (language === "typescript") fs.writeFileSync(path.join(root, "functions", "package.json"), JSON.stringify({ name: "functions", dependencies: {} }));
217
+ if (language === "csharp") fs.writeFileSync(path.join(root, "functions", "App.Functions.csproj"), "<Project>\n <ItemGroup>\n </ItemGroup>\n</Project>\n");
218
+ if (language === "python") fs.writeFileSync(path.join(root, "functions", "requirements.txt"), "azure-functions\n");
219
+ await addAuthCommand({ scheme: "staff", provider: "custom-jwt" });
220
+ if (language === "typescript") {
221
+ expect(JSON.parse(fs.readFileSync(path.join(root, "functions", "package.json"), "utf-8")).dependencies).toEqual(expect.objectContaining({ jsonwebtoken: expect.any(String), bcryptjs: expect.any(String) }));
222
+ } else if (language === "csharp") {
223
+ const csproj = fs.readFileSync(path.join(root, "functions", "App.Functions.csproj"), "utf-8");
224
+ expect(csproj).toContain('Include="System.IdentityModel.Tokens.Jwt"');
225
+ expect(csproj).toContain('Include="BCrypt.Net-Next"');
226
+ } else {
227
+ expect(fs.readFileSync(path.join(root, "functions", "requirements.txt"), "utf-8")).toMatch(/PyJWT[\s\S]*bcrypt/);
228
+ }
229
+ });
230
+ });
231
+
232
+ describe("Python custom-jwt blueprint registration", () => {
233
+ const originalCwd = process.cwd();
234
+ let root: string;
235
+
236
+ beforeEach(() => {
237
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-py-auth-"));
238
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
239
+ fs.writeFileSync(path.join(root, "swallowkit.config.json"), JSON.stringify({ backend: { language: "python" } }));
240
+ fs.mkdirSync(path.join(root, "shared"), { recursive: true });
241
+ fs.writeFileSync(path.join(root, "shared", "package.json"), JSON.stringify({ name: "@fixture/shared" }));
242
+ fs.mkdirSync(path.join(root, "functions"), { recursive: true });
243
+ fs.mkdirSync(path.join(root, "app"), { recursive: true });
244
+ fs.writeFileSync(path.join(root, "app", "layout.tsx"), "export default function L({ children }: any) { return <html><body>{children}</body></html>; }\n");
245
+ process.chdir(root);
246
+ });
247
+
248
+ afterEach(() => {
249
+ process.chdir(originalCwd);
250
+ fs.rmSync(root, { recursive: true, force: true });
251
+ });
252
+
253
+ const register = (body: string) => fs.writeFileSync(path.join(root, "functions", "function_app.py"), body);
254
+
255
+ it("imports the blueprint object the generated module actually defines", async () => {
256
+ register("import azure.functions as func\napp = func.FunctionApp()\n# SwallowKit scaffold registrations\n");
257
+ await addAuthCommand({ provider: "custom-jwt" });
258
+ const blueprint = fs.readFileSync(path.join(root, "functions", "blueprints", "auth.py"), "utf-8");
259
+ const app = fs.readFileSync(path.join(root, "functions", "function_app.py"), "utf-8");
260
+ expect(blueprint).toMatch(/^auth_bp = func\.Blueprint\(\)$/m);
261
+ expect(app).toContain("from blueprints.auth import auth_bp\napp.register_blueprint(auth_bp)");
262
+ });
263
+
264
+ it("repairs the broken import written by earlier releases without duplicating the registration", async () => {
265
+ register("import azure.functions as func\napp = func.FunctionApp()\nfrom blueprints.auth import bp as auth_bp\napp.register_blueprint(auth_bp)\n# SwallowKit scaffold registrations\n");
266
+ await addAuthCommand({ provider: "custom-jwt" });
267
+ const app = fs.readFileSync(path.join(root, "functions", "function_app.py"), "utf-8");
268
+ expect(app).not.toContain("import bp as auth_bp");
269
+ expect(app.match(/app\.register_blueprint\(auth_bp\)/g)).toHaveLength(1);
270
+ });
160
271
  });
@@ -2,6 +2,7 @@ import * as fs from "fs";
2
2
  import * as os from "os";
3
3
  import * as path from "path";
4
4
  import * as childProcess from "child_process";
5
+ import * as ts from "typescript";
5
6
  import { EventEmitter } from "events";
6
7
  import { runMachineCli } from "../machine";
7
8
  import { getCSharpSchemaModelPath } from "../core/scaffold/native-schema-generator";
@@ -212,6 +213,7 @@ describe("plan / apply / drift / verify machine commands", () => {
212
213
  }),
213
214
  warnings: [],
214
215
  });
216
+
215
217
  expect(response.data.diagnostics).toEqual([]);
216
218
  expect(response.data.operations).toEqual(
217
219
  expect.arrayContaining([
@@ -230,6 +232,115 @@ describe("plan / apply / drift / verify machine commands", () => {
230
232
  ).toBe(true);
231
233
  });
232
234
 
235
+ it.each(["typescript", "csharp", "python"] as const)(
236
+ "blocks %s scaffold with an explicit authPolicy until authentication is configured",
237
+ async (language) => {
238
+ if (language === "typescript") createProjectFixture(tempDir);
239
+ else createNativeBackendFixture(tempDir, language);
240
+ fs.appendFileSync(path.join(tempDir, "shared", "models", "todo.ts"), "\nexport const authPolicy = { roles: ['admin'] };\n");
241
+ const reset = mockSuccessfulNativeCodegen();
242
+ try {
243
+ const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only", "--skip-infra"));
244
+ expect(plan.response).toMatchObject({
245
+ ok: false, status: "blocked", error: { code: "auth-policy-without-auth" },
246
+ nextActions: [expect.objectContaining({ command: expect.stringContaining("plan auth") })],
247
+ });
248
+ expect(fs.existsSync(path.join(tempDir, ".swallowkit", "state", "plans"))).toBe(false);
249
+ const generate = await runMachine(machineArgs("generate", "scaffold", "todo", "--api-only", "--skip-infra"));
250
+ expect(generate.response).toMatchObject({ ok: false, status: "blocked", error: { code: "auth-policy-without-auth" } });
251
+ expect(fs.existsSync(path.join(tempDir, "functions", "Crud"))).toBe(false);
252
+ expect(fs.readFileSync(path.join(tempDir, "shared", "index.ts"), "utf-8")).toBe("export {};\n");
253
+ } finally {
254
+ reset();
255
+ }
256
+ }
257
+ );
258
+
259
+ it("blocks an explicit role policy when the provider is none", async () => {
260
+ createProjectFixture(tempDir);
261
+ fs.unlinkSync(path.join(tempDir, "swallowkit.config.js"));
262
+ writeFile(path.join(tempDir, "swallowkit.config.json"), JSON.stringify({ auth: { provider: "none" } }));
263
+ fs.appendFileSync(path.join(tempDir, "shared", "models", "todo.ts"), "\nexport const authPolicy = { roles: ['admin'] };\n");
264
+ const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only", "--skip-infra"));
265
+ expect(plan.response).toMatchObject({ status: "blocked", error: { code: "auth-policy-without-auth" } });
266
+ });
267
+
268
+ it("appends a manually authored model export during plan/apply scaffold without duplicating it", async () => {
269
+ createProjectFixture(tempDir);
270
+ const indexPath = path.join(tempDir, "shared", "index.ts");
271
+ const originalIndex = "// Custom exports are preserved.\nexport {};\n";
272
+ fs.writeFileSync(indexPath, originalIndex);
273
+ const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only", "--skip-infra"));
274
+ expect(plan.response.data.operations).toContainEqual(expect.objectContaining({
275
+ path: "shared/index.ts", action: "append", ownership: "extension-point", conflict: false,
276
+ }));
277
+ expect(fs.readFileSync(indexPath, "utf-8")).toBe(originalIndex);
278
+ const apply = await runMachine(machineArgs("apply", "scaffold", "--plan", plan.response.data.planId));
279
+ expect(apply.response.ok).toBe(true);
280
+ expect(fs.readFileSync(indexPath, "utf-8")).toBe(`${originalIndex}export { Todo } from './models/todo';\n`);
281
+ const repeat = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only", "--skip-infra"));
282
+ expect(repeat.response.data.operations.filter((op: { path: string; action: string }) =>
283
+ op.path === "shared/index.ts" && op.action !== "skip")).toEqual([]);
284
+ });
285
+
286
+ it.each<["typescript" | "csharp" | "python", "nextjs" | "react"]>([
287
+ ["typescript", "nextjs"], ["csharp", "react"], ["python", "react"],
288
+ ])("validates auth prerequisites in %s/%s before plan and apply", async (language, frontend) => {
289
+ if (language === "typescript") createProjectFixture(tempDir);
290
+ else createNativeBackendFixture(tempDir, language);
291
+ fs.unlinkSync(path.join(tempDir, "swallowkit.config.js"));
292
+ const configPath = path.join(tempDir, "swallowkit.config.json");
293
+ const config = { backend: { language }, frontend: { framework: frontend },
294
+ auth: { schemes: { admin: { provider: "swa" } }, authorization: {
295
+ defaultPolicy: "anonymous", policies: { adminOnly: { schemes: ["admin"], roles: ["admin"] } },
296
+ } },
297
+ };
298
+ writeFile(configPath, JSON.stringify(config));
299
+ const modelPath = path.join(tempDir, "shared", "models", "todo.ts");
300
+ writeFile(modelPath, `${createModelSource("Todo")}\nexport const authPolicy = { roles: ['admin'] };`);
301
+ const reset = mockSuccessfulNativeCodegen();
302
+ try {
303
+ const invalid = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only", "--skip-infra"));
304
+ expect(invalid.response).toMatchObject({ status: "blocked", error: { code: "auth-policy-requires-named-policy" } });
305
+ writeFile(modelPath, `${createModelSource("Todo")}\nexport const authPolicy = { policy: 'adminOnly' };`);
306
+ const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only", "--skip-infra"));
307
+ expect(plan.response.status).toBe("complete");
308
+ writeFile(configPath, JSON.stringify({ ...config, auth: undefined }));
309
+ const stale = await runMachine(machineArgs("apply", "scaffold", "--plan", plan.response.data.planId));
310
+ expect(stale.response).toMatchObject({ status: "blocked", error: { code: "stale-plan" } });
311
+ const direct = await runMachine(machineArgs("apply", "scaffold", "todo", "--api-only", "--skip-infra"));
312
+ expect(direct.response).toMatchObject({ status: "blocked", error: { code: "auth-policy-without-auth" } });
313
+ expect(fs.readFileSync(path.join(tempDir, "shared", "index.ts"), "utf-8")).toBe("export {};\n");
314
+ } finally { reset(); }
315
+ });
316
+
317
+ it.each([
318
+ ['export { Todo } from "./models/todo";\n', false],
319
+ ["export * from './models/todo';\n", false],
320
+ ["export type { Todo } from './models/todo';\n", true],
321
+ ["export type { Todo, displayName } from './models/todo';\n", true],
322
+ ["export { type Todo } from './models/todo';\n", true],
323
+ ["export { Todo as MyTodo } from './models/todo';", true],
324
+ ])("preserves existing exports and handles syntax variations: %s", async (content, append) => {
325
+ createProjectFixture(tempDir);
326
+ const indexPath = path.join(tempDir, "shared", "index.ts");
327
+ fs.writeFileSync(indexPath, content);
328
+ const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only", "--skip-infra"));
329
+ expect(plan.response.data.operations.some((op: { path: string; action: string }) =>
330
+ op.path === "shared/index.ts" && op.action === "append")).toBe(append);
331
+ expect(fs.readFileSync(indexPath, "utf-8")).toBe(content);
332
+
333
+ const apply = await runMachine(machineArgs("apply", "scaffold", "--plan", plan.response.data.planId));
334
+ expect(apply.response.ok).toBe(true);
335
+ // The shared package must still compile: no duplicate Todo binding, and Todo is usable as a value.
336
+ writeFile(path.join(tempDir, "shared", "consumer.ts"), "import { Todo } from './index';\nexport const parsed: Todo = Todo.parse({ id: '1', name: 'x' });\n");
337
+ const program = ts.createProgram([indexPath, path.join(tempDir, "shared", "consumer.ts")], {
338
+ strict: true, noEmit: true, skipLibCheck: true, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2020,
339
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
340
+ });
341
+ expect(ts.getPreEmitDiagnostics(program).map((d) => ts.flattenDiagnosticMessageText(d.messageText, "\n"))).toEqual([]);
342
+ });
343
+
233
344
  it("updates a ledger-unregistered legacy manifest without approval and preserves baseline identity", async () => {
234
345
  createProjectFixture(tempDir);
235
346
  createInfrastructureFixture(tempDir);
@@ -428,7 +428,7 @@ export async function addAuthCommand(options: AddAuthOptions) {
428
428
 
429
429
  // 10. Install dependencies
430
430
  console.log("\n Installing auth dependencies...");
431
- await installAuthDependencies(cwd, backendLanguage, rdbProvider, session);
431
+ installAuthDependenciesSync(cwd, backendLanguage, rdbProvider, session);
432
432
  await finalize();
433
433
 
434
434
  console.log("\n Authentication setup complete!");
@@ -458,7 +458,8 @@ function findObjectEnd(content: string, open: number): number {
458
458
  function updateConfigWithNamedScheme(cwd: string, scheme: string, provider: AuthProvider, session: FileOperationSession, allowedProviders?: string[]): void {
459
459
  const jsPath = path.join(cwd, "swallowkit.config.js");
460
460
  const jsonPath = path.join(cwd, "swallowkit.config.json");
461
- const configPath = session.fileExists(jsPath) ? jsPath : jsonPath;
461
+ // Match loadConfig precedence: swallowkit.config.json wins when both files exist.
462
+ const configPath = session.fileExists(jsonPath) ? jsonPath : jsPath;
462
463
  if (!session.fileExists(configPath)) throw new Error("swallowkit.config.js or swallowkit.config.json is required");
463
464
  const swaProviders = provider === "swa" ? normalizeSwaProviders(allowedProviders) : undefined;
464
465
  if (configPath.endsWith(".json")) {
@@ -510,6 +511,11 @@ function addNamedScheme(cwd: string, scheme: string, provider: AuthProvider, lan
510
511
  // Validate/update config before creating files, so duplicate names stop safely.
511
512
  updateConfigWithNamedScheme(cwd, scheme, provider, session, allowedProviders);
512
513
  const slug = scheme.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase();
514
+ if (provider === "custom-jwt") {
515
+ // The scheme's JWT helper needs the same token/password/driver packages as single-provider custom-jwt.
516
+ const connector = getConnectorDefinition(defaultCustomJwtConfig().userConnector) as RdbConnectorConfig | undefined;
517
+ installAuthDependenciesSync(cwd, language, connector?.provider ?? "mysql", session);
518
+ }
513
519
  if (language === "typescript") {
514
520
  const dir = path.join(cwd, "functions", "src", "auth", "schemes", slug);
515
521
  if (provider === "external-token") writeIfMissing(session, path.join(dir, "verifier.ts"), generateExternalTokenVerifierTS());
@@ -689,9 +695,14 @@ function generateFunctionsAuth(
689
695
  const functionAppPath = path.join(functionsDir, "function_app.py");
690
696
  if (session.fileExists(functionAppPath)) {
691
697
  const content = session.readFile(functionAppPath);
692
- const authImport = "from blueprints.auth import bp as auth_bp";
698
+ const authImport = "from blueprints.auth import auth_bp";
693
699
  const authRegister = "app.register_blueprint(auth_bp)";
694
- if (!content.includes(authImport)) {
700
+ // Earlier releases imported a non-existent `bp`; repair it in place when re-running.
701
+ const legacyImport = "from blueprints.auth import bp as auth_bp";
702
+ if (content.includes(legacyImport)) {
703
+ session.writeFile(functionAppPath, content.replace(legacyImport, authImport), extensionMeta);
704
+ console.log(` Updated: functions/function_app.py (fixed auth blueprint import)`);
705
+ } else if (!content.includes(authImport)) {
695
706
  const marker = "# SwallowKit scaffold registrations";
696
707
  if (content.includes(marker)) {
697
708
  const updated = content.replace(
@@ -742,6 +753,24 @@ function generateBFFAuth(cwd: string, projectName: string, sharedPackageName: st
742
753
  }
743
754
 
744
755
  function updateConfigWithAuth(cwd: string, provider: AuthProvider, config: CustomJwtConfig, session: FileOperationSession, allowedProviders?: string[]): void {
756
+ const jsonPath = path.join(cwd, "swallowkit.config.json");
757
+ if (session.fileExists(jsonPath)) {
758
+ // loadConfig reads swallowkit.config.json first, so it is the effective configuration.
759
+ const value = JSON.parse(session.readFile(jsonPath));
760
+ if (value.auth) {
761
+ console.log(" 'auth' section already exists in swallowkit.config.json");
762
+ return;
763
+ }
764
+ value.auth = {
765
+ provider,
766
+ ...(provider === "custom-jwt" ? { customJwt: { ...config, jwtSecretEnv: config.jwtSecretEnv || "JWT_SECRET", tokenExpiry: config.tokenExpiry || "24h" } } : {}),
767
+ ...(provider === "swa" ? { swa: { allowedProviders: normalizeSwaProviders(allowedProviders), roleSource: "swa-roles" } } : {}),
768
+ authorization: { defaultPolicy: provider === "none" ? "anonymous" : "authenticated" },
769
+ };
770
+ session.writeFile(jsonPath, JSON.stringify(value, null, 2) + "\n", extensionMeta);
771
+ console.log(` Updated: swallowkit.config.json`);
772
+ return;
773
+ }
745
774
  const configPath = path.join(cwd, "swallowkit.config.js");
746
775
  if (!session.fileExists(configPath)) {
747
776
  console.warn(" swallowkit.config.js not found. Please add auth config manually.");
@@ -786,7 +815,8 @@ function updateConfigWithAuth(cwd: string, provider: AuthProvider, config: Custo
786
815
  provider: '${provider}',
787
816
  ${providerSettings}
788
817
  authorization: {
789
- defaultPolicy: 'authenticated',
818
+ // Disabled auth has no scheme to authenticate against; 'authenticated' would fail validateConfig.
819
+ defaultPolicy: '${provider === "none" ? "anonymous" : "authenticated"}',
790
820
  },
791
821
  },
792
822
  `;
@@ -821,7 +851,7 @@ function updateEnvironmentFiles(cwd: string, session: FileOperationSession): voi
821
851
  }
822
852
  }
823
853
 
824
- async function installAuthDependencies(cwd: string, backendLanguage: BackendLanguage, provider: "mysql" | "postgres" | "sqlserver" = "mysql", session: FileOperationSession): Promise<void> {
854
+ function installAuthDependenciesSync(cwd: string, backendLanguage: BackendLanguage, provider: "mysql" | "postgres" | "sqlserver" = "mysql", session: FileOperationSession): void {
825
855
  if (backendLanguage === "typescript") {
826
856
  const funcPkgPath = path.join(cwd, "functions", "package.json");
827
857
  if (session.fileExists(funcPkgPath)) {
@@ -1,4 +1,4 @@
1
- import * as fs from "fs";
1
+ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
  import { spawn, execSync } from "child_process";
4
4
  import prompts from "prompts";
@@ -1214,7 +1214,7 @@ export async function register() {
1214
1214
  createAiAgentFiles(projectDir, projectName, backendLanguage, pm, frontend);
1215
1215
  }
1216
1216
 
1217
- async function createSharedPackage(projectDir: string, projectName: string) {
1217
+ export async function createSharedPackage(projectDir: string, projectName: string) {
1218
1218
  console.log('📦 Creating shared workspace package for Zod models...\n');
1219
1219
 
1220
1220
  const sharedDir = path.join(projectDir, 'shared');
@@ -1254,7 +1254,7 @@ async function createSharedPackage(projectDir: string, projectName: string) {
1254
1254
  // shared/index.ts (empty re-export file, scaffold will add entries)
1255
1255
  fs.writeFileSync(
1256
1256
  path.join(sharedDir, 'index.ts'),
1257
- `// Shared Zod models — auto-managed by SwallowKit scaffold command\n// Do not edit the export list below manually\n`
1257
+ `// Shared Zod models. SwallowKit create-model/scaffold append missing model exports.\n// You may add exports manually; existing exports and comments are preserved. No managed markers.\n`
1258
1258
  );
1259
1259
 
1260
1260
  // shared/.gitignore
@@ -1266,7 +1266,7 @@ async function createSharedPackage(projectDir: string, projectName: string) {
1266
1266
  console.log('✅ Shared package created\n');
1267
1267
  }
1268
1268
 
1269
- async function createAzureFunctionsProject(
1269
+ export async function createAzureFunctionsProject(
1270
1270
  projectDir: string,
1271
1271
  pm: PackageManager = 'pnpm',
1272
1272
  backendLanguage: BackendLanguage = 'typescript'
@@ -54,6 +54,7 @@ import {
54
54
  } from "../../core/operations/file-session";
55
55
  import { generateModelContainerMigration } from "../../core/operations/migration-generator";
56
56
  import { recordSessionOperations } from "../../core/project/artifacts";
57
+ import { updateSharedIndex } from "../../core/operations/create-model";
57
58
 
58
59
  const SCAFFOLD_GENERATOR = "scaffold";
59
60
 
@@ -158,6 +159,11 @@ export async function scaffoldCommand(options: ScaffoldOptions) {
158
159
  session.setSchemaAnalysis(schemaAnalysis);
159
160
  console.log(`✅ Model parsed: ${modelInfo.name} (${modelInfo.schemaName})`);
160
161
 
162
+ // Validate policy prerequisites before any generated artifacts are written.
163
+ resolveAuthPolicy(modelInfo, getAuthConfig());
164
+ for (const configName of ["swallowkit.config.js", "swallowkit.config.json", ".swallowkitrc.json"]) {
165
+ session.registerInput(path.join(process.cwd(), configName));
166
+ }
161
167
  const userScopeWarning = buildUserScopedDataWarning(modelInfo, getAuthConfig());
162
168
  if (userScopeWarning) {
163
169
  session.addWarning(userScopeWarning);
@@ -217,6 +223,8 @@ export async function scaffoldCommand(options: ScaffoldOptions) {
217
223
  console.log("ℹ️ --api-only skips UI generation only; Functions, BFF routes, OpenAPI, and native schema assets are still updated.");
218
224
  }
219
225
 
226
+ updateSharedIndex(modelPath, modelInfo.schemaName, session);
227
+
220
228
  // 5. Generate BFF callFunction helper
221
229
  await generateCallFunctionHelper(session, apiDir);
222
230
 
@@ -332,10 +340,43 @@ function applyExternalTokenFetchToGeneratedUi(modelInfo: ModelInfo, session: Fil
332
340
  export function resolveAuthPolicy(modelInfo: ModelInfo, authConfig: AuthConfig | undefined): ModelAuthPolicy | undefined {
333
341
  // モデルに明示的な authPolicy がある場合はそれを使用
334
342
  if (modelInfo.authPolicy) {
343
+ if (!isAuthenticationConfigured(authConfig)) {
344
+ throw new MachineCommandError(
345
+ "auth-policy-without-auth",
346
+ `Model '${modelInfo.name}' declares authPolicy, but authentication is not configured. No artifacts were written. Run plan auth and apply auth before scaffolding this model.`,
347
+ { model: modelInfo.name },
348
+ "blocked",
349
+ [{
350
+ command: "swallowkit machine plan auth --provider <custom-jwt|swa|external-token>",
351
+ description: "Choose an authentication provider, review and apply its plan, then re-plan scaffold.",
352
+ }]
353
+ );
354
+ }
335
355
  const normalized = normalizeAuthConfig(authConfig);
356
+ const usesNamedPolicy = Boolean(modelInfo.authPolicy.policy ||
357
+ typeof modelInfo.authPolicy.read === "string" || typeof modelInfo.authPolicy.write === "string");
358
+ // Guards resolve each operation independently: a string selects the named router, an array (or
359
+ // the roles fallback) selects the single-provider helper. Named schemes only have the router.
360
+ const operationIsNamed = (selected: string | string[] | undefined) =>
361
+ typeof selected === "string" || (selected === undefined && Boolean(modelInfo.authPolicy!.policy));
362
+ const everyOperationNamed = operationIsNamed(modelInfo.authPolicy.read) && operationIsNamed(modelInfo.authPolicy.write) &&
363
+ !modelInfo.authPolicy.roles;
364
+ if (authConfig?.schemes && !authConfig.provider && !everyOperationNamed) {
365
+ throw new MachineCommandError(
366
+ "auth-policy-requires-named-policy",
367
+ `Model '${modelInfo.name}' declares an authPolicy whose read or write operation does not resolve to a named policy, but authentication uses named schemes. Use authPolicy = { policy: '<name>' } or name both read and write policies (role arrays are not supported with named schemes); no artifacts were written.`,
368
+ { model: modelInfo.name },
369
+ "blocked",
370
+ [{
371
+ command: "swallowkit machine inspect capabilities",
372
+ description: "Define a named authorization policy selecting the required schemes/roles, reference it from the model, then re-plan scaffold.",
373
+ }]
374
+ );
375
+ }
336
376
  for (const policyName of [modelInfo.authPolicy.policy, typeof modelInfo.authPolicy.read === 'string' ? modelInfo.authPolicy.read : undefined, typeof modelInfo.authPolicy.write === 'string' ? modelInfo.authPolicy.write : undefined].filter((value): value is string => Boolean(value))) {
337
377
  if (!normalized?.authorization.policies[policyName]) throw new Error(`Model '${modelInfo.name}' references undefined auth policy '${policyName}'. Define auth.authorization.policies.${policyName}.`);
338
378
  }
379
+ if (usesNamedPolicy) assertNamedSchemesConfigured(modelInfo.name, "authPolicy", authConfig);
339
380
  return modelInfo.authPolicy;
340
381
  }
341
382
 
@@ -350,12 +391,35 @@ export function resolveAuthPolicy(modelInfo: ModelInfo, authConfig: AuthConfig |
350
391
  return {}; // 空のポリシー = 認証のみ、ロール制限なし
351
392
  }
352
393
 
353
- if (defaultPolicy !== 'anonymous') return { policy: defaultPolicy };
394
+ if (defaultPolicy !== 'anonymous') {
395
+ assertNamedSchemesConfigured(modelInfo.name, `auth.authorization.defaultPolicy '${defaultPolicy}'`, authConfig);
396
+ return { policy: defaultPolicy };
397
+ }
354
398
 
355
399
  // defaultPolicy が 'anonymous' なら認証不要
356
400
  return undefined;
357
401
  }
358
402
 
403
+ /**
404
+ * Named authorization policies are enforced by the generated multi-scheme router and scheme-specific
405
+ * client contexts, which exist only for named auth.schemes. A single-provider configuration
406
+ * (auth.provider) has no router, so referencing a named policy would generate code that cannot build.
407
+ */
408
+ function assertNamedSchemesConfigured(modelName: string, source: string, authConfig: AuthConfig | undefined): void {
409
+ if (Object.keys(authConfig?.schemes ?? {}).some((name) => name !== "default")) return;
410
+ throw new MachineCommandError(
411
+ "auth-named-policy-requires-named-scheme",
412
+ `Model '${modelName}' uses a named authorization policy via ${source}, but authentication is configured with a single auth.provider. ` +
413
+ `Named policies require named auth.schemes; no artifacts were written. Use role lists (authPolicy = { roles: [...] } or read/write role arrays) with auth.provider, or add a named scheme.`,
414
+ { model: modelName, source },
415
+ "blocked",
416
+ [{
417
+ command: "swallowkit machine plan auth --provider <swa|external-token|custom-jwt> --scheme <name>",
418
+ description: "Add a named scheme and reference it from auth.authorization.policies, or switch the model to role lists; then re-plan scaffold.",
419
+ }]
420
+ );
421
+ }
422
+
359
423
  /**
360
424
  * モデルファイルのパスを解決
361
425
  */
package/src/cli/index.ts CHANGED
@@ -223,8 +223,8 @@ export function createProgram(devCommandOverride: Command = devCommand): Command
223
223
 
224
224
  program
225
225
  .command("verify")
226
- .description("Run verification checks (structure, drift, typecheck)")
227
- .option("--checks <ids>", "Comma-separated check ids to run (structure,drift,typecheck)")
226
+ .description("Run verification checks (structure, drift, typecheck, backend-build)")
227
+ .option("--checks <ids>", "Comma-separated check ids (structure,drift,typecheck,backend-build,build,lint,test); quote CSV values in PowerShell")
228
228
  .action(async (options) => {
229
229
  await runVerifyCommand({ checks: options.checks });
230
230
  });
@@ -1,4 +1,5 @@
1
1
  import * as path from "path";
2
+ import * as ts from "typescript";
2
3
  import { ensureSwallowKitProject, loadConfig } from "../config";
3
4
  import { toPascalCase } from "../scaffold/model-parser";
4
5
  import { syncProjectManifest } from "../project/manifest";
@@ -90,21 +91,58 @@ export const connectorConfig = {
90
91
  `;
91
92
  }
92
93
 
93
- function updateSharedIndex(kebabName: string, pascalName: string, session: FileOperationSession): boolean {
94
+ export function updateSharedIndex(modelPath: string, schemaName: string, session: FileOperationSession): boolean {
94
95
  const indexPath = path.join(process.cwd(), "shared", "index.ts");
95
-
96
- if (!session.fileExists(indexPath)) {
97
- return false;
96
+ session.registerInput(indexPath);
97
+ const content = session.fileExists(indexPath) ? session.readFile(indexPath) : "";
98
+ const modulePath = `./${path.relative(path.dirname(indexPath), modelPath).replace(/\\/g, "/").replace(/\.ts$/, "")}`;
99
+ const source = ts.createSourceFile(indexPath, content, ts.ScriptTarget.Latest, true);
100
+ // Text edits that turn a type-only re-export of this schema into nothing (a value export of the same
101
+ // name also exports its type); keeping both would be a duplicate-identifier error.
102
+ const edits: Array<{ start: number; end: number; text: string }> = [];
103
+ let hasValueExport = false;
104
+ for (const statement of source.statements) {
105
+ if (!ts.isExportDeclaration(statement) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
106
+ const exportedPath = statement.moduleSpecifier.text.replace(/\.(ts|js)$/, "");
107
+ if (exportedPath !== modulePath) continue;
108
+ if (!statement.exportClause) {
109
+ if (!statement.isTypeOnly) hasValueExport = true;
110
+ continue;
111
+ }
112
+ if (!ts.isNamedExports(statement.exportClause)) continue;
113
+ const elements = statement.exportClause.elements;
114
+ const matches = elements.filter((element) =>
115
+ element.name.text === schemaName && (element.propertyName?.text ?? element.name.text) === schemaName);
116
+ for (const element of matches) {
117
+ if (!statement.isTypeOnly && !element.isTypeOnly) {
118
+ hasValueExport = true;
119
+ } else if (!statement.isTypeOnly) {
120
+ // export { type Todo } -> export { Todo }
121
+ edits.push({ start: element.getStart(source), end: element.getEnd(), text: schemaName });
122
+ hasValueExport = true;
123
+ } else if (elements.length === 1) {
124
+ let end = statement.getEnd();
125
+ if (content.slice(end, end + 2) === "\r\n") end += 2;
126
+ else if (content[end] === "\n") end += 1;
127
+ edits.push({ start: statement.getStart(source), end, text: "" });
128
+ } else {
129
+ const index = elements.indexOf(element);
130
+ const start = index === 0 ? element.getStart(source) : elements[index - 1].getEnd();
131
+ const end = index === 0 ? elements[index + 1].getStart(source) : element.getEnd();
132
+ edits.push({ start, end, text: "" });
133
+ }
134
+ }
98
135
  }
99
-
100
- const content = session.readFile(indexPath);
101
- const exportLine = `export { ${pascalName} } from './models/${kebabName}';`;
102
-
103
- if (content.includes(exportLine)) {
104
- return false;
136
+ let updated = content;
137
+ for (const edit of edits.sort((left, right) => right.start - left.start)) {
138
+ updated = `${updated.slice(0, edit.start)}${edit.text}${updated.slice(edit.end)}`;
105
139
  }
106
-
107
- session.writeFile(indexPath, `${content}${exportLine}\n`, {
140
+ if (hasValueExport && updated === content) return false;
141
+ if (!hasValueExport) {
142
+ const exportLine = `export { ${schemaName} } from '${modulePath}';`;
143
+ updated = `${updated}${updated && !updated.endsWith("\n") ? "\n" : ""}${exportLine}\n`;
144
+ }
145
+ session.writeFile(indexPath, updated, {
108
146
  ownership: "extension-point",
109
147
  generator: CREATE_MODEL_GENERATOR,
110
148
  });
@@ -172,7 +210,7 @@ export async function createModelOperation(options: CreateModelOperationOptions)
172
210
  });
173
211
  createdFiles.push(filePath.replace(/\\/g, "/"));
174
212
 
175
- if (updateSharedIndex(kebabName, pascalName, session)) {
213
+ if (updateSharedIndex(resolveFromRoot(filePath), pascalName, session)) {
176
214
  updatedIndex = true;
177
215
  }
178
216
  }