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.
@@ -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" });
@@ -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 const provisionCommand = new Command("provision")
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: options.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);