swallowkit 2.0.0-alpha.2 → 2.0.0-alpha.4

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 (176) hide show
  1. package/dist/cli/commands/configure-mcp.d.ts +12 -0
  2. package/dist/cli/commands/configure-mcp.d.ts.map +1 -0
  3. package/dist/cli/commands/configure-mcp.js +42 -0
  4. package/dist/cli/commands/configure-mcp.js.map +1 -0
  5. package/dist/cli/commands/dev.d.ts +2 -2
  6. package/dist/cli/commands/dev.d.ts.map +1 -1
  7. package/dist/cli/commands/dev.js +39 -27
  8. package/dist/cli/commands/dev.js.map +1 -1
  9. package/dist/cli/commands/init.d.ts +6 -2
  10. package/dist/cli/commands/init.d.ts.map +1 -1
  11. package/dist/cli/commands/init.js +43 -122
  12. package/dist/cli/commands/init.js.map +1 -1
  13. package/dist/cli/commands/migrations.d.ts.map +1 -1
  14. package/dist/cli/commands/migrations.js +25 -0
  15. package/dist/cli/commands/migrations.js.map +1 -1
  16. package/dist/cli/commands/provision.d.ts +2 -0
  17. package/dist/cli/commands/provision.d.ts.map +1 -1
  18. package/dist/cli/commands/provision.js +45 -16
  19. package/dist/cli/commands/provision.js.map +1 -1
  20. package/dist/cli/commands/scaffold.d.ts +4 -0
  21. package/dist/cli/commands/scaffold.d.ts.map +1 -1
  22. package/dist/cli/commands/scaffold.js +40 -2
  23. package/dist/cli/commands/scaffold.js.map +1 -1
  24. package/dist/cli/index.d.ts +2 -0
  25. package/dist/cli/index.d.ts.map +1 -1
  26. package/dist/cli/index.js +27 -2
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/core/operations/model-migration.d.ts +22 -0
  29. package/dist/core/operations/model-migration.d.ts.map +1 -0
  30. package/dist/core/operations/model-migration.js +36 -0
  31. package/dist/core/operations/model-migration.js.map +1 -0
  32. package/dist/core/operations/provision-operations.d.ts +19 -1
  33. package/dist/core/operations/provision-operations.d.ts.map +1 -1
  34. package/dist/core/operations/provision-operations.js +62 -35
  35. package/dist/core/operations/provision-operations.js.map +1 -1
  36. package/dist/core/operations/scaffold-apply.d.ts +1 -0
  37. package/dist/core/operations/scaffold-apply.d.ts.map +1 -1
  38. package/dist/core/operations/scaffold-apply.js +1 -0
  39. package/dist/core/operations/scaffold-apply.js.map +1 -1
  40. package/dist/core/operations/scaffold-machine.d.ts +1 -0
  41. package/dist/core/operations/scaffold-machine.d.ts.map +1 -1
  42. package/dist/core/operations/scaffold-machine.js.map +1 -1
  43. package/dist/core/operations/scaffold-plan.d.ts +2 -0
  44. package/dist/core/operations/scaffold-plan.d.ts.map +1 -1
  45. package/dist/core/operations/scaffold-plan.js +3 -1
  46. package/dist/core/operations/scaffold-plan.js.map +1 -1
  47. package/dist/core/project/agent-instructions.d.ts.map +1 -1
  48. package/dist/core/project/agent-instructions.js +4 -1
  49. package/dist/core/project/agent-instructions.js.map +1 -1
  50. package/dist/core/project/boundaries.d.ts +5 -2
  51. package/dist/core/project/boundaries.d.ts.map +1 -1
  52. package/dist/core/project/boundaries.js +49 -18
  53. package/dist/core/project/boundaries.js.map +1 -1
  54. package/dist/core/project/capabilities.d.ts +1 -0
  55. package/dist/core/project/capabilities.d.ts.map +1 -1
  56. package/dist/core/project/capabilities.js +20 -2
  57. package/dist/core/project/capabilities.js.map +1 -1
  58. package/dist/core/project/home-page.d.ts +4 -0
  59. package/dist/core/project/home-page.d.ts.map +1 -0
  60. package/dist/core/project/home-page.js +113 -0
  61. package/dist/core/project/home-page.js.map +1 -0
  62. package/dist/core/project/mcp-config.d.ts +26 -0
  63. package/dist/core/project/mcp-config.d.ts.map +1 -0
  64. package/dist/core/project/mcp-config.js +117 -0
  65. package/dist/core/project/mcp-config.js.map +1 -0
  66. package/dist/core/project/react-project.d.ts +1 -1
  67. package/dist/core/project/react-project.d.ts.map +1 -1
  68. package/dist/core/project/react-project.js +45 -24
  69. package/dist/core/project/react-project.js.map +1 -1
  70. package/dist/core/project/workflows.d.ts.map +1 -1
  71. package/dist/core/project/workflows.js +9 -1
  72. package/dist/core/project/workflows.js.map +1 -1
  73. package/dist/core/scaffold/auth-generator.js +8 -8
  74. package/dist/core/scaffold/model-parser.d.ts +23 -0
  75. package/dist/core/scaffold/model-parser.d.ts.map +1 -1
  76. package/dist/core/scaffold/model-parser.js +407 -137
  77. package/dist/core/scaffold/model-parser.js.map +1 -1
  78. package/dist/core/scaffold/native-schema-generator.d.ts +11 -0
  79. package/dist/core/scaffold/native-schema-generator.d.ts.map +1 -1
  80. package/dist/core/scaffold/native-schema-generator.js +279 -91
  81. package/dist/core/scaffold/native-schema-generator.js.map +1 -1
  82. package/dist/core/scaffold/openapi-generator.d.ts +5 -0
  83. package/dist/core/scaffold/openapi-generator.d.ts.map +1 -1
  84. package/dist/core/scaffold/openapi-generator.js +53 -3
  85. package/dist/core/scaffold/openapi-generator.js.map +1 -1
  86. package/dist/core/scaffold/ui-generator.d.ts +0 -3
  87. package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
  88. package/dist/core/scaffold/ui-generator.js +68 -7
  89. package/dist/core/scaffold/ui-generator.js.map +1 -1
  90. package/dist/core/verify/index.d.ts.map +1 -1
  91. package/dist/core/verify/index.js +24 -1
  92. package/dist/core/verify/index.js.map +1 -1
  93. package/dist/machine/index.d.ts.map +1 -1
  94. package/dist/machine/index.js +39 -15
  95. package/dist/machine/index.js.map +1 -1
  96. package/dist/mcp/index.d.ts +3 -2
  97. package/dist/mcp/index.d.ts.map +1 -1
  98. package/dist/mcp/index.js +137 -13
  99. package/dist/mcp/index.js.map +1 -1
  100. package/dist/mcp/version-selection.d.ts +55 -0
  101. package/dist/mcp/version-selection.d.ts.map +1 -0
  102. package/dist/mcp/version-selection.js +152 -0
  103. package/dist/mcp/version-selection.js.map +1 -0
  104. package/dist/utils/azure-cli.d.ts +44 -0
  105. package/dist/utils/azure-cli.d.ts.map +1 -0
  106. package/dist/utils/azure-cli.js +164 -0
  107. package/dist/utils/azure-cli.js.map +1 -0
  108. package/dist/utils/package-manager.js +1 -1
  109. package/dist/utils/package-manager.js.map +1 -1
  110. package/dist/utils/python-uv.d.ts +5 -7
  111. package/dist/utils/python-uv.d.ts.map +1 -1
  112. package/dist/utils/python-uv.js +50 -24
  113. package/dist/utils/python-uv.js.map +1 -1
  114. package/dist/utils/react-dev.d.ts +4 -0
  115. package/dist/utils/react-dev.d.ts.map +1 -0
  116. package/dist/utils/react-dev.js +35 -0
  117. package/dist/utils/react-dev.js.map +1 -0
  118. package/dist/utils/semver.d.ts +14 -0
  119. package/dist/utils/semver.d.ts.map +1 -0
  120. package/dist/utils/semver.js +61 -0
  121. package/dist/utils/semver.js.map +1 -0
  122. package/package.json +1 -1
  123. package/src/__tests__/__snapshots__/boundaries.test.ts.snap +154 -0
  124. package/src/__tests__/agent-loop-phase34.test.ts +29 -0
  125. package/src/__tests__/auth.test.ts +9 -0
  126. package/src/__tests__/azure-cli.test.ts +199 -0
  127. package/src/__tests__/boundaries.test.ts +42 -0
  128. package/src/__tests__/csharp-schema.test.ts +261 -0
  129. package/src/__tests__/dev.test.ts +13 -6
  130. package/src/__tests__/home-page.test.ts +50 -0
  131. package/src/__tests__/init.test.ts +57 -0
  132. package/src/__tests__/mcp-version.test.ts +262 -0
  133. package/src/__tests__/mcp.test.ts +49 -3
  134. package/src/__tests__/package-manager.test.ts +2 -2
  135. package/src/__tests__/plan-apply.test.ts +68 -0
  136. package/src/__tests__/python-uv.test.ts +50 -14
  137. package/src/__tests__/python-venv.test.ts +48 -0
  138. package/src/__tests__/react-dev.test.ts +72 -0
  139. package/src/__tests__/react.test.ts +19 -0
  140. package/src/__tests__/schema-analysis.test.ts +329 -0
  141. package/src/cli/commands/configure-mcp.ts +54 -0
  142. package/src/cli/commands/dev.ts +40 -31
  143. package/src/cli/commands/init.ts +48 -125
  144. package/src/cli/commands/migrations.ts +22 -0
  145. package/src/cli/commands/provision.ts +53 -17
  146. package/src/cli/commands/scaffold.ts +40 -2
  147. package/src/cli/index.ts +26 -3
  148. package/src/core/operations/model-migration.ts +52 -0
  149. package/src/core/operations/provision-operations.ts +86 -42
  150. package/src/core/operations/scaffold-apply.ts +2 -0
  151. package/src/core/operations/scaffold-machine.ts +1 -0
  152. package/src/core/operations/scaffold-plan.ts +5 -1
  153. package/src/core/project/agent-instructions.ts +4 -1
  154. package/src/core/project/agent-templates/AGENTS.md +39 -3
  155. package/src/core/project/agent-templates/CLAUDE.md +3 -1
  156. package/src/core/project/agent-templates/copilot-instructions.md +3 -1
  157. package/src/core/project/boundaries.ts +60 -18
  158. package/src/core/project/capabilities.ts +22 -2
  159. package/src/core/project/home-page.ts +114 -0
  160. package/src/core/project/mcp-config.ts +84 -0
  161. package/src/core/project/react-project.ts +49 -27
  162. package/src/core/project/workflows.ts +9 -1
  163. package/src/core/scaffold/auth-generator.ts +8 -8
  164. package/src/core/scaffold/model-parser.ts +413 -129
  165. package/src/core/scaffold/native-schema-generator.ts +305 -105
  166. package/src/core/scaffold/openapi-generator.ts +63 -5
  167. package/src/core/scaffold/ui-generator.ts +70 -8
  168. package/src/core/verify/index.ts +23 -1
  169. package/src/machine/index.ts +42 -18
  170. package/src/mcp/index.ts +156 -14
  171. package/src/mcp/version-selection.ts +163 -0
  172. package/src/utils/azure-cli.ts +154 -0
  173. package/src/utils/package-manager.ts +1 -1
  174. package/src/utils/python-uv.ts +53 -33
  175. package/src/utils/react-dev.ts +31 -0
  176. package/src/utils/semver.ts +59 -0
@@ -0,0 +1,199 @@
1
+ import * as childProcess from "child_process";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import {
6
+ buildBatchCommandLine,
7
+ checkAzCliAvailability,
8
+ resetAzCliInvocationCache,
9
+ resolveAzCliInvocation,
10
+ runAzCli,
11
+ setAzCliInvocationForTesting,
12
+ } from "../utils/azure-cli";
13
+ import { INTERACTIVE_PROVISION_RETRY_HINT, executeProvisionPlan, planProvisionOperation } from "../core/operations/provision-operations";
14
+ import { describeProvisionAction } from "../cli/commands/provision";
15
+ import { createMigrationManifest, saveMigrationManifest } from "../core/project/migrations";
16
+ import { formatCliError } from "../cli";
17
+ import { MachineCommandError } from "../machine/errors";
18
+
19
+ jest.mock("child_process", () => {
20
+ const actual = jest.requireActual<typeof import("child_process")>("child_process");
21
+ return { ...actual, spawnSync: jest.fn(actual.spawnSync) };
22
+ });
23
+
24
+ const spawnSyncMock = jest.mocked(childProcess.spawnSync) as unknown as ReturnType<typeof jest.fn>;
25
+
26
+ describe("Azure CLI resolution on Windows", () => {
27
+ let root: string;
28
+ beforeEach(() => {
29
+ root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-az-"));
30
+ });
31
+ afterEach(() => fs.rmSync(root, { recursive: true, force: true }));
32
+
33
+ const touch = (file: string) => {
34
+ fs.mkdirSync(path.dirname(file), { recursive: true });
35
+ fs.writeFileSync(file, "");
36
+ return file;
37
+ };
38
+
39
+ it("runs the MSI install's bundled Python the same way az.cmd does", () => {
40
+ const wrapper = touch(path.join(root, "CLI2", "wbin", "az.cmd"));
41
+ touch(path.join(root, "CLI2", "wbin", "az"));
42
+ const python = touch(path.join(root, "CLI2", "python.exe"));
43
+ const invocation = resolveAzCliInvocation({ PATH: `${path.join(root, "missing")};${path.dirname(wrapper)}`, PATHEXT: ".COM;.EXE;.BAT;.CMD" }, "win32");
44
+ expect(invocation).toEqual({ command: python, prefixArgs: ["-IBm", "azure.cli"], env: { AZ_INSTALLER: "MSI" }, source: wrapper });
45
+ });
46
+
47
+ it("uses an executable directly and other batch wrappers through cmd.exe", () => {
48
+ const exe = touch(path.join(root, "exe", "az.exe"));
49
+ expect(resolveAzCliInvocation({ PATH: path.dirname(exe) }, "win32")).toEqual({ command: exe, prefixArgs: [], source: exe });
50
+ const bat = touch(path.join(root, "venv", "Scripts", "az.bat"));
51
+ expect(resolveAzCliInvocation({ PATH: path.dirname(bat), ComSpec: "C:\\Windows\\system32\\cmd.exe" }, "win32"))
52
+ .toEqual({ command: "C:\\Windows\\system32\\cmd.exe", prefixArgs: [], batchScript: bat, source: bat });
53
+ });
54
+
55
+ it("reports not found when PATH has no az, and uses az directly elsewhere", () => {
56
+ expect(resolveAzCliInvocation({ PATH: root }, "win32")).toBeNull();
57
+ expect(resolveAzCliInvocation({ PATH: "/usr/bin" }, "linux")).toEqual({ command: "az", prefixArgs: [], source: "az" });
58
+ });
59
+
60
+ it("quotes batch arguments and refuses values cmd.exe would reinterpret", () => {
61
+ expect(buildBatchCommandLine("C:\\Program Files\\az.cmd", ["deployment", "--template-file", "C:\\my app\\main.bicep", "location=japaneast"]))
62
+ .toBe('""C:\\Program Files\\az.cmd" "deployment" "--template-file" "C:\\my app\\main.bicep" "location=japaneast""');
63
+ expect(() => buildBatchCommandLine("az.cmd", ["100%"])).toThrow(/cannot be passed safely/);
64
+ expect(() => buildBatchCommandLine("az.cmd", ['a"b'])).toThrow(/cannot be passed safely/);
65
+ });
66
+ });
67
+
68
+ describe("Azure CLI invocation and availability", () => {
69
+ afterEach(() => {
70
+ resetAzCliInvocationCache();
71
+ spawnSyncMock.mockReset();
72
+ spawnSyncMock.mockImplementation(jest.requireActual<typeof import("child_process")>("child_process").spawnSync);
73
+ });
74
+
75
+ it("never uses a shell and prepends wrapper arguments", () => {
76
+ spawnSyncMock.mockReturnValue({ status: 0, stdout: "ok", stderr: "" });
77
+ const invocation = { command: "C:\\CLI2\\python.exe", prefixArgs: ["-IBm", "azure.cli"], env: { AZ_INSTALLER: "MSI" }, source: "az.cmd" };
78
+ expect(runAzCli(["group", "exists", "--name", "rg"], { invocation })).toMatchObject({ status: 0, stdout: "ok" });
79
+ const [command, args, options] = spawnSyncMock.mock.calls[0];
80
+ expect(command).toBe("C:\\CLI2\\python.exe");
81
+ expect(args).toEqual(["-IBm", "azure.cli", "group", "exists", "--name", "rg"]);
82
+ expect(options).toMatchObject({ shell: false, env: expect.objectContaining({ AZ_INSTALLER: "MSI" }) });
83
+ });
84
+
85
+ it("runs batch wrappers through cmd.exe with verbatim quoted arguments", () => {
86
+ spawnSyncMock.mockReturnValue({ status: 0, stdout: "", stderr: "" });
87
+ runAzCli(["--version"], { invocation: { command: "cmd.exe", prefixArgs: [], batchScript: "C:\\az.bat", source: "C:\\az.bat" } });
88
+ const [command, args, options] = spawnSyncMock.mock.calls[0];
89
+ expect(command).toBe("cmd.exe");
90
+ expect(args).toEqual(["/d", "/s", "/v:off", "/c", '""C:\\az.bat" "--version""']);
91
+ expect(options).toMatchObject({ shell: false, windowsVerbatimArguments: true });
92
+ });
93
+
94
+ it("distinguishes a missing CLI from one that cannot start", () => {
95
+ setAzCliInvocationForTesting(null);
96
+ expect(checkAzCliAvailability()).toEqual({ available: false, reason: "not-found", detail: "az was not found on PATH." });
97
+
98
+ setAzCliInvocationForTesting({ command: "az", prefixArgs: [], source: "az" });
99
+ spawnSyncMock.mockReturnValue({ status: null, stdout: "", stderr: "", error: Object.assign(new Error("spawn az ENOENT"), { code: "ENOENT" }) });
100
+ expect(checkAzCliAvailability()).toMatchObject({ available: false, reason: "not-found" });
101
+
102
+ spawnSyncMock.mockReturnValue({ status: 1, stdout: "", stderr: "ModuleNotFoundError: azure.cli" });
103
+ expect(checkAzCliAvailability()).toMatchObject({ available: false, reason: "not-runnable", detail: expect.stringContaining("ModuleNotFoundError") });
104
+
105
+ spawnSyncMock.mockReturnValue({ status: 0, stdout: "azure-cli 2.70.0", stderr: "" });
106
+ expect(checkAzCliAvailability()).toMatchObject({ available: true });
107
+ });
108
+ });
109
+
110
+ describe("provision planning with Azure CLI state", () => {
111
+ const originalCwd = process.cwd();
112
+ let project: string;
113
+ beforeEach(() => {
114
+ project = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-provision-az-"));
115
+ fs.mkdirSync(path.join(project, "infra"), { recursive: true });
116
+ fs.writeFileSync(path.join(project, "infra", "main.bicep"), "targetScope = 'resourceGroup'\n");
117
+ fs.writeFileSync(path.join(project, "infra", "main.parameters.json"), JSON.stringify({ parameters: { projectName: { value: "sample" } } }));
118
+ saveMigrationManifest(createMigrationManifest(project, { projectId: "project" }), project);
119
+ process.chdir(project);
120
+ setAzCliInvocationForTesting({ command: "az", prefixArgs: [], source: "az" });
121
+ });
122
+ afterEach(() => {
123
+ process.chdir(originalCwd);
124
+ resetAzCliInvocationCache();
125
+ spawnSyncMock.mockReset();
126
+ spawnSyncMock.mockImplementation(jest.requireActual<typeof import("child_process")>("child_process").spawnSync);
127
+ fs.rmSync(project, { recursive: true, force: true });
128
+ });
129
+
130
+ const options = { resourceGroup: "rg-sample", location: "japaneast", swaLocation: "eastasia" };
131
+
132
+ it("reads Azure state through the resolved CLI and plans a bootstrap", async () => {
133
+ spawnSyncMock.mockImplementation((_command: string, args: string[]) => {
134
+ if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
135
+ if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: "false\n", stderr: "" };
136
+ return { status: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
137
+ });
138
+ const plan = await planProvisionOperation(options);
139
+ expect(plan.azCliAvailable).toBe(true);
140
+ expect(plan.mode).toBe("bootstrap");
141
+ expect(plan.warnings.filter((warning) => warning.startsWith("az-"))).toEqual([]);
142
+ expect(spawnSyncMock.mock.calls.every((call: unknown[]) => (call[2] as { shell?: boolean }).shell === false)).toBe(true);
143
+ });
144
+
145
+ const plansDir = () => path.join(project, ".swallowkit", "state", "plans");
146
+ const savedPlans = () => (fs.existsSync(plansDir()) ? fs.readdirSync(plansDir()) : []);
147
+
148
+ it("keeps the interactive preview in memory and re-checks Azure before running it", async () => {
149
+ let groupExists = false;
150
+ spawnSyncMock.mockImplementation((_command: string, args: string[]) => {
151
+ if (args[0] === "--version") return { status: 0, stdout: "azure-cli", stderr: "" };
152
+ if (args[0] === "group" && args[1] === "exists") return { status: 0, stdout: `${groupExists}\n`, stderr: "" };
153
+ if (args[0] === "group" && args[1] === "show") return { status: 0, stdout: JSON.stringify({ id: "/subscriptions/test/resourceGroups/rg-sample", tags: {} }), stderr: "" };
154
+ return { status: 1, stdout: "", stderr: `unexpected ${args.join(" ")}` };
155
+ });
156
+ const preview = await planProvisionOperation(options, { persist: false });
157
+ expect(savedPlans()).toEqual([]);
158
+ expect(describeProvisionAction(preview)).toBe(
159
+ "Resource group rg-sample does not exist yet. It will be created in japaneast and the infrastructure in infra/main.bicep will be deployed."
160
+ );
161
+
162
+ groupExists = true;
163
+ await expect(executeProvisionPlan(preview, { approve: true, retryHint: INTERACTIVE_PROVISION_RETRY_HINT })).rejects.toMatchObject({
164
+ code: "stale-azure-state",
165
+ message: 'The resource group was created after the preview was computed. Run "swallowkit provision" again to review the current state.',
166
+ });
167
+ expect(spawnSyncMock.mock.calls.some((call: unknown[]) => (call[1] as string[])[0] === "group" && (call[1] as string[])[1] === "create")).toBe(false);
168
+ });
169
+
170
+ it("still saves plans for the machine plan/apply workflow", async () => {
171
+ spawnSyncMock.mockImplementation((_command: string, args: string[]) => args[0] === "--version"
172
+ ? { status: 0, stdout: "azure-cli", stderr: "" }
173
+ : { status: 0, stdout: "false\n", stderr: "" });
174
+ const plan = await planProvisionOperation(options);
175
+ expect(savedPlans().some((file) => file.includes(plan.planId))).toBe(true);
176
+ });
177
+
178
+ it("asks for az login instead of reporting a missing CLI", async () => {
179
+ spawnSyncMock.mockImplementation((_command: string, args: string[]) => args[0] === "--version"
180
+ ? { status: 0, stdout: "azure-cli", stderr: "" }
181
+ : { status: 1, stdout: "", stderr: "ERROR: Please run 'az login' to setup account." });
182
+ await expect(planProvisionOperation(options)).rejects.toMatchObject({ code: "az-not-logged-in", status: "requires-human" });
183
+ });
184
+
185
+ it("reports an installed but broken CLI accurately", async () => {
186
+ spawnSyncMock.mockReturnValue({ status: 1, stdout: "", stderr: "Failed to load python executable." });
187
+ const plan = await planProvisionOperation(options);
188
+ expect(plan.mode).toBe("offline");
189
+ expect(plan.warnings.some((warning) => warning.startsWith("az-cli-not-runnable:") && warning.includes("Failed to load python executable."))).toBe(true);
190
+ });
191
+ });
192
+
193
+ describe("regular CLI error output", () => {
194
+ it("prints machine errors without a stack trace", () => {
195
+ const text = formatCliError(new MachineCommandError("online-plan-required", "Cannot apply.", { nextAction: "Run az login." }, "blocked"));
196
+ expect(text).toBe("❌ Cannot apply.\n (code: online-plan-required)\n Next: Run az login.");
197
+ expect(formatCliError(new Error("plain failure"))).toBe("❌ plain failure");
198
+ });
199
+ });
@@ -0,0 +1,42 @@
1
+ import * as fs from "fs";
2
+ import * as os from "os";
3
+ import * as path from "path";
4
+ import { inspectBoundaries } from "../core/project/boundaries";
5
+ import { buildSwallowKitConfigSource } from "../cli/commands/init";
6
+ import type { BackendLanguage } from "../types";
7
+
8
+ describe("inspect boundaries convention rules", () => {
9
+ let project: string;
10
+ const originalCwd = process.cwd();
11
+
12
+ beforeEach(() => {
13
+ project = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-boundaries-"));
14
+ });
15
+
16
+ afterEach(() => {
17
+ process.chdir(originalCwd);
18
+ fs.rmSync(project, { recursive: true, force: true });
19
+ });
20
+
21
+ it.each<BackendLanguage>(["csharp", "python", "typescript"])("matches the %s backend", (language) => {
22
+ const contract = inspectBoundaries(project, language);
23
+ expect(contract.backendLanguage).toBe(language);
24
+ expect(contract.conventionRules).toMatchSnapshot();
25
+ expect(contract.conventionRules.some((rule) => rule.pattern === "functions/function_app.py")).toBe(language === "python");
26
+ });
27
+
28
+ it("resolves the backend language from swallowkit.config", () => {
29
+ fs.writeFileSync(path.join(project, "swallowkit.config.js"), buildSwallowKitConfigSource("csharp", "react"));
30
+ process.chdir(project);
31
+ const contract = inspectBoundaries(project);
32
+ expect(contract.backendLanguage).toBe("csharp");
33
+ expect(contract.conventionRules.map((rule) => rule.pattern)).not.toContain("functions/function_app.py");
34
+ });
35
+
36
+ it("orders specific infrastructure rules before the baseline rule", () => {
37
+ const patterns = inspectBoundaries(project, "typescript").conventionRules.map((rule) => rule.pattern);
38
+ expect(patterns.indexOf("infra/migrations/manifest.json")).toBeLessThan(patterns.indexOf("infra/migrations/"));
39
+ expect(patterns.indexOf("infra/migrations/")).toBeLessThan(patterns.indexOf("infra/"));
40
+ expect(patterns[patterns.length - 1]).toBe("*");
41
+ });
42
+ });
@@ -0,0 +1,261 @@
1
+ import { spawnSync } from "child_process";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import * as z from "zod/v4";
6
+ import { FileOperationSession } from "../core/operations/file-session";
7
+ import { parseModelFile } from "../core/scaffold/model-parser";
8
+ import { generateLanguageSchemaArtifacts } from "../core/scaffold/native-schema-generator";
9
+ import { generateOpenApiDocument } from "../core/scaffold/openapi-generator";
10
+
11
+ const MODEL_SOURCE = `import { z } from 'zod/v4';
12
+ export const ProbeItem = z.object({
13
+ id: z.string(),
14
+ userId: z.string(),
15
+ title: z.string().min(1).max(40),
16
+ code: z.string().regex(/^[a-z]+-\\d+$/i).optional(),
17
+ tags: z.array(z.string().min(2)).max(3),
18
+ score: z.number().min(0).max(1),
19
+ count: z.number().int().nonnegative(),
20
+ kind: z.enum(['a', 'b']),
21
+ nested: z.object({ x: z.string(), depth: z.object({ level: z.number().int() }).optional() }).optional(),
22
+ publishedAt: z.iso.datetime().optional(),
23
+ note: z.string().nullable(),
24
+ rating: z.number().gt(0).min(0).lt(5).max(5).nullable(),
25
+ items: z.array(z.object({ x: z.string().min(1) })).optional(),
26
+ kinds: z.array(z.enum(['a', 'b'])).optional(),
27
+ metadata: z.object({}).optional(),
28
+ createdAt: z.string().optional(),
29
+ updatedAt: z.string().optional(),
30
+ });
31
+ export type ProbeItem = z.infer<typeof ProbeItem>;
32
+ export const partitionKey = '/userId';
33
+ export const displayName = 'Probe';
34
+ `;
35
+
36
+ // Same schema as MODEL_SOURCE; used to validate C# output exactly as the BFF responseSchema would.
37
+ const ProbeItem = z.object({
38
+ id: z.string(),
39
+ userId: z.string(),
40
+ title: z.string().min(1).max(40),
41
+ code: z.string().regex(/^[a-z]+-\d+$/i).optional(),
42
+ tags: z.array(z.string().min(2)).max(3),
43
+ score: z.number().min(0).max(1),
44
+ count: z.number().int().nonnegative(),
45
+ kind: z.enum(["a", "b"]),
46
+ nested: z.object({ x: z.string(), depth: z.object({ level: z.number().int() }).optional() }).optional(),
47
+ publishedAt: z.iso.datetime().optional(),
48
+ note: z.string().nullable(),
49
+ rating: z.number().gt(0).min(0).lt(5).max(5).nullable(),
50
+ items: z.array(z.object({ x: z.string().min(1) })).optional(),
51
+ kinds: z.array(z.enum(["a", "b"])).optional(),
52
+ metadata: z.object({}).optional(),
53
+ createdAt: z.string().optional(),
54
+ updatedAt: z.string().optional(),
55
+ });
56
+
57
+ const VALID_INPUTS = [
58
+ {
59
+ id: "1",
60
+ userId: "user-1",
61
+ title: "Probe",
62
+ code: "ABC-12",
63
+ tags: ["aa", "bb"],
64
+ score: 0.25,
65
+ count: 3,
66
+ kind: "b",
67
+ nested: { x: "value", depth: { level: 2 } },
68
+ publishedAt: "2024-05-06T07:08:09.123Z",
69
+ note: "note",
70
+ rating: 2.5,
71
+ items: [{ x: "a" }, { x: "b" }],
72
+ kinds: ["a", "b"],
73
+ metadata: {},
74
+ createdAt: "2024-05-06T07:08:09.123456+09:00",
75
+ updatedAt: "not a timestamp but still a string",
76
+ },
77
+ { id: "2", userId: "user-2", title: "Minimal", tags: [], score: 1, count: 0, kind: "a", note: null, rating: null },
78
+ ];
79
+
80
+ const INVALID_INPUTS: Array<{ input: Record<string, unknown>; members: string[] }> = [
81
+ { input: { ...VALID_INPUTS[1], title: "" }, members: ["title"] },
82
+ { input: { ...VALID_INPUTS[1], title: "x".repeat(41) }, members: ["title"] },
83
+ { input: { ...VALID_INPUTS[1], code: "no-digits" }, members: ["code"] },
84
+ { input: { ...VALID_INPUTS[1], tags: ["a"] }, members: ["tags[0]"] },
85
+ { input: { ...VALID_INPUTS[1], tags: ["aa", "bb", "cc", "dd"] }, members: ["tags"] },
86
+ { input: { ...VALID_INPUTS[1], score: 1.5 }, members: ["score"] },
87
+ { input: { ...VALID_INPUTS[1], count: -1 }, members: ["count"] },
88
+ { input: { ...VALID_INPUTS[1], nested: { depth: { level: 1 } } }, members: ["nested.x"] },
89
+ { input: { ...VALID_INPUTS[1], rating: 0 }, members: ["rating"] },
90
+ { input: { ...VALID_INPUTS[1], rating: 5 }, members: ["rating"] },
91
+ { input: { ...VALID_INPUTS[1], items: [{ x: "ok" }, { x: "" }] }, members: ["items[1].x"] },
92
+ ];
93
+
94
+ function createModel(): { root: string; modelPath: string } {
95
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-csharp-schema-"));
96
+ const modelPath = path.join(root, "shared", "models", "probe-item.ts");
97
+ fs.mkdirSync(path.dirname(modelPath), { recursive: true });
98
+ fs.writeFileSync(modelPath, MODEL_SOURCE);
99
+ return { root, modelPath };
100
+ }
101
+
102
+ describe("Zod -> OpenAPI -> C# conversion", () => {
103
+ let root: string;
104
+ let modelPath: string;
105
+ const originalCwd = process.cwd();
106
+
107
+ beforeEach(() => {
108
+ ({ root, modelPath } = createModel());
109
+ });
110
+
111
+ afterEach(() => {
112
+ process.chdir(originalCwd);
113
+ fs.rmSync(root, { recursive: true, force: true });
114
+ });
115
+
116
+ it("records formats, integers, constraints, and inline object shapes from Zod", async () => {
117
+ const model = await parseModelFile(modelPath);
118
+ const field = (name: string) => model.fields.find((candidate) => candidate.name === name)!;
119
+ expect(model.parserMode).toBe("static-ast");
120
+ expect(field("createdAt")).toMatchObject({ type: "string", isOptional: true });
121
+ expect(field("createdAt").format).toBeUndefined();
122
+ expect(field("publishedAt").format).toBe("date-time");
123
+ expect(field("title").constraints).toEqual({ minLength: 1, maxLength: 40 });
124
+ expect(field("code").constraints).toEqual({ pattern: "^[a-z]+-\\d+$", patternFlags: "i" });
125
+ expect(field("tags").constraints).toEqual({ minLength: 2, maxItems: 3 });
126
+ expect(field("score")).toMatchObject({ constraints: { minimum: 0, maximum: 1 } });
127
+ expect(field("score").isInteger).toBeUndefined();
128
+ expect(field("count")).toMatchObject({ isInteger: true, constraints: { minimum: 0 } });
129
+ expect(field("nested").objectFields?.map((child) => child.name)).toEqual(["x", "depth"]);
130
+ expect(field("nested").objectFields?.[1].objectFields?.[0]).toMatchObject({ name: "level", isInteger: true });
131
+ expect(field("rating")).toMatchObject({
132
+ isNullable: true,
133
+ constraints: { minimum: 0, exclusiveMinimum: true, maximum: 5, exclusiveMaximum: true },
134
+ });
135
+ expect(field("metadata").objectFields).toEqual([]);
136
+ });
137
+
138
+ it("emits typed OpenAPI components without guessing formats from names", async () => {
139
+ const model = await parseModelFile(modelPath);
140
+ const schemas = JSON.parse(generateOpenApiDocument([model], model)).components.schemas;
141
+ const properties = schemas.ProbeItem.properties;
142
+ expect(properties.createdAt).toEqual({ type: "string" });
143
+ expect(properties.publishedAt).toEqual({ type: "string", format: "date-time" });
144
+ expect(properties.score).toEqual({ type: "number", format: "double", minimum: 0, maximum: 1 });
145
+ expect(properties.count).toEqual({ type: "integer", format: "int64", minimum: 0 });
146
+ expect(properties.title).toEqual({ type: "string", minLength: 1, maxLength: 40 });
147
+ expect(properties.code).toEqual({ type: "string", pattern: "^[a-z]+-\\d+$" });
148
+ expect(properties.tags).toEqual({ type: "array", items: { type: "string", minLength: 2 }, maxItems: 3 });
149
+ expect(properties.nested).toEqual({ $ref: "#/components/schemas/ProbeItemNested" });
150
+ expect(schemas.ProbeItemNested).toEqual({
151
+ type: "object",
152
+ properties: { x: { type: "string" }, depth: { $ref: "#/components/schemas/ProbeItemNestedDepth" } },
153
+ required: ["x"],
154
+ });
155
+ expect(schemas.ProbeItemNestedDepth.properties.level).toEqual({ type: "integer", format: "int64" });
156
+ });
157
+
158
+ it("generates C# types and validation from the Zod contract", async () => {
159
+ const model = await parseModelFile(modelPath);
160
+ process.chdir(root);
161
+ await generateLanguageSchemaArtifacts([model], model, "functions", "csharp", new FileOperationSession("commit", root));
162
+ const modelDir = path.join(root, "functions", "generated", "csharp-models", "src", "SwallowKitBackendModels", "Model");
163
+ const source = fs.readFileSync(path.join(modelDir, "ProbeItem.cs"), "utf8");
164
+
165
+ expect(source).toContain("public string? CreatedAt");
166
+ expect(source).toContain("public DateTime? PublishedAt");
167
+ expect(source).toContain("public double Score");
168
+ expect(source).toContain("public long Count");
169
+ expect(source).toContain("public ProbeItemNested? Nested");
170
+ expect(source).not.toMatch(/Dictionary<string, object>|decimal|DateTime\? CreatedAt/);
171
+ expect(source).not.toContain("yield break;");
172
+ expect(source).toContain('Title.Length < 1');
173
+ expect(source).toContain('Regex.IsMatch(Code, "^[a-z]+-\\\\d+$", RegexOptions.IgnoreCase)');
174
+ expect(source).toContain("Score > 1");
175
+ expect(source).toContain("Count < 0");
176
+ expect(fs.readFileSync(path.join(modelDir, "ProbeItemNested.cs"), "utf8")).toContain("public ProbeItemNestedDepth? Depth");
177
+ expect(fs.readFileSync(path.join(modelDir, "ProbeItemNestedDepth.cs"), "utf8")).toContain("public long Level");
178
+ expect(source).toContain("public string? Note");
179
+ expect(source).not.toContain("if (Note is null)");
180
+ expect(source).toContain("public double? Rating");
181
+ expect(source).toContain("Rating.HasValue && Rating.Value <= 0");
182
+ expect(source).toContain("public List<ProbeItemItems>? Items");
183
+ expect(source).toContain("public List<KindsEnum>? Kinds");
184
+ const metadata = fs.readFileSync(path.join(modelDir, "ProbeItemMetadata.cs"), "utf8");
185
+ expect(metadata.match(/public ProbeItemMetadata\(\)/g)).toHaveLength(1);
186
+ });
187
+ });
188
+
189
+ const dotnetVersion = spawnSync("dotnet", ["--version"], { encoding: "utf8" });
190
+ const itWithDotnet = dotnetVersion.status === 0 ? it : it.skip;
191
+
192
+ describe("generated C# JSON round trip", () => {
193
+ itWithDotnet("serializes back to JSON that passes the same Zod schema, and rejects what Zod rejects", async () => {
194
+ const { root, modelPath } = createModel();
195
+ const originalCwd = process.cwd();
196
+ try {
197
+ const model = await parseModelFile(modelPath);
198
+ process.chdir(root);
199
+ await generateLanguageSchemaArtifacts([model], model, "functions", "csharp", new FileOperationSession("commit", root));
200
+ process.chdir(originalCwd);
201
+
202
+ const app = path.join(root, "roundtrip");
203
+ fs.mkdirSync(app);
204
+ const major = String(dotnetVersion.stdout).trim().split(".")[0];
205
+ fs.writeFileSync(path.join(app, "RoundTrip.csproj"), `<Project Sdk="Microsoft.NET.Sdk">
206
+ <PropertyGroup>
207
+ <OutputType>Exe</OutputType>
208
+ <TargetFramework>net${major}.0</TargetFramework>
209
+ <Nullable>enable</Nullable>
210
+ <ImplicitUsings>enable</ImplicitUsings>
211
+ </PropertyGroup>
212
+ <ItemGroup>
213
+ <Compile Include="../functions/generated/csharp-models/**/*.cs" />
214
+ </ItemGroup>
215
+ </Project>
216
+ `);
217
+ fs.writeFileSync(path.join(app, "Program.cs"), `using System.ComponentModel.DataAnnotations;
218
+ using System.Text.Json;
219
+ using SwallowKitBackendModels.Model;
220
+
221
+ var results = new List<object>();
222
+ foreach (var file in args)
223
+ {
224
+ var item = JsonSerializer.Deserialize<ProbeItem>(File.ReadAllText(file))!;
225
+ var errors = new List<ValidationResult>();
226
+ Validator.TryValidateObject(item, new ValidationContext(item), errors, true);
227
+ results.Add(new { json = JsonSerializer.Serialize(item), members = errors.SelectMany(error => error.MemberNames).ToArray() });
228
+ }
229
+ Console.WriteLine(JsonSerializer.Serialize(results));
230
+ `);
231
+ const inputs = [...VALID_INPUTS, ...INVALID_INPUTS.map((entry) => entry.input)];
232
+ const files = inputs.map((input, index) => {
233
+ const file = path.join(root, `input-${index}.json`);
234
+ fs.writeFileSync(file, JSON.stringify(input));
235
+ return file;
236
+ });
237
+ const run = spawnSync("dotnet", ["run", "--project", app, "--", ...files], {
238
+ encoding: "utf8",
239
+ timeout: 240000,
240
+ env: { ...process.env, DOTNET_CLI_TELEMETRY_OPTOUT: "1", DOTNET_NOLOGO: "1" },
241
+ });
242
+ if (run.status !== 0) throw new Error(`dotnet run failed (${run.status}):\n${run.stdout}\n${run.stderr}`);
243
+ const output = JSON.parse(run.stdout.trim().split(/\r?\n/).pop()!) as Array<{ json: string; members: string[] }>;
244
+
245
+ VALID_INPUTS.forEach((input, index) => {
246
+ expect(ProbeItem.safeParse(input).success).toBe(true);
247
+ expect(output[index].members).toEqual([]);
248
+ const roundTripped = JSON.parse(output[index].json);
249
+ expect(ProbeItem.safeParse(roundTripped).success).toBe(true);
250
+ expect(roundTripped).toEqual(input);
251
+ });
252
+ INVALID_INPUTS.forEach(({ input, members }, offset) => {
253
+ expect(ProbeItem.safeParse(input).success).toBe(false);
254
+ expect(output[VALID_INPUTS.length + offset].members).toEqual(members);
255
+ });
256
+ } finally {
257
+ process.chdir(originalCwd);
258
+ fs.rmSync(root, { recursive: true, force: true });
259
+ }
260
+ }, 300000);
261
+ });
@@ -32,6 +32,7 @@ describe("dev command helpers", () => {
32
32
 
33
33
  it("passes the requested port to Azure Functions Core Tools", () => {
34
34
  expect(buildFunctionsStartArgs("7076")).toEqual(["start", "--port", "7076"]);
35
+ expect(buildFunctionsStartArgs("7076", "csharp")).toEqual(["--port", "7076"]);
35
36
  });
36
37
 
37
38
  it("parses the Core Tools version from CLI output", () => {
@@ -47,16 +48,22 @@ describe("dev command helpers", () => {
47
48
  it("falls back to npm Core Tools for older C# isolated hosts", () => {
48
49
  expect(buildFunctionsCoreToolsCommand("csharp", "4.0.5198")).toEqual({
49
50
  command: "npm",
50
- argsPrefix: ["exec", "--yes", "azure-functions-core-tools@4", "--"],
51
- label: "npm exec azure-functions-core-tools@4 (installed func 4.0.5198 is too old for C# isolated)",
51
+ argsPrefix: ["exec", "--yes", "--package", "azure-functions-core-tools@4", "--", "dotnet", "run", "--no-launch-profile", "--"],
52
+ label: "dotnet run with npm azure-functions-core-tools@4 (installed func 4.0.5198 is too old for C# isolated)",
52
53
  });
53
54
  });
54
55
 
55
- it("keeps the installed func command for supported Core Tools versions", () => {
56
+ it("uses the .NET SDK launch target for C# so extensions load from the output directory", () => {
56
57
  expect(buildFunctionsCoreToolsCommand("csharp", "4.6.0")).toEqual({
57
- command: "func",
58
- argsPrefix: [],
59
- label: "func 4.6.0",
58
+ command: "dotnet",
59
+ argsPrefix: ["run", "--no-launch-profile", "--"],
60
+ label: "dotnet run",
61
+ });
62
+ });
63
+
64
+ it.each(["typescript", "python"] as const)("keeps func start for %s", language => {
65
+ expect(buildFunctionsCoreToolsCommand(language, "4.14.0")).toEqual({
66
+ command: "func", argsPrefix: [], label: "func 4.14.0",
60
67
  });
61
68
  });
62
69
 
@@ -0,0 +1,50 @@
1
+ import { buildHomePage } from "../core/project/home-page";
2
+ import { adaptReactPage } from "../core/scaffold/react-ui-generator";
3
+
4
+ describe("shared home page presentation", () => {
5
+ it.each(["npm", "pnpm"] as const)(
6
+ "differs only in framework label and routing with %s",
7
+ (pm) => {
8
+ const next = buildHomePage("nextjs", pm);
9
+ const react = buildHomePage("react", pm);
10
+ const adaptedNext = adaptReactPage(next)
11
+ .replace("export const dynamic = 'force-dynamic';\n\n", "")
12
+ .replace("Next.js on Azure", "React on Azure");
13
+
14
+ expect(react).toBe(adaptedNext);
15
+ expect(react).toContain("Welcome to SwallowKit");
16
+ expect(react).toContain("Your Models");
17
+ expect(react).toContain("Get Started");
18
+ expect(react).toContain("Built with SwallowKit");
19
+ expect(react).toContain(
20
+ `${pm === "npm" ? "npx" : "pnpm exec"} swallowkit scaffold shared/models/your-model.ts`
21
+ );
22
+ }
23
+ );
24
+
25
+ it("keeps Next.js-only directives out of the SPA", () => {
26
+ const next = buildHomePage("nextjs", "pnpm");
27
+ const react = buildHomePage("react", "pnpm");
28
+
29
+ expect(next).toContain("'use client'");
30
+ expect(next).toContain("export const dynamic = 'force-dynamic'");
31
+ expect(next).toContain("href={model.path}");
32
+ expect(react).not.toMatch(/next\/|use client|force-dynamic/);
33
+ expect(react).toContain("import { Link } from 'react-router-dom'");
34
+ expect(react).toContain("to={model.path}");
35
+ });
36
+
37
+ it.each(["nextjs", "react"] as const)(
38
+ "preserves connection states and same-origin requests for %s",
39
+ (frontend) => {
40
+ const source = buildHomePage(frontend, "pnpm");
41
+ expect(source).toContain("fetch('/api/greet?name=SwallowKit')");
42
+ expect(source).toContain("disabled={greetingStatus === 'loading'}");
43
+ expect(source).toContain("setGreetingStatus('success')");
44
+ expect(source).toContain("setGreetingStatus('error')");
45
+ expect(source).toContain('role="status"');
46
+ expect(source).toContain('role="alert"');
47
+ expect(source).not.toMatch(/BACKEND_FUNCTIONS_|VITE_/);
48
+ }
49
+ );
50
+ });