swallowkit 2.0.0-alpha.3 → 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.
- package/dist/cli/commands/add-auth.js +37 -6
- package/dist/cli/commands/add-auth.js.map +1 -1
- 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 +6 -1
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +19 -5
- 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 +85 -3
- 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 +29 -4
- package/dist/cli/index.js.map +1 -1
- package/dist/core/operations/create-model.d.ts +2 -0
- package/dist/core/operations/create-model.d.ts.map +1 -1
- package/dist/core/operations/create-model.js +60 -8
- package/dist/core/operations/create-model.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 +51 -20
- package/dist/core/project/boundaries.js.map +1 -1
- package/dist/core/project/capabilities.d.ts +2 -0
- package/dist/core/project/capabilities.d.ts.map +1 -1
- package/dist/core/project/capabilities.js +29 -5
- 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/react-project.js +1 -1
- package/dist/core/project/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +23 -5
- package/dist/core/project/workflows.js.map +1 -1
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
- package/dist/core/scaffold/auth-generator.js +18 -19
- package/dist/core/scaffold/auth-generator.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 +13 -0
- package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
- package/dist/core/scaffold/native-schema-generator.js +289 -76
- 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 +4 -3
- package/dist/core/verify/index.d.ts.map +1 -1
- package/dist/core/verify/index.js +122 -5
- package/dist/core/verify/index.js.map +1 -1
- package/dist/machine/contracts.d.ts +1 -0
- package/dist/machine/contracts.d.ts.map +1 -1
- package/dist/machine/errors.d.ts +3 -2
- package/dist/machine/errors.d.ts.map +1 -1
- package/dist/machine/errors.js +2 -1
- package/dist/machine/errors.js.map +1 -1
- package/dist/machine/index.d.ts.map +1 -1
- package/dist/machine/index.js +42 -17
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts +4 -2
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +154 -15
- 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 +6 -3
- package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
- package/src/__tests__/agent-loop-phase34.test.ts +124 -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__/generated-project-matrix.test.ts +789 -0
- package/src/__tests__/mcp-version.test.ts +262 -0
- package/src/__tests__/mcp.test.ts +64 -3
- package/src/__tests__/multi-auth.test.ts +115 -4
- package/src/__tests__/plan-apply.test.ts +170 -0
- package/src/__tests__/schema-analysis.test.ts +329 -0
- package/src/cli/commands/add-auth.ts +36 -6
- package/src/cli/commands/configure-mcp.ts +54 -0
- package/src/cli/commands/init.ts +23 -8
- package/src/cli/commands/migrations.ts +22 -0
- package/src/cli/commands/provision.ts +53 -17
- package/src/cli/commands/scaffold.ts +105 -3
- package/src/cli/index.ts +28 -5
- package/src/core/operations/create-model.ts +51 -13
- 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 +67 -6
- package/src/core/project/agent-templates/CLAUDE.md +7 -1
- package/src/core/project/agent-templates/copilot-instructions.md +7 -1
- package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
- package/src/core/project/boundaries.ts +62 -20
- package/src/core/project/capabilities.ts +32 -5
- package/src/core/project/mcp-config.ts +84 -0
- package/src/core/project/react-project.ts +1 -1
- package/src/core/project/workflows.ts +23 -5
- package/src/core/scaffold/auth-generator.ts +18 -19
- package/src/core/scaffold/model-parser.ts +413 -129
- package/src/core/scaffold/native-schema-generator.ts +313 -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 +118 -6
- package/src/machine/contracts.ts +1 -0
- package/src/machine/errors.ts +4 -2
- package/src/machine/index.ts +45 -20
- package/src/mcp/index.ts +173 -18
- package/src/mcp/version-selection.ts +163 -0
- package/src/utils/azure-cli.ts +154 -0
- package/src/utils/semver.ts +59 -0
|
@@ -2,10 +2,12 @@ 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";
|
|
8
9
|
import { initializeLegacyMigrations } from "../core/operations/migration-generator";
|
|
10
|
+
import { addModelContainerMigration } from "../core/operations/model-migration";
|
|
9
11
|
import {
|
|
10
12
|
MIGRATION_MANIFEST_PATH,
|
|
11
13
|
createMigrationManifest,
|
|
@@ -211,6 +213,7 @@ describe("plan / apply / drift / verify machine commands", () => {
|
|
|
211
213
|
}),
|
|
212
214
|
warnings: [],
|
|
213
215
|
});
|
|
216
|
+
|
|
214
217
|
expect(response.data.diagnostics).toEqual([]);
|
|
215
218
|
expect(response.data.operations).toEqual(
|
|
216
219
|
expect.arrayContaining([
|
|
@@ -229,6 +232,115 @@ describe("plan / apply / drift / verify machine commands", () => {
|
|
|
229
232
|
).toBe(true);
|
|
230
233
|
});
|
|
231
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
|
+
|
|
232
344
|
it("updates a ledger-unregistered legacy manifest without approval and preserves baseline identity", async () => {
|
|
233
345
|
createProjectFixture(tempDir);
|
|
234
346
|
createInfrastructureFixture(tempDir);
|
|
@@ -380,6 +492,64 @@ describe("plan / apply / drift / verify machine commands", () => {
|
|
|
380
492
|
}));
|
|
381
493
|
});
|
|
382
494
|
|
|
495
|
+
it("plans and applies with --skip-infra without touching infra/, then adds only the migration later", async () => {
|
|
496
|
+
createProjectFixture(tempDir);
|
|
497
|
+
createInfrastructureFixture(tempDir);
|
|
498
|
+
saveMigrationManifest(createMigrationManifest(tempDir, { legacy: false, projectId: "new-project" }), tempDir);
|
|
499
|
+
const snapshotInfra = () => {
|
|
500
|
+
const files: Record<string, string> = {};
|
|
501
|
+
const visit = (directory: string) => {
|
|
502
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
503
|
+
const absolute = path.join(directory, entry.name);
|
|
504
|
+
if (entry.isDirectory()) visit(absolute);
|
|
505
|
+
else files[path.relative(tempDir, absolute).replace(/\\/g, "/")] = fs.readFileSync(absolute, "utf-8");
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
visit(path.join(tempDir, "infra"));
|
|
509
|
+
return files;
|
|
510
|
+
};
|
|
511
|
+
const before = snapshotInfra();
|
|
512
|
+
|
|
513
|
+
const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only", "--skip-infra"));
|
|
514
|
+
expect(plan.exitCode).toBe(0);
|
|
515
|
+
expect(plan.response.data.options.skipInfra).toBe(true);
|
|
516
|
+
expect(plan.response.data.operations.filter((operation: { path: string }) => operation.path.startsWith("infra/"))).toEqual([]);
|
|
517
|
+
expect(plan.response.data.warnings).toEqual(expect.arrayContaining([
|
|
518
|
+
expect.stringMatching(/^infra-skipped: The Cosmos DB container migration for Todo was not generated/),
|
|
519
|
+
]));
|
|
520
|
+
expect(plan.response.nextActions).toContainEqual(expect.objectContaining({ command: "swallowkit migrations add-model todo" }));
|
|
521
|
+
|
|
522
|
+
const apply = await runMachine(machineArgs("apply", "scaffold", "--plan", plan.response.data.planId));
|
|
523
|
+
expect(apply.exitCode).toBe(0);
|
|
524
|
+
expect(apply.response.data.createdFiles.filter((file: string) => file.startsWith("infra/"))).toEqual([]);
|
|
525
|
+
expect(apply.response.nextActions).toContainEqual(expect.objectContaining({ command: "swallowkit migrations add-model todo" }));
|
|
526
|
+
expect(snapshotInfra()).toEqual(before);
|
|
527
|
+
|
|
528
|
+
const verify = await runMachine(machineArgs("verify", "project", "--checks", "structure,drift"));
|
|
529
|
+
expect(verify.exitCode).toBe(0);
|
|
530
|
+
expect(verify.response.data.summary.done).toBe(true);
|
|
531
|
+
const drift = await runMachine(machineArgs("inspect", "drift"));
|
|
532
|
+
expect(drift.response.data.findings.filter((finding: { severity: string }) => finding.severity !== "info")).toEqual([]);
|
|
533
|
+
|
|
534
|
+
const dryRun = await addModelContainerMigration("todo", { dryRun: true });
|
|
535
|
+
expect(dryRun.operations.map((operation) => operation.path)).toEqual([
|
|
536
|
+
"infra/migrations/0001-create-todo-container/main.bicep",
|
|
537
|
+
MIGRATION_MANIFEST_PATH.replace(/\\/g, "/"),
|
|
538
|
+
]);
|
|
539
|
+
expect(snapshotInfra()).toEqual(before);
|
|
540
|
+
|
|
541
|
+
const added = await addModelContainerMigration("todo");
|
|
542
|
+
expect(added.migration).toEqual({ version: 1, template: "infra/migrations/0001-create-todo-container/main.bicep" });
|
|
543
|
+
expect(fs.readFileSync(path.join(tempDir, added.migration!.template), "utf-8")).toContain("'/id'");
|
|
544
|
+
expect(loadMigrationManifest(tempDir).migrations).toEqual([
|
|
545
|
+
expect.objectContaining({ version: 1, source: "model", sourceModel: "Todo" }),
|
|
546
|
+
]);
|
|
547
|
+
const again = await addModelContainerMigration("todo");
|
|
548
|
+
expect(again.operations.every((operation) => operation.action === "skip")).toBe(true);
|
|
549
|
+
const verifyAfter = await runMachine(machineArgs("verify", "project", "--checks", "structure,drift"));
|
|
550
|
+
expect(verifyAfter.response.data.summary.done).toBe(true);
|
|
551
|
+
});
|
|
552
|
+
|
|
383
553
|
it.each([
|
|
384
554
|
{
|
|
385
555
|
name: "invalid manifest",
|
|
@@ -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
|
+
});
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
698
|
+
const authImport = "from blueprints.auth import auth_bp";
|
|
693
699
|
const authRegister = "app.register_blueprint(auth_bp)";
|
|
694
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)) {
|