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.
Files changed (35) hide show
  1. package/dist/cli/commands/init.d.ts.map +1 -1
  2. package/dist/cli/commands/init.js +0 -2
  3. package/dist/cli/commands/init.js.map +1 -1
  4. package/dist/cli/commands/provision.d.ts +1 -0
  5. package/dist/cli/commands/provision.d.ts.map +1 -1
  6. package/dist/cli/commands/provision.js +144 -54
  7. package/dist/cli/commands/provision.js.map +1 -1
  8. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  9. package/dist/cli/commands/scaffold.js +6 -1
  10. package/dist/cli/commands/scaffold.js.map +1 -1
  11. package/dist/core/operations/migration-generator.d.ts +1 -1
  12. package/dist/core/operations/migration-generator.d.ts.map +1 -1
  13. package/dist/core/operations/migration-generator.js +22 -0
  14. package/dist/core/operations/migration-generator.js.map +1 -1
  15. package/dist/core/project/workflows.d.ts.map +1 -1
  16. package/dist/core/project/workflows.js +13 -0
  17. package/dist/core/project/workflows.js.map +1 -1
  18. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  19. package/dist/core/scaffold/ui-generator.js +26 -2
  20. package/dist/core/scaffold/ui-generator.js.map +1 -1
  21. package/package.json +1 -1
  22. package/src/__tests__/__snapshots__/ui-generator.test.ts.snap +26 -2
  23. package/src/__tests__/agent-instructions.test.ts +22 -1
  24. package/src/__tests__/init-agent-instructions.test.ts +99 -0
  25. package/src/__tests__/plan-apply.test.ts +101 -0
  26. package/src/__tests__/provision-output.test.ts +313 -0
  27. package/src/__tests__/ui-generator.test.ts +21 -0
  28. package/src/cli/commands/init.ts +0 -3
  29. package/src/cli/commands/provision.ts +89 -3
  30. package/src/cli/commands/scaffold.ts +5 -1
  31. package/src/core/operations/migration-generator.ts +35 -1
  32. package/src/core/project/agent-templates/AGENTS.md +26 -0
  33. package/src/core/project/agent-templates/shared-models.instructions.md +7 -0
  34. package/src/core/project/workflows.ts +13 -0
  35. package/src/core/scaffold/ui-generator.ts +26 -2
@@ -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();
@@ -851,7 +851,11 @@ export async function generateCosmosContainer(modelInfo: any, session: FileOpera
851
851
  return;
852
852
  }
853
853
  const migration = generateModelContainerMigration(modelInfo, session);
854
- console.log(`✅ Created migration ${migration.version}: ${migration.template}`);
854
+ if (migration) {
855
+ console.log(`✅ Created migration ${migration.version}: ${migration.template}`);
856
+ } else {
857
+ console.log(`ℹ️ Cosmos DB container is already owned by the migration baseline. Skipping infrastructure migration.`);
858
+ }
855
859
  }
856
860
 
857
861
  /**
@@ -68,6 +68,37 @@ function appendMigration(manifest: MigrationManifest, migration: InfrastructureM
68
68
  };
69
69
  }
70
70
 
71
+ function baselineOwnsModelContainer(
72
+ manifest: MigrationManifest,
73
+ model: { name: string; partitionKey: string },
74
+ session: FileOperationSession
75
+ ): boolean {
76
+ const containerSlug = normalizeSlug(model.name);
77
+ const containerPath = `infra/containers/${containerSlug}-container.bicep`;
78
+ const mainPath = "infra/main.bicep";
79
+ const baselinePaths = new Set(manifest.baseline.files.map((file) => file.path.replace(/\\/g, "/")));
80
+ if (!baselinePaths.has(containerPath) || !baselinePaths.has(mainPath)) return false;
81
+
82
+ const mainBicep = session.readFile(path.join(session.rootDirectory, mainPath));
83
+ const wired = mainBicep.includes(`'containers/${containerSlug}-container.bicep'`) ||
84
+ mainBicep.includes(`"containers/${containerSlug}-container.bicep"`);
85
+ if (!wired) return false;
86
+
87
+ const containerBicep = session.readFile(path.join(session.rootDirectory, containerPath));
88
+ const baselinePartitionKey = containerBicep.match(
89
+ /param\s+partitionKeyPath\s+string\s*=\s*['"]([^'"]+)['"]/
90
+ )?.[1];
91
+ if (baselinePartitionKey && baselinePartitionKey !== model.partitionKey) {
92
+ throw new MachineCommandError(
93
+ "baseline-container-configuration-change",
94
+ `Model ${model.name} changes the partition key of a baseline-owned container. Create an explicit infrastructure migration instead of regenerating the container.`,
95
+ { model: model.name, baselinePartitionKey, requestedPartitionKey: model.partitionKey, containerPath },
96
+ "blocked"
97
+ );
98
+ }
99
+ return true;
100
+ }
101
+
71
102
  function initializeMigrations(projectRoot: string, legacy: boolean): MigrationManifest {
72
103
  const manifest = createMigrationManifest(projectRoot, { legacy });
73
104
  const session = new FileOperationSession("commit", projectRoot);
@@ -124,9 +155,12 @@ param location string
124
155
  export function generateModelContainerMigration(
125
156
  model: { name: string; partitionKey: string },
126
157
  session: FileOperationSession = new FileOperationSession("commit")
127
- ): InfrastructureMigration {
158
+ ): InfrastructureMigration | null {
128
159
  const manifest = readManifestForSession(session);
129
160
  const existing = manifest.migrations.find((migration) => migration.source === "model" && migration.sourceModel === model.name);
161
+ if (!existing && baselineOwnsModelContainer(manifest, model, session)) {
162
+ return null;
163
+ }
130
164
  const slug = normalizeSlug(`create-${model.name}-container`);
131
165
  const version = existing?.version ?? manifest.migrations.length + 1;
132
166
  const directory = path.join("infra", "migrations", `${formatVersion(version)}-${slug}`);
@@ -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);