swallowkit 1.1.2 → 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/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__/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/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,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
|
-
|
|
@@ -1,7 +1,83 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import prompts from "prompts";
|
|
3
|
+
import { spawnSync } from "child_process";
|
|
3
4
|
import { ensureSwallowKitProject } from "../../core/config";
|
|
4
|
-
import { applyProvisionOperation, planProvisionOperation } from "../../core/operations/provision-operations";
|
|
5
|
+
import { applyProvisionOperation, planProvisionOperation, ProvisionPlanData } from "../../core/operations/provision-operations";
|
|
6
|
+
|
|
7
|
+
function readAzOutput(args: string[]): string | undefined {
|
|
8
|
+
try {
|
|
9
|
+
const result = spawnSync("az", args, {
|
|
10
|
+
shell: false,
|
|
11
|
+
encoding: "utf-8",
|
|
12
|
+
timeout: 60_000,
|
|
13
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
14
|
+
});
|
|
15
|
+
if (result.status === 0 && !result.error && !result.signal) {
|
|
16
|
+
const value = result.stdout?.trim();
|
|
17
|
+
if (value && value !== "null") return value;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
// Callers report a safe fallback without displaying credential-bearing output.
|
|
21
|
+
}
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function manualAzCommand(args: string[]): string {
|
|
26
|
+
return `az ${args.map((arg) => /^[A-Za-z0-9._:/=-]+$/.test(arg) ? arg : JSON.stringify(arg)).join(" ")}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readCiCdSecret(args: string[], resourceKnown: boolean, xml = false): string {
|
|
30
|
+
const value = resourceKnown ? readAzOutput(args) : undefined;
|
|
31
|
+
const valid = value && (xml
|
|
32
|
+
? /^(?:<\?xml[^>]*\?>\s*)?<publishData(?:\s[^>]*)?>[\s\S]*<\/publishData>$/.test(value)
|
|
33
|
+
&& /<publishProfile\s[^>]+(?:\/>|>[\s\S]*<\/publishProfile>)/.test(value)
|
|
34
|
+
: /^\S+$/.test(value));
|
|
35
|
+
if (valid) return ` ${value}\n`;
|
|
36
|
+
return ` Could not retrieve automatically${resourceKnown ? "" : " (resource name missing)"}.\n Run manually: ${manualAzCommand(args)}\n`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Human CLI only: called after deployment, migration tags, and ledger writes succeed.
|
|
40
|
+
function printProvisionSetup(plan: ProvisionPlanData, subscription: string): void {
|
|
41
|
+
if (!["bootstrap", "reconcile", "migrate"].includes(plan.mode)) return;
|
|
42
|
+
const target = ["--resource-group", plan.resourceGroup, "--subscription", subscription];
|
|
43
|
+
const fields = ["staticWebAppName", "staticWebAppUrl", "functionsAppName", "functionsAppUrl", "cosmosDbAccountName", "cosmosDatabaseName"] as const;
|
|
44
|
+
const outputArgs = [
|
|
45
|
+
"deployment", "group", "show", "--name", "swallowkit-baseline", ...target,
|
|
46
|
+
"--query", `{${fields.map((field) => `${field}:properties.outputs.${field}.value`).join(",")}}`, "-o", "json",
|
|
47
|
+
];
|
|
48
|
+
let outputs: Record<string, unknown> = {};
|
|
49
|
+
const raw = readAzOutput(outputArgs);
|
|
50
|
+
try {
|
|
51
|
+
const parsed: unknown = raw ? JSON.parse(raw) : undefined;
|
|
52
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Invalid outputs.");
|
|
53
|
+
outputs = parsed as Record<string, unknown>;
|
|
54
|
+
if (!fields.every((field) => typeof outputs[field] === "string" && outputs[field])) {
|
|
55
|
+
throw new Error("Resource outputs missing.");
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
console.warn(`Provision succeeded, but resource information could not be read. Do not redeploy to retrieve it.\nRun manually: ${manualAzCommand(outputArgs)}`);
|
|
59
|
+
}
|
|
60
|
+
console.log("\nResource information:");
|
|
61
|
+
console.log(` resourceGroup: ${plan.resourceGroup}`);
|
|
62
|
+
console.log(` subscription: ${subscription}`);
|
|
63
|
+
for (const field of fields) {
|
|
64
|
+
if (typeof outputs[field] === "string" && outputs[field]) console.log(` ${field}: ${outputs[field]}`);
|
|
65
|
+
}
|
|
66
|
+
const knownName = (value: unknown): value is string => typeof value === "string" && /^[A-Za-z0-9-]+$/.test(value);
|
|
67
|
+
const swaKnown = knownName(outputs.staticWebAppName);
|
|
68
|
+
const functionsKnown = knownName(outputs.functionsAppName);
|
|
69
|
+
const swaName = swaKnown ? String(outputs.staticWebAppName) : "<swa-name>";
|
|
70
|
+
const functionsName = functionsKnown ? String(outputs.functionsAppName) : "<functions-app-name>";
|
|
71
|
+
console.log("\nCI/CD settings (save these as secrets in GitHub Actions or Azure Pipelines):\n");
|
|
72
|
+
console.log(" [AZURE_STATIC_WEB_APPS_API_TOKEN]");
|
|
73
|
+
console.log(readCiCdSecret(["staticwebapp", "secrets", "list", "--name", swaName, ...target, "--query", "properties.apiKey", "-o", "tsv"], swaKnown));
|
|
74
|
+
console.log(" [AZURE_FUNCTIONAPP_NAME]");
|
|
75
|
+
console.log(functionsKnown ? ` ${functionsName}\n` : " Resource name missing; inspect the deployment outputs using the command above.\n");
|
|
76
|
+
console.log(" [AZURE_FUNCTIONAPP_PUBLISH_PROFILE]");
|
|
77
|
+
console.log(readCiCdSecret(["webapp", "deployment", "list-publishing-profiles", "--name", functionsName, ...target, "--xml"], functionsKnown, true));
|
|
78
|
+
console.log(" These values are shown only in this terminal; SwallowKit does not store them. Treat them as secrets.");
|
|
79
|
+
console.log(" Next: configure your CI/CD pipeline and manually trigger the first deployment.");
|
|
80
|
+
}
|
|
5
81
|
|
|
6
82
|
const PRIMARY_REGIONS = [
|
|
7
83
|
{ title: "Japan East (japaneast)", value: "japaneast" },
|
|
@@ -64,7 +140,8 @@ async function resolveLocations(options: ProvisionCliOptions): Promise<{ locatio
|
|
|
64
140
|
return { location, swaLocation };
|
|
65
141
|
}
|
|
66
142
|
|
|
67
|
-
export
|
|
143
|
+
export function createProvisionCommand(): Command {
|
|
144
|
+
return new Command("provision")
|
|
68
145
|
.description("Bootstrap, adopt, migrate, or explicitly reconcile Azure resources")
|
|
69
146
|
.requiredOption("-g, --resource-group <name>", "Resource group name")
|
|
70
147
|
.option("--subscription <id>", "Azure subscription ID")
|
|
@@ -78,10 +155,14 @@ export const provisionCommand = new Command("provision")
|
|
|
78
155
|
.action(async (options: ProvisionCliOptions) => {
|
|
79
156
|
ensureSwallowKitProject("provision");
|
|
80
157
|
const locations = await resolveLocations(options);
|
|
158
|
+
const subscription = options.subscription ?? readAzOutput(["account", "show", "--query", "id", "-o", "tsv"]);
|
|
159
|
+
if (!subscription || !/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(subscription)) {
|
|
160
|
+
throw new Error("Could not determine the Azure subscription. Run az account show --query id -o tsv, then retry with --subscription <id>.");
|
|
161
|
+
}
|
|
81
162
|
const baseline = options.baseline === undefined ? undefined : Number(options.baseline);
|
|
82
163
|
const plan = await planProvisionOperation({
|
|
83
164
|
resourceGroup: options.resourceGroup,
|
|
84
|
-
subscription
|
|
165
|
+
subscription,
|
|
85
166
|
location: locations.location,
|
|
86
167
|
swaLocation: locations.swaLocation,
|
|
87
168
|
whatIf: options.whatIf,
|
|
@@ -121,4 +202,9 @@ export const provisionCommand = new Command("provision")
|
|
|
121
202
|
|
|
122
203
|
const result = await applyProvisionOperation({ planId: plan.planId, approve: true });
|
|
123
204
|
console.log(`Provision completed (${plan.mode}). ${result.executedCommands.length} command(s) executed.`);
|
|
205
|
+
for (const warning of result.warnings) console.warn(`Warning: ${warning}`);
|
|
206
|
+
printProvisionSetup(plan, subscription);
|
|
124
207
|
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const provisionCommand = createProvisionCommand();
|
|
@@ -254,6 +254,32 @@ Additional model-change facts:
|
|
|
254
254
|
|
|
255
255
|
## New Models / CRUD Features
|
|
256
256
|
|
|
257
|
+
For every new persistent model, follow the current SwallowKit model-creation
|
|
258
|
+
and infrastructure-generation workflow, **whether or not it uses custom APIs**.
|
|
259
|
+
Adding a hand-written schema alone is not completion.
|
|
260
|
+
|
|
261
|
+
1. Inspect current capabilities and entities. Use
|
|
262
|
+
`{{runCmd}} swallowkit create-model <name>` or the agent-facing equivalent
|
|
263
|
+
`{{runCmd}} swallowkit machine generate model <name> --overwrite never`.
|
|
264
|
+
This creates the schema, shared export, and model metadata; it does not
|
|
265
|
+
create the Cosmos DB container by itself.
|
|
266
|
+
2. Author domain fields, then plan/apply scaffold for that model using the
|
|
267
|
+
current options. In 1.x, scaffold adds new Cosmos DB containers as versioned
|
|
268
|
+
Bicep migrations under `infra/migrations/`, recorded in its `manifest.json`.
|
|
269
|
+
Existing baseline-owned containers remain wired in `infra/main.bicep`;
|
|
270
|
+
do not add duplicate containers or edit applied migrations.
|
|
271
|
+
3. Verify the correspondence between the model, container name/partition key,
|
|
272
|
+
Bicep wiring or migration manifest, and `.swallowkit/artifacts.json`
|
|
273
|
+
generation ledger. Use `machine inspect entities`, `machine inspect infra`,
|
|
274
|
+
`machine inspect drift`, and `machine verify project`; inspect the actual
|
|
275
|
+
files too. Local generation/verification does not mean Azure was deployed.
|
|
276
|
+
|
|
277
|
+
Custom APIs and non-persistent types remain ai-authored. Connector-backed
|
|
278
|
+
models follow their connector workflow rather than inventing Cosmos containers.
|
|
279
|
+
If generation is omitted, state the reason and unfinished work explicitly.
|
|
280
|
+
Never describe a project as deployable while required persistence infrastructure
|
|
281
|
+
is missing; do not hand-edit managed infrastructure or the generation ledger.
|
|
282
|
+
|
|
257
283
|
Use the `swallowkit-add-model` Agent Skill when available.
|
|
258
284
|
|
|
259
285
|
Otherwise follow the equivalent runbook under `.swallowkit/workflows/`.
|
|
@@ -52,6 +52,13 @@ After changing a model, do not manually patch generated artifacts.
|
|
|
52
52
|
|
|
53
53
|
## After changing a model
|
|
54
54
|
|
|
55
|
+
- For new persistent models, follow the root `AGENTS.md` model-creation and
|
|
56
|
+
scaffold infrastructure workflow even when all APIs are custom. A schema
|
|
57
|
+
alone is not completion: verify container Bicep wiring or migrations and
|
|
58
|
+
`.swallowkit/artifacts.json`. Non-persistent types and connector-backed
|
|
59
|
+
models do not require invented Cosmos containers. If generation is omitted,
|
|
60
|
+
report the reason and unfinished infrastructure; never claim deployability
|
|
61
|
+
while required persistence infrastructure is missing.
|
|
55
62
|
- The shared package is consumed via its built `dist/`. `verify project`
|
|
56
63
|
rebuilds it automatically, but if you typecheck manually, build the shared
|
|
57
64
|
package first — "no exported member" errors usually mean a stale `dist/`.
|
|
@@ -41,6 +41,12 @@ export function buildWorkflowDocs(runCmd: string): WorkflowDoc[] {
|
|
|
41
41
|
|
|
42
42
|
Goal: add a data model and derive CRUD/BFF/UI/infra deterministically.
|
|
43
43
|
|
|
44
|
+
For persistent models, this workflow is required even when all APIs are custom.
|
|
45
|
+
A hand-written schema alone is not completion. Non-persistent types remain
|
|
46
|
+
ai-authored; connector-backed models follow their connector workflow without
|
|
47
|
+
inventing Cosmos containers. If generation is omitted, report the reason and
|
|
48
|
+
unfinished work; never claim deployability with required infrastructure missing.
|
|
49
|
+
|
|
44
50
|
## Responsibility boundary
|
|
45
51
|
|
|
46
52
|
- **You author freely**: the model schema (\`shared/models/<name>.ts\`) and any business logic.
|
|
@@ -60,6 +66,13 @@ Goal: add a data model and derive CRUD/BFF/UI/infra deterministically.
|
|
|
60
66
|
5. Verify:
|
|
61
67
|
\`${runCmd} swallowkit machine verify project\`
|
|
62
68
|
- \`summary.done: false\` → \`${runCmd} swallowkit machine explain failure\` and repair, then re-verify.
|
|
69
|
+
- Check model/container name and partition key, versioned container
|
|
70
|
+
migrations in \`infra/migrations/manifest.json\` (or existing baseline-owned
|
|
71
|
+
container wiring in \`infra/main.bicep\`), and the
|
|
72
|
+
generation ledger \`.swallowkit/artifacts.json\`. Use \`machine inspect entities\`,
|
|
73
|
+
\`machine inspect infra\`, and \`machine inspect drift\` and inspect actual files.
|
|
74
|
+
Model creation alone does not generate containers; scaffold does.
|
|
75
|
+
Local verification is not proof of Azure deployment.
|
|
63
76
|
6. If \`dev-seeds/\` exists, update seed JSON for the new model. Seeds are only
|
|
64
77
|
applied by \`${runCmd} swallowkit dev --seed-env <environment>\` — editing the
|
|
65
78
|
JSON alone does nothing.
|
|
@@ -337,7 +337,7 @@ export function generateDetailPage(model: ModelInfo, sharedPackageName: string,
|
|
|
337
337
|
|
|
338
338
|
return `'use client';
|
|
339
339
|
|
|
340
|
-
import { useEffect, useState } from 'react';
|
|
340
|
+
import { Suspense, useEffect, useState } from 'react';
|
|
341
341
|
import { useParams, useRouter } from 'next/navigation';
|
|
342
342
|
import Link from 'next/link';
|
|
343
343
|
import { z } from 'zod/v4';
|
|
@@ -347,6 +347,18 @@ ${hasAuth ? `import { useAuth } from '${authContextImport}';` : ''}
|
|
|
347
347
|
type ${modelName} = z.infer<typeof ${localSchemaName}>;
|
|
348
348
|
|
|
349
349
|
export default function ${modelName}DetailPage() {
|
|
350
|
+
return (
|
|
351
|
+
<Suspense fallback={
|
|
352
|
+
<div className="flex items-center justify-center min-h-screen">
|
|
353
|
+
<div className="text-lg text-gray-900 dark:text-gray-100">Loading...</div>
|
|
354
|
+
</div>
|
|
355
|
+
}>
|
|
356
|
+
<${modelName}DetailContent />
|
|
357
|
+
</Suspense>
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function ${modelName}DetailContent() {
|
|
350
362
|
const params = useParams();
|
|
351
363
|
const router = useRouter();
|
|
352
364
|
const [${modelCamel}, set${modelName}] = useState<${modelName} | null>(null);
|
|
@@ -1031,7 +1043,7 @@ export function generateEditPage(model: ModelInfo, sharedPackageName: string, au
|
|
|
1031
1043
|
|
|
1032
1044
|
return `'use client';
|
|
1033
1045
|
|
|
1034
|
-
import { useEffect, useState } from 'react';
|
|
1046
|
+
import { Suspense, useEffect, useState } from 'react';
|
|
1035
1047
|
import { useParams, useRouter } from 'next/navigation';
|
|
1036
1048
|
import ${modelName}Form from '../../_components/${modelName}Form';
|
|
1037
1049
|
import { z } from 'zod/v4';
|
|
@@ -1041,6 +1053,18 @@ ${hasAuth ? `import { useAuth } from '${authContextImport}';` : ''}
|
|
|
1041
1053
|
type ${modelName} = z.infer<typeof ${localSchemaName}>;
|
|
1042
1054
|
|
|
1043
1055
|
export default function Edit${modelName}Page() {
|
|
1056
|
+
return (
|
|
1057
|
+
<Suspense fallback={
|
|
1058
|
+
<div className="flex items-center justify-center min-h-screen">
|
|
1059
|
+
<div className="text-lg">Loading...</div>
|
|
1060
|
+
</div>
|
|
1061
|
+
}>
|
|
1062
|
+
<Edit${modelName}Content />
|
|
1063
|
+
</Suspense>
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function Edit${modelName}Content() {
|
|
1044
1068
|
const params = useParams();
|
|
1045
1069
|
${hasAuth ? ' const router = useRouter();' : ''}
|
|
1046
1070
|
const [${modelCamel}, set${modelName}] = useState<${modelName} | null>(null);
|