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.
- package/dist/cli/commands/add-auth.js +37 -6
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +3 -1
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +45 -1
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.js +2 -2
- 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/project/boundaries.js +3 -3
- package/dist/core/project/boundaries.js.map +1 -1
- package/dist/core/project/capabilities.d.ts +1 -0
- package/dist/core/project/capabilities.d.ts.map +1 -1
- package/dist/core/project/capabilities.js +9 -3
- package/dist/core/project/capabilities.js.map +1 -1
- 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 +15 -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/native-schema-generator.d.ts +2 -0
- package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
- package/dist/core/scaffold/native-schema-generator.js +16 -1
- package/dist/core/scaffold/native-schema-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 +98 -4
- 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 +3 -2
- package/dist/machine/index.js.map +1 -1
- package/dist/mcp/index.d.ts +1 -0
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +19 -4
- package/dist/mcp/index.js.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/__snapshots__/boundaries.test.ts.snap +6 -6
- package/src/__tests__/agent-loop-phase34.test.ts +95 -0
- package/src/__tests__/generated-project-matrix.test.ts +789 -0
- package/src/__tests__/mcp.test.ts +15 -0
- package/src/__tests__/multi-auth.test.ts +115 -4
- package/src/__tests__/plan-apply.test.ts +111 -0
- package/src/cli/commands/add-auth.ts +36 -6
- package/src/cli/commands/init.ts +4 -4
- package/src/cli/commands/scaffold.ts +65 -1
- package/src/cli/index.ts +2 -2
- package/src/core/operations/create-model.ts +51 -13
- package/src/core/project/agent-templates/AGENTS.md +28 -3
- package/src/core/project/agent-templates/CLAUDE.md +4 -0
- package/src/core/project/agent-templates/copilot-instructions.md +4 -0
- package/src/core/project/agent-templates/shared-models.instructions.md +11 -3
- package/src/core/project/boundaries.ts +3 -3
- package/src/core/project/capabilities.ts +10 -3
- package/src/core/project/react-project.ts +1 -1
- package/src/core/project/workflows.ts +15 -5
- package/src/core/scaffold/auth-generator.ts +18 -19
- package/src/core/scaffold/native-schema-generator.ts +16 -0
- package/src/core/verify/index.ts +95 -5
- package/src/machine/contracts.ts +1 -0
- package/src/machine/errors.ts +4 -2
- package/src/machine/index.ts +3 -2
- 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('
|
|
61
|
-
expect(python).not.toContain('
|
|
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
|
-
|
|
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)) {
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
|
|
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
|
|
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')
|
|
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
|
|
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(
|
|
94
|
+
export function updateSharedIndex(modelPath: string, schemaName: string, session: FileOperationSession): boolean {
|
|
94
95
|
const indexPath = path.join(process.cwd(), "shared", "index.ts");
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
101
|
-
|
|
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
|
-
|
|
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(
|
|
213
|
+
if (updateSharedIndex(resolveFromRoot(filePath), pascalName, session)) {
|
|
176
214
|
updatedIndex = true;
|
|
177
215
|
}
|
|
178
216
|
}
|