swallowkit 1.1.1 → 1.1.3
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/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +0 -2
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/provision.d.ts +1 -0
- package/dist/cli/commands/provision.d.ts.map +1 -1
- package/dist/cli/commands/provision.js +144 -54
- package/dist/cli/commands/provision.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +6 -1
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/core/operations/migration-generator.d.ts +1 -1
- package/dist/core/operations/migration-generator.d.ts.map +1 -1
- package/dist/core/operations/migration-generator.js +22 -0
- package/dist/core/operations/migration-generator.js.map +1 -1
- package/dist/core/project/workflows.d.ts.map +1 -1
- package/dist/core/project/workflows.js +13 -0
- package/dist/core/project/workflows.js.map +1 -1
- package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
- package/dist/core/scaffold/ui-generator.js +26 -2
- package/dist/core/scaffold/ui-generator.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/ui-generator.test.ts.snap +26 -2
- package/src/__tests__/agent-instructions.test.ts +22 -1
- package/src/__tests__/init-agent-instructions.test.ts +99 -0
- package/src/__tests__/plan-apply.test.ts +101 -0
- package/src/__tests__/provision-output.test.ts +313 -0
- package/src/__tests__/ui-generator.test.ts +21 -0
- package/src/cli/commands/init.ts +0 -3
- package/src/cli/commands/provision.ts +89 -3
- package/src/cli/commands/scaffold.ts +5 -1
- package/src/core/operations/migration-generator.ts +35 -1
- package/src/core/project/agent-templates/AGENTS.md +26 -0
- package/src/core/project/agent-templates/shared-models.instructions.md +7 -0
- package/src/core/project/workflows.ts +13 -0
- package/src/core/scaffold/ui-generator.ts +26 -2
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as childProcess from "child_process";
|
|
4
|
+
import { initCommand } from "../cli/commands/init";
|
|
5
|
+
import { buildAgentsMd } from "../core/project/agent-instructions";
|
|
6
|
+
import { generateDetailPage, generateEditPage } from "../core/scaffold/ui-generator";
|
|
7
|
+
import { createBasicModelInfo } from "./fixtures";
|
|
8
|
+
|
|
9
|
+
jest.mock("child_process", () => ({
|
|
10
|
+
...jest.requireActual<typeof import("child_process")>("child_process"),
|
|
11
|
+
spawn: jest.fn(),
|
|
12
|
+
execSync: jest.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe("init persistence instructions", () => {
|
|
16
|
+
const cwd = process.cwd();
|
|
17
|
+
let root: string;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
root = fs.mkdtempSync(path.join(cwd, ".init-instructions-test-"));
|
|
21
|
+
process.chdir(root);
|
|
22
|
+
jest.spyOn(console, "log").mockImplementation(() => undefined);
|
|
23
|
+
jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
24
|
+
jest.spyOn(process, "exit").mockImplementation(() => { throw new Error("Unexpected init failure"); });
|
|
25
|
+
jest.mocked(childProcess.execSync).mockReturnValue("main\n");
|
|
26
|
+
const spawn = jest.mocked(childProcess.spawn as (
|
|
27
|
+
file: string, args: readonly string[]
|
|
28
|
+
) => childProcess.ChildProcess);
|
|
29
|
+
spawn.mockImplementation((_file, args = []) => {
|
|
30
|
+
if (args.includes("create-next-app") || args.includes("create-next-app@latest")) {
|
|
31
|
+
const project = path.join(root, "fictional-app");
|
|
32
|
+
fs.mkdirSync(path.join(project, "app"), { recursive: true });
|
|
33
|
+
fs.writeFileSync(path.join(project, "package.json"), JSON.stringify({ name: "fictional-app", scripts: {}, dependencies: {} }));
|
|
34
|
+
fs.writeFileSync(path.join(project, "next.config.ts"), "const nextConfig = { cacheComponents: true };\nexport default nextConfig;\n");
|
|
35
|
+
fs.writeFileSync(path.join(project, "tsconfig.json"), '{"compilerOptions":{}}');
|
|
36
|
+
}
|
|
37
|
+
const child = new childProcess.ChildProcess();
|
|
38
|
+
process.nextTick(() => child.emit("close", 0));
|
|
39
|
+
return child;
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
process.chdir(cwd);
|
|
45
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
46
|
+
jest.restoreAllMocks();
|
|
47
|
+
jest.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it.each(["npm", "pnpm"] as const)("writes the persistence rule through actual init using %s, with subprocesses mocked", async (packageManager) => {
|
|
51
|
+
await initCommand({
|
|
52
|
+
name: "fictional-app", template: "default", cicd: "skip", backendLanguage: "typescript",
|
|
53
|
+
cosmosDbMode: "serverless", vnet: "none", swaPlan: "free", packageManager,
|
|
54
|
+
});
|
|
55
|
+
const project = path.join(root, "fictional-app");
|
|
56
|
+
const home = fs.readFileSync(path.join(project, "app", "page.tsx"), "utf-8");
|
|
57
|
+
expect(home).toMatch(/^'use client'/);
|
|
58
|
+
expect(home).not.toMatch(/export const (dynamic|revalidate|fetchCache)\b/);
|
|
59
|
+
expect(home).toContain("useState");
|
|
60
|
+
expect(home).toContain("fetch('/api/greet?name=SwallowKit')");
|
|
61
|
+
expect(home).toContain("onClick={testConnection}");
|
|
62
|
+
expect(home).toContain("scaffoldConfig.models.map");
|
|
63
|
+
const config = fs.readFileSync(path.join(project, "next.config.ts"), "utf-8");
|
|
64
|
+
expect(config).toContain("cacheComponents: true");
|
|
65
|
+
expect(config).toContain("output: 'standalone'");
|
|
66
|
+
const model = createBasicModelInfo();
|
|
67
|
+
for (const [route, code, content] of [
|
|
68
|
+
[path.join("app", "todo", "[id]", "page.tsx"), generateDetailPage(model, "@fictional-app/shared"), "TodoDetailContent"],
|
|
69
|
+
[path.join("app", "todo", "[id]", "edit", "page.tsx"), generateEditPage(model, "@fictional-app/shared"), "EditTodoContent"],
|
|
70
|
+
]) {
|
|
71
|
+
const file = path.join(project, route);
|
|
72
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
73
|
+
fs.writeFileSync(file, code);
|
|
74
|
+
const generated = fs.readFileSync(file, "utf-8");
|
|
75
|
+
expect(generated).toContain(`<${content} />`);
|
|
76
|
+
expect(generated).toContain("<Suspense fallback=");
|
|
77
|
+
expect(generated.slice(0, generated.indexOf(`function ${content}`))).not.toContain("useParams()");
|
|
78
|
+
expect(generated).not.toMatch(/export const (dynamic|revalidate|fetchCache|instant)\b/);
|
|
79
|
+
}
|
|
80
|
+
expect(fs.readFileSync(path.join(project, "next.config.ts"), "utf-8")).toContain("cacheComponents: true");
|
|
81
|
+
const agents = fs.readFileSync(path.join(project, "AGENTS.md"), "utf-8");
|
|
82
|
+
expect(agents).toBe(buildAgentsMd({ projectName: "fictional-app", backendLanguage: "typescript", packageManager }));
|
|
83
|
+
expect(agents).toContain("**whether or not it uses custom APIs**");
|
|
84
|
+
expect(agents).toContain(".swallowkit/artifacts.json");
|
|
85
|
+
expect(agents).toContain("Never describe a project as deployable");
|
|
86
|
+
for (const file of [
|
|
87
|
+
path.join(".swallowkit", "workflows", "add-model.md"),
|
|
88
|
+
path.join(".github", "skills", "swallowkit-add-model", "SKILL.md"),
|
|
89
|
+
path.join(".github", "instructions", "shared-models.instructions.md"),
|
|
90
|
+
]) {
|
|
91
|
+
const content = fs.readFileSync(path.join(project, file), "utf-8");
|
|
92
|
+
expect(content).toContain("even when all APIs are custom");
|
|
93
|
+
expect(content).toContain(".swallowkit/artifacts.json");
|
|
94
|
+
}
|
|
95
|
+
for (const file of ["CLAUDE.md", path.join(".github", "copilot-instructions.md")]) {
|
|
96
|
+
expect(fs.readFileSync(path.join(project, file), "utf-8")).toContain("root `AGENTS.md`");
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -83,6 +83,19 @@ function createInfrastructureFixture(rootDir: string): void {
|
|
|
83
83
|
writeFile(path.join(rootDir, "infra", "modules", "cosmosdb.bicep"), "// baseline cosmos\n");
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
function addLegacyContainerToInfrastructure(rootDir: string, modelKebab: string): void {
|
|
87
|
+
const containerPath = `containers/${modelKebab}-container.bicep`;
|
|
88
|
+
fs.appendFileSync(
|
|
89
|
+
path.join(rootDir, "infra", "main.bicep"),
|
|
90
|
+
`\nmodule ${modelKebab.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())}Container '${containerPath}' = {\n name: '${modelKebab}-container'\n}\n`,
|
|
91
|
+
"utf-8"
|
|
92
|
+
);
|
|
93
|
+
writeFile(
|
|
94
|
+
path.join(rootDir, "infra", containerPath),
|
|
95
|
+
"param partitionKeyPath string = '/id'\n\nresource container 'Microsoft.DocumentDB/databaseAccounts/sqlDatabases/containers@2023-11-15' = {\n name: 'Todos'\n}\n"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
86
99
|
function createNativeBackendFixture(rootDir: string, backendLanguage: "csharp" | "python"): void {
|
|
87
100
|
createProjectFixture(rootDir);
|
|
88
101
|
writeFile(
|
|
@@ -254,6 +267,94 @@ describe("plan / apply / drift / verify machine commands", () => {
|
|
|
254
267
|
]);
|
|
255
268
|
});
|
|
256
269
|
|
|
270
|
+
it("updates baseline-owned model artifacts without creating a second container migration", async () => {
|
|
271
|
+
createNativeBackendFixture(tempDir, "csharp");
|
|
272
|
+
writeFile(path.join(tempDir, "shared", "models", "answer-record.ts"), createModelSource("AnswerRecord"));
|
|
273
|
+
createInfrastructureFixture(tempDir);
|
|
274
|
+
addLegacyContainerToInfrastructure(tempDir, "answer-record");
|
|
275
|
+
const original = initializeLegacyMigrations(tempDir);
|
|
276
|
+
const restoreCodegenMocks = mockSuccessfulNativeCodegen();
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const baselineModelApply = await runMachine(machineArgs("apply", "scaffold", "answer-record"));
|
|
280
|
+
expect(baselineModelApply.exitCode).toBe(0);
|
|
281
|
+
expect(loadMigrationManifest(tempDir).migrations).toEqual([]);
|
|
282
|
+
|
|
283
|
+
writeFile(
|
|
284
|
+
path.join(tempDir, "shared", "models", "written-mock-session.ts"),
|
|
285
|
+
createModelSource("WrittenMockSession")
|
|
286
|
+
);
|
|
287
|
+
const newModelApply = await runMachine(machineArgs("apply", "scaffold", "written-mock-session"));
|
|
288
|
+
expect(newModelApply.exitCode).toBe(0);
|
|
289
|
+
expect(loadMigrationManifest(tempDir).migrations).toEqual([
|
|
290
|
+
expect.objectContaining({
|
|
291
|
+
version: 1,
|
|
292
|
+
slug: "create-written-mock-session-container",
|
|
293
|
+
sourceModel: "WrittenMockSession",
|
|
294
|
+
}),
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
writeFile(
|
|
298
|
+
path.join(tempDir, "shared", "models", "answer-record.ts"),
|
|
299
|
+
createModelSource("AnswerRecord", "\n writtenMockSessionId: z.string().optional(),")
|
|
300
|
+
);
|
|
301
|
+
const plan = await runMachine(machineArgs("plan", "scaffold", "answer-record"));
|
|
302
|
+
|
|
303
|
+
expect(plan.exitCode).toBe(0);
|
|
304
|
+
expect(plan.response.ok).toBe(true);
|
|
305
|
+
expect(plan.response.data.operations).toEqual(expect.arrayContaining([
|
|
306
|
+
expect.objectContaining({ path: "functions/openapi/answer-record.openapi.json", action: "update" }),
|
|
307
|
+
expect.objectContaining({
|
|
308
|
+
path: "functions/generated/csharp-models/src/SwallowKitBackendModels/Model/AnswerRecord.cs",
|
|
309
|
+
action: "update",
|
|
310
|
+
}),
|
|
311
|
+
expect.objectContaining({ path: "app/answer-record/_components/AnswerRecordForm.tsx", action: "update" }),
|
|
312
|
+
]));
|
|
313
|
+
expect(plan.response.data.operations).not.toEqual(expect.arrayContaining([
|
|
314
|
+
expect.objectContaining({ path: expect.stringContaining("infra/migrations/0002-") }),
|
|
315
|
+
]));
|
|
316
|
+
expect(plan.response.data.operations).not.toEqual(expect.arrayContaining([
|
|
317
|
+
expect.objectContaining({ path: MIGRATION_MANIFEST_PATH.replace(/\\/g, "/") }),
|
|
318
|
+
]));
|
|
319
|
+
|
|
320
|
+
const apply = await runMachine(machineArgs("apply", "scaffold", "--plan", plan.response.data.planId));
|
|
321
|
+
expect(apply.exitCode).toBe(0);
|
|
322
|
+
const updated = loadMigrationManifest(tempDir);
|
|
323
|
+
expect(updated.migrations).toHaveLength(1);
|
|
324
|
+
expect(updated.baseline).toEqual(original.baseline);
|
|
325
|
+
|
|
326
|
+
const verify = await runMachine(machineArgs("verify", "project", "--checks", "structure,drift"));
|
|
327
|
+
expect(verify.exitCode).toBe(0);
|
|
328
|
+
expect(verify.response.ok).toBe(true);
|
|
329
|
+
expect(verify.response.data.summary.done).toBe(true);
|
|
330
|
+
} finally {
|
|
331
|
+
restoreCodegenMocks();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("requires an explicit migration when a baseline-owned container partition key changes", async () => {
|
|
336
|
+
createProjectFixture(tempDir);
|
|
337
|
+
writeFile(
|
|
338
|
+
path.join(tempDir, "shared", "models", "todo.ts"),
|
|
339
|
+
`${createModelSource("Todo")}\nexport const partitionKey = '/tenantId';\n`
|
|
340
|
+
);
|
|
341
|
+
createInfrastructureFixture(tempDir);
|
|
342
|
+
addLegacyContainerToInfrastructure(tempDir, "todo");
|
|
343
|
+
initializeLegacyMigrations(tempDir);
|
|
344
|
+
|
|
345
|
+
const plan = await runMachine(machineArgs("plan", "scaffold", "todo", "--api-only"));
|
|
346
|
+
|
|
347
|
+
expect(plan.exitCode).toBe(1);
|
|
348
|
+
expect(plan.response.ok).toBe(false);
|
|
349
|
+
expect(plan.response.status).toBe("blocked");
|
|
350
|
+
expect(plan.response.error.code).toBe("baseline-container-configuration-change");
|
|
351
|
+
expect(plan.response.error.details).toEqual(expect.objectContaining({
|
|
352
|
+
baselinePartitionKey: "/id",
|
|
353
|
+
requestedPartitionKey: "/tenantId",
|
|
354
|
+
}));
|
|
355
|
+
expect(fs.existsSync(path.join(tempDir, "infra", "migrations", "0001-create-todo-container"))).toBe(false);
|
|
356
|
+
});
|
|
357
|
+
|
|
257
358
|
it("uses the same approval-free manifest update for a non-legacy project", async () => {
|
|
258
359
|
createProjectFixture(tempDir);
|
|
259
360
|
createInfrastructureFixture(tempDir);
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as childProcess from "child_process";
|
|
4
|
+
import type { SpiedFunction } from "jest-mock";
|
|
5
|
+
import * as migrations from "../core/project/migrations";
|
|
6
|
+
import prompts from "prompts";
|
|
7
|
+
import { createProvisionCommand } from "../cli/commands/provision";
|
|
8
|
+
import { planProvisionOperation } from "../core/operations/provision-operations";
|
|
9
|
+
import { createCustomMigration } from "../core/operations/migration-generator";
|
|
10
|
+
import { runMachineCli } from "../machine";
|
|
11
|
+
import { buildSwallowKitToolDefinitions } from "../mcp";
|
|
12
|
+
|
|
13
|
+
jest.mock("child_process", () => ({
|
|
14
|
+
...jest.requireActual<typeof import("child_process")>("child_process"),
|
|
15
|
+
spawnSync: jest.fn(),
|
|
16
|
+
}));
|
|
17
|
+
jest.mock("prompts", () => jest.fn());
|
|
18
|
+
|
|
19
|
+
const subscription = "00000000-0000-4000-8000-000000000001";
|
|
20
|
+
const token = "fictional-swa-token-for-tests";
|
|
21
|
+
const profile = '<publishData><publishProfile profileName="fictional" userPWD="fictional-password" /></publishData>';
|
|
22
|
+
const resources = {
|
|
23
|
+
staticWebAppName: "swa-fictional",
|
|
24
|
+
staticWebAppUrl: "https://fictional.example",
|
|
25
|
+
functionsAppName: "func-fictional",
|
|
26
|
+
functionsAppUrl: "https://functions.example",
|
|
27
|
+
cosmosDbAccountName: "cosmos-fictional",
|
|
28
|
+
cosmosDatabaseName: "fictionalDatabase",
|
|
29
|
+
};
|
|
30
|
+
type AzResponse = { status: number | null; stdout: string; stderr?: string; error?: Error; signal?: NodeJS.Signals | null };
|
|
31
|
+
const az = jest.mocked(childProcess.spawnSync as (
|
|
32
|
+
file: string, args: readonly string[]
|
|
33
|
+
) => childProcess.SpawnSyncReturns<string>);
|
|
34
|
+
|
|
35
|
+
describe("stable human provision setup output", () => {
|
|
36
|
+
const cwd = process.cwd();
|
|
37
|
+
let root: string;
|
|
38
|
+
let exists: boolean;
|
|
39
|
+
let tags: Record<string, string>;
|
|
40
|
+
let outputResponse: AzResponse;
|
|
41
|
+
let tokenResponse: AzResponse;
|
|
42
|
+
let profileResponse: AzResponse;
|
|
43
|
+
let failCommand: string | undefined;
|
|
44
|
+
let log: SpiedFunction<typeof console.log>;
|
|
45
|
+
let warn: SpiedFunction<typeof console.warn>;
|
|
46
|
+
const options = { resourceGroup: "rg-fictional", subscription, location: "japaneast", swaLocation: "eastasia" };
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
root = fs.mkdtempSync(path.join(cwd, ".provision-output-test-"));
|
|
50
|
+
process.chdir(root);
|
|
51
|
+
fs.writeFileSync(path.join(root, "swallowkit.config.js"), "module.exports = {};\n");
|
|
52
|
+
fs.mkdirSync(path.join(root, "infra"));
|
|
53
|
+
fs.writeFileSync(path.join(root, "infra", "main.bicep"), "targetScope = 'resourceGroup'\n");
|
|
54
|
+
fs.writeFileSync(path.join(root, "infra", "main.parameters.json"), JSON.stringify({ parameters: { projectName: { value: "fictional" } } }));
|
|
55
|
+
migrations.saveMigrationManifest(migrations.createMigrationManifest(root), root);
|
|
56
|
+
exists = false;
|
|
57
|
+
tags = {};
|
|
58
|
+
outputResponse = { status: 0, stdout: JSON.stringify(resources) };
|
|
59
|
+
tokenResponse = { status: 0, stdout: token };
|
|
60
|
+
profileResponse = { status: 0, stdout: profile };
|
|
61
|
+
failCommand = undefined;
|
|
62
|
+
log = jest.spyOn(console, "log").mockImplementation(() => undefined);
|
|
63
|
+
warn = jest.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
64
|
+
const respond = (args: readonly string[]): AzResponse => {
|
|
65
|
+
const command = args.slice(0, 3).join(" ");
|
|
66
|
+
if (command === failCommand) return { status: 1, stdout: "", stderr: "fictional failure" };
|
|
67
|
+
if (args[0] === "--version") return { status: 0, stdout: "fictional az" };
|
|
68
|
+
if (args[0] === "account") return { status: 0, stdout: subscription };
|
|
69
|
+
if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: String(exists) };
|
|
70
|
+
if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: `/subscriptions/${subscription}/resourceGroups/rg-fictional`, tags }) };
|
|
71
|
+
if (args[0] === "group" && args[1] === "create") {
|
|
72
|
+
exists = true;
|
|
73
|
+
return { status: 0, stdout: "{}" };
|
|
74
|
+
}
|
|
75
|
+
if (args[0] === "group" && args[1] === "update") {
|
|
76
|
+
const assignment = args[args.indexOf("--set") + 1];
|
|
77
|
+
const separator = assignment.indexOf("=");
|
|
78
|
+
tags[assignment.slice(5, separator)] = assignment.slice(separator + 1);
|
|
79
|
+
return { status: 0, stdout: "{}" };
|
|
80
|
+
}
|
|
81
|
+
if (args[0] === "tag") {
|
|
82
|
+
const assignment = args[args.indexOf("--tags") + 1];
|
|
83
|
+
const separator = assignment.indexOf("=");
|
|
84
|
+
tags[assignment.slice(0, separator)] = assignment.slice(separator + 1);
|
|
85
|
+
return { status: 0, stdout: "{}" };
|
|
86
|
+
}
|
|
87
|
+
if (command === "deployment group create") return { status: 0, stdout: JSON.stringify({ properties: { outputs: {} } }) };
|
|
88
|
+
if (command === "deployment group what-if") return { status: 0, stdout: '{"changes":[]}' };
|
|
89
|
+
if (command === "deployment group show") {
|
|
90
|
+
// Output/secret retrieval must happen only after the stable apply has
|
|
91
|
+
// committed its manifest and deleted the plan.
|
|
92
|
+
expect(tags).not.toEqual({});
|
|
93
|
+
expect(fs.readdirSync(path.join(root, ".swallowkit", "state", "plans"))).toEqual([]);
|
|
94
|
+
return outputResponse;
|
|
95
|
+
}
|
|
96
|
+
if (command === "staticwebapp secrets list") return tokenResponse;
|
|
97
|
+
if (command === "webapp deployment list-publishing-profiles") return profileResponse;
|
|
98
|
+
throw new Error(`Unexpected mocked command: ${args.join(" ")}`);
|
|
99
|
+
};
|
|
100
|
+
az.mockImplementation((_file, args = []) => ({
|
|
101
|
+
pid: 0, output: [], signal: null, stderr: "", ...respond(args),
|
|
102
|
+
}));
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
process.chdir(cwd);
|
|
107
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
108
|
+
jest.restoreAllMocks();
|
|
109
|
+
az.mockReset();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
async function human(extra: string[] = [], explicitSubscription = true): Promise<void> {
|
|
113
|
+
await createProvisionCommand().parseAsync([
|
|
114
|
+
"node", "provision", "-g", options.resourceGroup,
|
|
115
|
+
"--location", options.location, "--swa-location", options.swaLocation,
|
|
116
|
+
...(explicitSubscription ? ["--subscription", subscription] : []), "--approve", ...extra,
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function transcript(): string {
|
|
121
|
+
return [...log.mock.calls, ...warn.mock.calls].flat().join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function queries(): Array<readonly string[]> {
|
|
125
|
+
return az.mock.calls.map((call) => call[1] ?? []).filter((args) =>
|
|
126
|
+
args[0] === "staticwebapp" || args[0] === "webapp" || args.slice(0, 3).join(" ") === "deployment group show");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function assertNoSecretsOnDisk(directory = root): void {
|
|
130
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
131
|
+
const entryPath = path.join(directory, entry.name);
|
|
132
|
+
if (entry.isDirectory()) assertNoSecretsOnDisk(entryPath);
|
|
133
|
+
else {
|
|
134
|
+
const content = fs.readFileSync(entryPath, "utf-8");
|
|
135
|
+
expect(content).not.toContain(token);
|
|
136
|
+
expect(content).not.toContain("fictional-password");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
it("prints all resource fields and CI/CD values only after apply succeeds, scoped to the deployment target", async () => {
|
|
142
|
+
await human();
|
|
143
|
+
for (const value of Object.values(resources)) expect(transcript()).toContain(value);
|
|
144
|
+
for (const label of ["AZURE_STATIC_WEB_APPS_API_TOKEN", "AZURE_FUNCTIONAPP_NAME", "AZURE_FUNCTIONAPP_PUBLISH_PROFILE"]) expect(transcript()).toContain(`[${label}]`);
|
|
145
|
+
expect(transcript()).toContain(token);
|
|
146
|
+
expect(transcript()).toContain(profile);
|
|
147
|
+
expect(transcript()).not.toContain("Run manually");
|
|
148
|
+
expect(queries()).toHaveLength(3);
|
|
149
|
+
for (const args of queries()) {
|
|
150
|
+
expect(args[args.indexOf("--subscription") + 1]).toBe(subscription);
|
|
151
|
+
expect(args[args.indexOf("--resource-group") + 1]).toBe(options.resourceGroup);
|
|
152
|
+
}
|
|
153
|
+
expect(queries()[0]).toEqual(expect.arrayContaining(["--name", "swallowkit-baseline"]));
|
|
154
|
+
expect(queries()[1]).toEqual(expect.arrayContaining(["--query", "properties.apiKey", "-o", "tsv"]));
|
|
155
|
+
expect(queries()[2]).toContain("--xml");
|
|
156
|
+
assertNoSecretsOnDisk();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("pins the active subscription before planning when the flag is omitted", async () => {
|
|
160
|
+
await human([], false);
|
|
161
|
+
expect(az.mock.calls[0][1]).toEqual(["account", "show", "--query", "id", "-o", "tsv"]);
|
|
162
|
+
for (const args of queries()) expect(args).toEqual(expect.arrayContaining(["--subscription", subscription]));
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it.each([
|
|
166
|
+
["failed", { status: 1, stdout: token, stderr: "fictional sensitive diagnostic" }],
|
|
167
|
+
["missing", { status: 0, stdout: "" }],
|
|
168
|
+
["null", { status: 0, stdout: "null" }],
|
|
169
|
+
["truncated", { status: 0, stdout: token, error: new Error("ENOBUFS fictional") }],
|
|
170
|
+
["interrupted", { status: 0, stdout: token, signal: "SIGTERM" }],
|
|
171
|
+
["timeout", { status: null, stdout: token, error: new Error("ETIMEDOUT fictional") }],
|
|
172
|
+
] satisfies Array<[string, AzResponse]>)("shows safe manual commands for %s credential retrieval without leaking partial output", async (_name, response) => {
|
|
173
|
+
tokenResponse = response;
|
|
174
|
+
profileResponse = { ...response, stdout: response.stdout && response.stdout !== "null" ? profile : response.stdout };
|
|
175
|
+
await human();
|
|
176
|
+
expect(transcript()).toContain("Could not retrieve automatically");
|
|
177
|
+
expect(transcript()).toContain("Run manually: az staticwebapp secrets list");
|
|
178
|
+
expect(transcript()).toContain("Run manually: az webapp deployment list-publishing-profiles");
|
|
179
|
+
expect(transcript()).toContain(`--subscription ${subscription}`);
|
|
180
|
+
expect(transcript()).not.toContain(token);
|
|
181
|
+
expect(transcript()).not.toContain(profile);
|
|
182
|
+
expect(transcript()).not.toContain("fictional sensitive diagnostic");
|
|
183
|
+
assertNoSecretsOnDisk();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("rejects incomplete XML rather than presenting it as a publish profile", async () => {
|
|
187
|
+
profileResponse.stdout = '<publishData><publishProfile userPWD="fictional-password" />';
|
|
188
|
+
await human();
|
|
189
|
+
expect(transcript()).toContain("Run manually: az webapp deployment list-publishing-profiles");
|
|
190
|
+
expect(transcript()).not.toContain("fictional-password");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("accepts XML publishing profiles containing nested database elements", async () => {
|
|
194
|
+
profileResponse.stdout = '<publishData><publishProfile profileName="fictional" userPWD="fictional-password"><databases /></publishProfile></publishData>';
|
|
195
|
+
await human();
|
|
196
|
+
expect(transcript()).toContain(profileResponse.stdout);
|
|
197
|
+
expect(transcript()).not.toContain("Run manually");
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("shows manual fallbacks for thrown credential errors without printing their details", async () => {
|
|
201
|
+
const implementation = az.getMockImplementation();
|
|
202
|
+
if (!implementation) throw new Error("Missing Azure mock implementation.");
|
|
203
|
+
az.mockImplementation((file, args = []) => {
|
|
204
|
+
if (args[0] === "webapp" || args[0] === "staticwebapp") throw new Error("fictional-password");
|
|
205
|
+
return implementation(file, args);
|
|
206
|
+
});
|
|
207
|
+
await human();
|
|
208
|
+
expect(transcript()).toContain("Run manually: az staticwebapp secrets list");
|
|
209
|
+
expect(transcript()).toContain("Run manually: az webapp deployment list-publishing-profiles");
|
|
210
|
+
expect(transcript()).not.toContain("fictional-password");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it.each(["", "{}", "null", "invalid-json"])("shows manual output and credential commands when resource outputs are unavailable (%s)", async (stdout) => {
|
|
214
|
+
outputResponse.stdout = stdout;
|
|
215
|
+
await human();
|
|
216
|
+
expect(transcript()).toContain("resource name missing");
|
|
217
|
+
expect(transcript()).toContain('"<swa-name>"');
|
|
218
|
+
expect(transcript()).toContain('"<functions-app-name>"');
|
|
219
|
+
expect(queries()).toHaveLength(1);
|
|
220
|
+
expect(transcript()).not.toContain(token);
|
|
221
|
+
expect(transcript()).not.toContain(profile);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it.each(["deployment group create", "group update --name"])("does not query credentials after %s fails", async (command) => {
|
|
225
|
+
failCommand = command;
|
|
226
|
+
await expect(human()).rejects.toThrow("Command failed");
|
|
227
|
+
expect(queries()).toEqual([]);
|
|
228
|
+
expect(transcript()).not.toContain("Provision completed");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not query credentials if ledger persistence fails after deployment and tag success", async () => {
|
|
232
|
+
jest.spyOn(migrations, "replaceMigrationManifest").mockImplementation(() => { throw new Error("fictional ledger failure"); });
|
|
233
|
+
await expect(human()).rejects.toThrow("fictional ledger failure");
|
|
234
|
+
expect(queries()).toEqual([]);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("does not query credentials on human cancellation", async () => {
|
|
238
|
+
const stdinTty = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
|
239
|
+
const stdoutTty = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
|
|
240
|
+
Object.defineProperty(process.stdin, "isTTY", { value: true, configurable: true });
|
|
241
|
+
Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });
|
|
242
|
+
jest.mocked(prompts).mockResolvedValue({ approved: false });
|
|
243
|
+
try {
|
|
244
|
+
await createProvisionCommand().parseAsync([
|
|
245
|
+
"node", "provision", "-g", options.resourceGroup, "--subscription", subscription,
|
|
246
|
+
"--location", options.location, "--swa-location", options.swaLocation,
|
|
247
|
+
]);
|
|
248
|
+
expect(transcript()).toContain("Provision cancelled");
|
|
249
|
+
expect(queries()).toEqual([]);
|
|
250
|
+
expect(exists).toBe(false);
|
|
251
|
+
} finally {
|
|
252
|
+
if (stdinTty) Object.defineProperty(process.stdin, "isTTY", stdinTty);
|
|
253
|
+
else Reflect.deleteProperty(process.stdin, "isTTY");
|
|
254
|
+
if (stdoutTty) Object.defineProperty(process.stdout, "isTTY", stdoutTty);
|
|
255
|
+
else Reflect.deleteProperty(process.stdout, "isTTY");
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("restores setup output after a successful explicit reconcile", async () => {
|
|
260
|
+
exists = true;
|
|
261
|
+
const manifest = migrations.loadMigrationManifest(root);
|
|
262
|
+
tags[migrations.getMigrationTagKey(manifest.projectId)] = migrations.encodeMigrationState({ schemaVersion: 1, version: 0, checksum: manifest.baseline.checksum });
|
|
263
|
+
await human(["--reconcile"]);
|
|
264
|
+
expect(transcript()).toContain("Provision completed (reconcile)");
|
|
265
|
+
expect(transcript()).toContain(token);
|
|
266
|
+
assertNoSecretsOnDisk();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("does not query credentials on no-op or adoption", async () => {
|
|
270
|
+
exists = true;
|
|
271
|
+
const manifest = migrations.loadMigrationManifest(root);
|
|
272
|
+
tags[migrations.getMigrationTagKey(manifest.projectId)] = migrations.encodeMigrationState({ schemaVersion: 1, version: 0, checksum: manifest.baseline.checksum });
|
|
273
|
+
await human();
|
|
274
|
+
expect(queries()).toEqual([]);
|
|
275
|
+
tags = {};
|
|
276
|
+
migrations.replaceMigrationManifest({ ...manifest, baseline: { ...manifest.baseline, legacy: true } }, root);
|
|
277
|
+
await human(["--adopt-existing", "--baseline", "0", "--what-if"]);
|
|
278
|
+
expect(queries()).toEqual([]);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("reads baseline resource outputs after a successful migration rather than using container-only migration outputs", async () => {
|
|
282
|
+
exists = true;
|
|
283
|
+
const manifest = migrations.loadMigrationManifest(root);
|
|
284
|
+
tags[migrations.getMigrationTagKey(manifest.projectId)] = migrations.encodeMigrationState({ schemaVersion: 1, version: 0, checksum: manifest.baseline.checksum });
|
|
285
|
+
createCustomMigration("fictional-addition", root);
|
|
286
|
+
await human();
|
|
287
|
+
expect(transcript()).toContain("Provision completed (migrate)");
|
|
288
|
+
expect(transcript()).toContain(token);
|
|
289
|
+
expect(queries()[0]).toEqual(expect.arrayContaining(["--name", "swallowkit-baseline"]));
|
|
290
|
+
assertNoSecretsOnDisk();
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("keeps successful machine and MCP apply responses credential-free and performs no secret queries", async () => {
|
|
294
|
+
const plan = await planProvisionOperation(options);
|
|
295
|
+
const writes: string[] = [];
|
|
296
|
+
jest.spyOn(process.stdout, "write").mockImplementation((chunk) => { writes.push(String(chunk)); return true; });
|
|
297
|
+
await runMachineCli(["node", "swallowkit", "machine", "apply", "provision", "--plan", plan.planId, "--approve"]);
|
|
298
|
+
const stdout = writes.join("");
|
|
299
|
+
expect(JSON.parse(stdout).ok).toBe(true);
|
|
300
|
+
const runner = jest.fn(async () => ({ stdout, stderr: "", exitCode: 0 }));
|
|
301
|
+
const tool = buildSwallowKitToolDefinitions(runner).find((entry) => entry.name === "swallowkit_apply_provision");
|
|
302
|
+
if (!tool) throw new Error("Missing apply provision MCP tool.");
|
|
303
|
+
const response = await tool.handler({ planId: plan.planId, approve: true });
|
|
304
|
+
expect(runner).toHaveBeenCalledWith(["apply", "provision", "--plan", plan.planId, "--approve"]);
|
|
305
|
+
for (const content of [stdout, JSON.stringify(response)]) {
|
|
306
|
+
expect(content).not.toContain(token);
|
|
307
|
+
expect(content).not.toContain("fictional-password");
|
|
308
|
+
expect(content).not.toContain("AZURE_STATIC_WEB_APPS_API_TOKEN");
|
|
309
|
+
}
|
|
310
|
+
expect(queries()).toEqual([]);
|
|
311
|
+
assertNoSecretsOnDisk();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -22,6 +22,27 @@ const namedAuthOptions = {
|
|
|
22
22
|
authContextImport: "@/lib/auth/schemes/staff/auth-context",
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
describe("dynamic route Cache Components compatibility", () => {
|
|
26
|
+
it.each([undefined, namedAuthOptions])("keeps detail/edit URL hooks below Suspense with auth=%j", (authOptions) => {
|
|
27
|
+
const model = createBasicModelInfo();
|
|
28
|
+
for (const [code, page, content] of [
|
|
29
|
+
[generateDetailPage(model, "@myapp/shared", authOptions), "TodoDetailPage", "TodoDetailContent"],
|
|
30
|
+
[generateEditPage(model, "@myapp/shared", authOptions), "EditTodoPage", "EditTodoContent"],
|
|
31
|
+
]) {
|
|
32
|
+
expect(code).toContain("import { Suspense, useEffect, useState } from 'react'");
|
|
33
|
+
const wrapper = code.slice(code.indexOf(`export default function ${page}`), code.indexOf(`function ${content}`));
|
|
34
|
+
expect(wrapper).toContain("<Suspense fallback=");
|
|
35
|
+
expect(wrapper).toContain(`<${content} />`);
|
|
36
|
+
expect(wrapper).not.toContain("useParams()");
|
|
37
|
+
expect(wrapper).not.toContain("useAuth()");
|
|
38
|
+
expect(code.slice(code.indexOf(`function ${content}`))).toContain("const params = useParams()");
|
|
39
|
+
expect(code).toContain("Loading...");
|
|
40
|
+
expect(code).toContain("fetch(`/api/todo/${id}`)");
|
|
41
|
+
expect(code).not.toMatch(/export const (dynamic|revalidate|fetchCache|instant)\b/);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
25
46
|
describe("generateAuthLayout", () => {
|
|
26
47
|
it("uses the same resolved auth context as authenticated CRUD pages", () => {
|
|
27
48
|
const model = createBasicModelInfo({ name: "Product" });
|
package/src/cli/commands/init.ts
CHANGED
|
@@ -1601,8 +1601,6 @@ async function createHomePage(projectDir: string, pm: PackageManager = 'pnpm') {
|
|
|
1601
1601
|
|
|
1602
1602
|
const pageContent = `'use client'
|
|
1603
1603
|
|
|
1604
|
-
export const dynamic = 'force-dynamic';
|
|
1605
|
-
|
|
1606
1604
|
import { useState } from 'react';
|
|
1607
1605
|
import { scaffoldConfig } from '@/lib/scaffold-config';
|
|
1608
1606
|
|
|
@@ -3434,4 +3432,3 @@ async function createAzurePipelines(projectDir: string, pm: PackageManager, back
|
|
|
3434
3432
|
|
|
3435
3433
|
console.log('✅ Azure Pipelines created\n');
|
|
3436
3434
|
}
|
|
3437
|
-
|