@usequark/quark-create-app 1.23.11

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 (237) hide show
  1. package/README.md +210 -0
  2. package/package.json +56 -0
  3. package/src/dependabot-config.test.js +128 -0
  4. package/src/deploy/adapters/iac.js +203 -0
  5. package/src/deploy/adapters/index.js +27 -0
  6. package/src/deploy/adapters/railway.js +508 -0
  7. package/src/deploy/adapters/railway.test.js +318 -0
  8. package/src/deploy/contract.js +59 -0
  9. package/src/deploy/deploy.integration.test.js +183 -0
  10. package/src/deploy/deploy.js +430 -0
  11. package/src/deploy/deploy.test.js +105 -0
  12. package/src/deploy/discovery.js +115 -0
  13. package/src/deploy/discovery.test.js +141 -0
  14. package/src/deploy/index.js +38 -0
  15. package/src/deploy/inspect.js +130 -0
  16. package/src/index.js +2920 -0
  17. package/src/mobile-template.test.js +232 -0
  18. package/src/prepare-hooks.test.js +535 -0
  19. package/src/scaffold-guards.test.js +140 -0
  20. package/src/scaffold-output.test.js +228 -0
  21. package/src/template-scripts.test.js +160 -0
  22. package/src/utils.js +77 -0
  23. package/src/utils.test.js +95 -0
  24. package/src/worker-dockerfile.test.js +85 -0
  25. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  26. package/templates/base-project/.dockerignore +14 -0
  27. package/templates/base-project/.env.railway.example +87 -0
  28. package/templates/base-project/.github/copilot-instructions.md +65 -0
  29. package/templates/base-project/.github/dependabot.yml +31 -0
  30. package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
  31. package/templates/base-project/.github/workflows/ci.yml +116 -0
  32. package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
  33. package/templates/base-project/.github/workflows/install-windows.yml +27 -0
  34. package/templates/base-project/.github/workflows/release.yml +67 -0
  35. package/templates/base-project/.railway/railway.ts +40 -0
  36. package/templates/base-project/CLAUDE.md +163 -0
  37. package/templates/base-project/MAIN.md +21 -0
  38. package/templates/base-project/README.md +164 -0
  39. package/templates/base-project/apps/web/.env.example +99 -0
  40. package/templates/base-project/apps/web/Dockerfile +38 -0
  41. package/templates/base-project/apps/web/biome.json +8 -0
  42. package/templates/base-project/apps/web/jsconfig.json +9 -0
  43. package/templates/base-project/apps/web/next.config.js +154 -0
  44. package/templates/base-project/apps/web/package.json +42 -0
  45. package/templates/base-project/apps/web/postcss.config.mjs +7 -0
  46. package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
  47. package/templates/base-project/apps/web/public/quark.svg +47 -0
  48. package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
  49. package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
  50. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  51. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  52. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  53. package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
  54. package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
  55. package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
  56. package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
  57. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
  58. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
  59. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
  60. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
  61. package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
  62. package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
  63. package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
  64. package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
  65. package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
  66. package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
  67. package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
  68. package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
  69. package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
  70. package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
  71. package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
  72. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
  73. package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
  74. package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
  75. package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
  76. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
  77. package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
  78. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  79. package/templates/base-project/apps/web/src/app/global-error.js +58 -0
  80. package/templates/base-project/apps/web/src/app/globals.css +1166 -0
  81. package/templates/base-project/apps/web/src/app/icon.svg +47 -0
  82. package/templates/base-project/apps/web/src/app/layout.js +57 -0
  83. package/templates/base-project/apps/web/src/app/manifest.js +12 -0
  84. package/templates/base-project/apps/web/src/app/not-found.js +39 -0
  85. package/templates/base-project/apps/web/src/app/page.js +262 -0
  86. package/templates/base-project/apps/web/src/app/robots.js +23 -0
  87. package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
  88. package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
  89. package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
  90. package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
  91. package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
  92. package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
  93. package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
  94. package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
  95. package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
  96. package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
  97. package/templates/base-project/apps/web/src/lib/auth.js +191 -0
  98. package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
  99. package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
  100. package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
  101. package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
  102. package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
  103. package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
  104. package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
  105. package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
  106. package/templates/base-project/apps/web/src/preflight.js +55 -0
  107. package/templates/base-project/apps/web/src/proxy.js +328 -0
  108. package/templates/base-project/biome.json +52 -0
  109. package/templates/base-project/docker-compose.override.yml +17 -0
  110. package/templates/base-project/docker-compose.yml +37 -0
  111. package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
  112. package/templates/base-project/docs/START_HERE.md +70 -0
  113. package/templates/base-project/docs/adr/README.md +42 -0
  114. package/templates/base-project/package.json +58 -0
  115. package/templates/base-project/packages/db/package.json +38 -0
  116. package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
  117. package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
  118. package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
  119. package/templates/base-project/packages/db/prisma/seed.js +152 -0
  120. package/templates/base-project/packages/db/prisma.config.js +27 -0
  121. package/templates/base-project/packages/db/src/client.js +75 -0
  122. package/templates/base-project/packages/db/src/connection.js +44 -0
  123. package/templates/base-project/packages/db/src/context.js +275 -0
  124. package/templates/base-project/packages/db/src/index.js +5 -0
  125. package/templates/base-project/packages/db/src/ping.js +66 -0
  126. package/templates/base-project/packages/db/src/queries.js +292 -0
  127. package/templates/base-project/packages/db/src/schemas.js +30 -0
  128. package/templates/base-project/packages/db/src/umami.js +299 -0
  129. package/templates/base-project/pnpm-workspace.yaml +20 -0
  130. package/templates/base-project/scripts/check-loading.mjs +49 -0
  131. package/templates/base-project/scripts/check-standards.mjs +186 -0
  132. package/templates/base-project/scripts/clean-workspace.mjs +479 -0
  133. package/templates/base-project/scripts/dev-preflight.mjs +48 -0
  134. package/templates/base-project/scripts/doctor.js +408 -0
  135. package/templates/base-project/scripts/prepare.js +490 -0
  136. package/templates/base-project/scripts/run-tests.mjs +71 -0
  137. package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
  138. package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
  139. package/templates/base-project/skills/add-model/SKILL.md +28 -0
  140. package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
  141. package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
  142. package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
  143. package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
  144. package/templates/base-project/skills/i18n/SKILL.md +211 -0
  145. package/templates/base-project/skills/payment/SKILL.md +280 -0
  146. package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
  147. package/templates/base-project/turbo.json +143 -0
  148. package/templates/config/package.json +20 -0
  149. package/templates/config/src/app-url.js +134 -0
  150. package/templates/config/src/environment.js +284 -0
  151. package/templates/config/src/index.js +16 -0
  152. package/templates/config/src/load-config.js +157 -0
  153. package/templates/config/src/rate-limit.js +136 -0
  154. package/templates/config/src/redis.js +43 -0
  155. package/templates/config/src/validate-env.js +532 -0
  156. package/templates/jobs/README.md +52 -0
  157. package/templates/jobs/package.json +9 -0
  158. package/templates/jobs/src/definitions.js +13 -0
  159. package/templates/jobs/src/index.js +1 -0
  160. package/templates/mobile/.env.example +16 -0
  161. package/templates/mobile/CHECKLIST.md +40 -0
  162. package/templates/mobile/README.md +130 -0
  163. package/templates/mobile/app/(app)/_layout.tsx +83 -0
  164. package/templates/mobile/app/(app)/index.tsx +147 -0
  165. package/templates/mobile/app/(app)/profile.tsx +204 -0
  166. package/templates/mobile/app/(app)/settings.tsx +80 -0
  167. package/templates/mobile/app/(auth)/_layout.tsx +10 -0
  168. package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
  169. package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
  170. package/templates/mobile/app/+not-found.tsx +32 -0
  171. package/templates/mobile/app/_layout.tsx +18 -0
  172. package/templates/mobile/app.json +47 -0
  173. package/templates/mobile/assets/adaptive-icon.png +0 -0
  174. package/templates/mobile/assets/icon.png +0 -0
  175. package/templates/mobile/assets/notification-icon.png +0 -0
  176. package/templates/mobile/assets/splash.png +0 -0
  177. package/templates/mobile/biome.json +27 -0
  178. package/templates/mobile/components/AuthGate.tsx +19 -0
  179. package/templates/mobile/components/ThemeProvider.tsx +23 -0
  180. package/templates/mobile/eas.json +29 -0
  181. package/templates/mobile/hooks/use-auth.ts +127 -0
  182. package/templates/mobile/lib/api-client.ts +138 -0
  183. package/templates/mobile/lib/api.ts +83 -0
  184. package/templates/mobile/lib/auth-providers-google.ts +74 -0
  185. package/templates/mobile/lib/auth-providers.ts +74 -0
  186. package/templates/mobile/lib/auth.ts +100 -0
  187. package/templates/mobile/lib/config.ts +14 -0
  188. package/templates/mobile/lib/errors.ts +46 -0
  189. package/templates/mobile/lib/notifications.ts +77 -0
  190. package/templates/mobile/lib/storage.ts +25 -0
  191. package/templates/mobile/lib/theme-tokens.ts +16 -0
  192. package/templates/mobile/package.json +37 -0
  193. package/templates/mobile/tsconfig.json +10 -0
  194. package/templates/pwa/app/_components/PWARegister.js +15 -0
  195. package/templates/pwa/app/manifest.js +32 -0
  196. package/templates/pwa/public/sw.js +72 -0
  197. package/templates/ui/README.md +44 -0
  198. package/templates/ui/package.json +21 -0
  199. package/templates/ui/src/badge.js +23 -0
  200. package/templates/ui/src/button.js +111 -0
  201. package/templates/ui/src/card.js +128 -0
  202. package/templates/ui/src/checkbox.js +20 -0
  203. package/templates/ui/src/container.js +13 -0
  204. package/templates/ui/src/dialog.js +137 -0
  205. package/templates/ui/src/error-banner.js +15 -0
  206. package/templates/ui/src/footer.js +239 -0
  207. package/templates/ui/src/form-field.integration-test.js +142 -0
  208. package/templates/ui/src/form-field.js +50 -0
  209. package/templates/ui/src/index.js +24 -0
  210. package/templates/ui/src/input.js +11 -0
  211. package/templates/ui/src/label.js +14 -0
  212. package/templates/ui/src/lightbox.integration-test.js +167 -0
  213. package/templates/ui/src/lightbox.js +243 -0
  214. package/templates/ui/src/logo.js +52 -0
  215. package/templates/ui/src/navbar.js +602 -0
  216. package/templates/ui/src/password-input.js +31 -0
  217. package/templates/ui/src/rich-text.js +514 -0
  218. package/templates/ui/src/select.js +317 -0
  219. package/templates/ui/src/skeleton.js +10 -0
  220. package/templates/ui/src/spinner.js +31 -0
  221. package/templates/ui/src/table.js +169 -0
  222. package/templates/ui/src/textarea.js +11 -0
  223. package/templates/ui/src/theme-constants.js +24 -0
  224. package/templates/ui/src/theme.js +196 -0
  225. package/templates/ui/src/toast.js +206 -0
  226. package/templates/worker/Dockerfile +64 -0
  227. package/templates/worker/README.md +56 -0
  228. package/templates/worker/package.json +36 -0
  229. package/templates/worker/src/config/pricing.js +46 -0
  230. package/templates/worker/src/handlers/email-job-validation.js +36 -0
  231. package/templates/worker/src/handlers/email.js +98 -0
  232. package/templates/worker/src/handlers/files.js +59 -0
  233. package/templates/worker/src/handlers/index.js +20 -0
  234. package/templates/worker/src/handlers/push-notification.js +234 -0
  235. package/templates/worker/src/index.js +505 -0
  236. package/templates/worker/src/lib/railway.js +438 -0
  237. package/templates/worker/src/lib/tokens.js +81 -0
@@ -0,0 +1,318 @@
1
+ // biome-ignore-all lint/suspicious/noTemplateCurlyInString: this file exists to
2
+ // exercise Railway's own `${{Service.VAR}}` template syntax and the escaper that
3
+ // neutralises a literal `${}`. Both are plain strings by design, and the rule
4
+ // cannot tell them apart from a missed template interpolation.
5
+ import assert from "node:assert/strict";
6
+ import fs from "node:fs/promises";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { after, test } from "node:test";
10
+
11
+ const temporaryDirectories = [];
12
+
13
+ after(async () => {
14
+ await Promise.all(
15
+ temporaryDirectories.map((dir) =>
16
+ fs.rm(dir, { recursive: true, force: true }),
17
+ ),
18
+ );
19
+ });
20
+
21
+ async function makeTempDir() {
22
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "railway-test-"));
23
+ temporaryDirectories.push(dir);
24
+ return dir;
25
+ }
26
+
27
+ // --- Mock execa ---
28
+ // We inject a mock by rewriting the module's execa reference via
29
+ // import.meta mocking. Since node:test doesn't support that natively,
30
+ // we test the adapter functions by providing our own execa replacement.
31
+ // The module imports execa from "execa"; for testing we verify behavior
32
+ // by calling internal helpers with mocked execa.
33
+
34
+ // Instead, test the CLI wrapper functions using a utility that replaces execa.
35
+ // We'll test the public interface by calling the real functions against
36
+ // a fixture directory and testing the helper functions.
37
+
38
+ test("checkRailwayCLI returns null when railway is not installed", async () => {
39
+ const { checkRailwayCLI } = await import("./railway.js");
40
+ // On a system without railway, this returns null.
41
+ // On a system with railway, it returns a version string.
42
+ // We just verify it returns either null or a non-empty string.
43
+ const result = await checkRailwayCLI();
44
+ if (result === null) {
45
+ assert.equal(result, null);
46
+ } else {
47
+ assert.ok(typeof result === "string" && result.length > 0);
48
+ }
49
+ });
50
+
51
+ test("checkRailwayLogin returns null when not logged in", async () => {
52
+ const { checkRailwayLogin } = await import("./railway.js");
53
+ const result = await checkRailwayLogin();
54
+ if (result === null) {
55
+ assert.equal(result, null);
56
+ } else {
57
+ assert.ok(typeof result === "string" && result.length > 0);
58
+ }
59
+ });
60
+
61
+ test("isProjectLinked returns false when .railway directory does not exist", async () => {
62
+ const { isProjectLinked } = await import("./railway.js");
63
+ const tmpDir = await makeTempDir();
64
+ const result = await isProjectLinked(tmpDir);
65
+ assert.equal(result, false);
66
+ });
67
+
68
+ test("isProjectLinked returns true when .railway directory contains config", async () => {
69
+ const { isProjectLinked } = await import("./railway.js");
70
+ const tmpDir = await makeTempDir();
71
+ const railwayDir = path.join(tmpDir, ".railway");
72
+ await fs.mkdir(railwayDir, { recursive: true });
73
+ await fs.writeFile(
74
+ path.join(railwayDir, "config.json"),
75
+ JSON.stringify({ projectId: "test" }),
76
+ );
77
+ const result = await isProjectLinked(tmpDir);
78
+ assert.equal(result, true);
79
+ });
80
+
81
+ test("isProjectLinked returns false when .railway directory is empty", async () => {
82
+ const { isProjectLinked } = await import("./railway.js");
83
+ const tmpDir = await makeTempDir();
84
+ const railwayDir = path.join(tmpDir, ".railway");
85
+ await fs.mkdir(railwayDir, { recursive: true });
86
+ const result = await isProjectLinked(tmpDir);
87
+ assert.equal(result, false);
88
+ });
89
+
90
+ test("RailwayError has correct shape", async () => {
91
+ const { RailwayError, DIAGNOSTIC_CODES } = await import("./railway.js");
92
+ const error = new RailwayError(
93
+ "test error",
94
+ DIAGNOSTIC_CODES.RAILWAY_CLI_NOT_FOUND,
95
+ { detail: "x" },
96
+ );
97
+ assert.equal(error.name, "RailwayError");
98
+ assert.equal(error.code, DIAGNOSTIC_CODES.RAILWAY_CLI_NOT_FOUND);
99
+ assert.equal(error.message, "test error");
100
+ assert.deepEqual(error.meta, { detail: "x" });
101
+ });
102
+
103
+ test("DIAGNOSTIC_CODES are frozen", async () => {
104
+ const { DIAGNOSTIC_CODES } = await import("./railway.js");
105
+ assert.throws(() => {
106
+ DIAGNOSTIC_CODES.NEW_CODE = "test";
107
+ });
108
+ });
109
+
110
+ // --- IaC adapter tests ---
111
+
112
+ test("generateIacFile creates .railway/railway.ts with preserve() for secrets", async () => {
113
+ const { generateIacFile } = await import("./iac.js");
114
+ const tmpDir = await makeTempDir();
115
+ const iacPath = path.join(tmpDir, ".railway", "railway.ts");
116
+
117
+ await generateIacFile({
118
+ iacPath,
119
+ services: [
120
+ { name: "web", kind: "web", relativeRootDir: "apps/web" },
121
+ { name: "worker", kind: "worker", relativeRootDir: "apps/worker" },
122
+ ],
123
+ secrets: {
124
+ AUTH_SECRET: "preserve()",
125
+ NEXTAUTH_SECRET: "preserve()",
126
+ },
127
+ variableRefs: {
128
+ DATABASE_URL: "${{Postgres.DATABASE_URL}}",
129
+ REDIS_URL: "${{Redis.REDIS_URL}}",
130
+ },
131
+ serviceVars: {
132
+ worker: { WORKER_CONCURRENCY: '"5"' },
133
+ },
134
+ projectName: "test-project",
135
+ });
136
+
137
+ const content = await fs.readFile(iacPath, "utf8");
138
+ assert.ok(content.includes("import { defineRailway"));
139
+ assert.ok(content.includes('service("web"'));
140
+ assert.ok(content.includes('service("worker"'));
141
+ assert.ok(content.includes("preserve()"));
142
+ assert.ok(content.includes("${{Postgres.DATABASE_URL}}"));
143
+ assert.ok(content.includes("${{Redis.REDIS_URL}}"));
144
+ assert.ok(content.includes("pnpm db:migrate:deploy"));
145
+ assert.ok(content.includes("/api/health"));
146
+ assert.ok(content.includes("WORKER_CONCURRENCY"));
147
+ assert.ok(content.includes('"test-project"'));
148
+ // Must NOT contain github() source — CLI deploys manage settings, not sources
149
+ assert.ok(
150
+ !content.includes("github("),
151
+ "Generated IaC must not reference github()",
152
+ );
153
+ assert.ok(
154
+ !content.includes("source:"),
155
+ "Generated IaC must not include source field",
156
+ );
157
+ });
158
+
159
+ test("escapeTsString escapes dangerous characters", async () => {
160
+ const { escapeTsString } = await import("./iac.js");
161
+
162
+ assert.strictEqual(escapeTsString('hello"world'), 'hello\\"world');
163
+ assert.strictEqual(escapeTsString("back\\slash"), "back\\\\slash");
164
+ assert.strictEqual(escapeTsString("line\nbreak"), "line\\nbreak");
165
+ assert.strictEqual(escapeTsString("dollar${}"), "dollar\\${}");
166
+ assert.strictEqual(escapeTsString("cr\rreturn"), "cr\\rreturn");
167
+ assert.strictEqual(escapeTsString("safe-value"), "safe-value");
168
+ });
169
+
170
+ test("generateIacFile escapes service names with special chars", async () => {
171
+ const { generateIacFile } = await import("./iac.js");
172
+ const tmpDir = await makeTempDir();
173
+ const iacPath = path.join(tmpDir, ".railway", "railway.ts");
174
+
175
+ await generateIacFile({
176
+ iacPath,
177
+ services: [
178
+ { name: 'my "web" app', kind: "web", relativeRootDir: "apps/web" },
179
+ ],
180
+ projectName: 'project "name"',
181
+ });
182
+
183
+ const content = await fs.readFile(iacPath, "utf8");
184
+ // Variable name uses sanitized identifier (underscores, not escaped quotes)
185
+ assert.ok(content.includes('const my_web_app = service("my \\"web\\" app"'));
186
+ // Project name is escaped in string, not double-escaped
187
+ assert.ok(content.includes('project("project \\"name\\""'));
188
+ // Resources list uses identifiers
189
+ assert.ok(content.includes("resources: [my_web_app]"));
190
+ });
191
+
192
+ // --- Environment flag forwarding tests ---
193
+ // These use a mock railway CLI on PATH to verify --environment is
194
+ // forwarded to execa for each function that supports it.
195
+
196
+ async function withMockRailway(fn) {
197
+ const tmpDir = await makeTempDir();
198
+ const railwayPath = path.join(tmpDir, "railway");
199
+ const argsLog = path.join(tmpDir, "args.log");
200
+
201
+ await fs.writeFile(
202
+ railwayPath,
203
+ `#!/bin/sh
204
+ echo "$*" >> "${argsLog}"
205
+ case "$*" in
206
+ *"deployment list"*)
207
+ echo '[{"id":"dep_mock_123","status":"SUCCESS"}]'
208
+ ;;
209
+ *"list"*)
210
+ echo '[]'
211
+ ;;
212
+ *)
213
+ echo '{"deploymentId":"dep_mock_123"}'
214
+ ;;
215
+ esac
216
+ `,
217
+ );
218
+ await fs.chmod(railwayPath, 0o755);
219
+
220
+ const origPath = process.env.PATH;
221
+ process.env.PATH = `${tmpDir}:${origPath}`;
222
+
223
+ try {
224
+ await fn(tmpDir, argsLog);
225
+ } finally {
226
+ process.env.PATH = origPath;
227
+ }
228
+ }
229
+
230
+ test("deployService forwards --environment flag", async () => {
231
+ await withMockRailway(async (tmpDir, argsLog) => {
232
+ const { deployService } = await import("./railway.js");
233
+
234
+ await deployService("web", { cwd: tmpDir, environment: "staging" });
235
+
236
+ const log = await fs.readFile(argsLog, "utf-8");
237
+ assert.ok(
238
+ log.includes("--environment"),
239
+ `Expected --environment in args: ${log}`,
240
+ );
241
+ assert.ok(log.includes("staging"), `Expected staging in args: ${log}`);
242
+ });
243
+ });
244
+
245
+ test("setProjectVariable forwards --environment flag", async () => {
246
+ await withMockRailway(async (tmpDir, argsLog) => {
247
+ const { setProjectVariable } = await import("./railway.js");
248
+
249
+ await setProjectVariable("KEY", "val", {
250
+ cwd: tmpDir,
251
+ environment: "staging",
252
+ serviceName: "web",
253
+ });
254
+
255
+ const log = await fs.readFile(argsLog, "utf-8");
256
+ assert.ok(
257
+ log.includes("--environment"),
258
+ `Expected --environment in args: ${log}`,
259
+ );
260
+ assert.ok(log.includes("staging"), `Expected staging in args: ${log}`);
261
+ });
262
+ });
263
+
264
+ test("setProjectVariables forwards --environment flag", async () => {
265
+ await withMockRailway(async (tmpDir, argsLog) => {
266
+ const { setProjectVariables } = await import("./railway.js");
267
+
268
+ await setProjectVariables(
269
+ [
270
+ { key: "A", value: "1" },
271
+ { key: "B", value: "2" },
272
+ ],
273
+ { cwd: tmpDir, environment: "staging", serviceName: "web" },
274
+ );
275
+
276
+ const log = await fs.readFile(argsLog, "utf-8");
277
+ assert.ok(
278
+ log.includes("--environment"),
279
+ `Expected --environment in args: ${log}`,
280
+ );
281
+ assert.ok(log.includes("staging"), `Expected staging in args: ${log}`);
282
+ });
283
+ });
284
+
285
+ test("getExistingVariable forwards --environment flag", async () => {
286
+ await withMockRailway(async (tmpDir, argsLog) => {
287
+ const { getExistingVariable } = await import("./railway.js");
288
+
289
+ // Mock returns empty JSON array for variable list
290
+ await getExistingVariable("AUTH_SECRET", {
291
+ cwd: tmpDir,
292
+ environment: "staging",
293
+ serviceName: "web",
294
+ });
295
+
296
+ const log = await fs.readFile(argsLog, "utf-8");
297
+ assert.ok(
298
+ log.includes("--environment"),
299
+ `Expected --environment in args: ${log}`,
300
+ );
301
+ assert.ok(log.includes("staging"), `Expected staging in args: ${log}`);
302
+ });
303
+ });
304
+
305
+ test("deleteService forwards --environment flag", async () => {
306
+ await withMockRailway(async (tmpDir, argsLog) => {
307
+ const { deleteService } = await import("./railway.js");
308
+
309
+ await deleteService("web", { cwd: tmpDir, environment: "staging" });
310
+
311
+ const log = await fs.readFile(argsLog, "utf-8");
312
+ assert.ok(
313
+ log.includes("--environment"),
314
+ `Expected --environment in args: ${log}`,
315
+ );
316
+ assert.ok(log.includes("staging"), `Expected staging in args: ${log}`);
317
+ });
318
+ });
@@ -0,0 +1,59 @@
1
+ export const QUARK_DEPLOY_PROJECT_KIND = "quark";
2
+
3
+ export const QUARK_SERVICE_KINDS = Object.freeze({
4
+ WEB: "web",
5
+ WORKER: "worker",
6
+ OPENCODE: "opencode",
7
+ });
8
+
9
+ const webServiceContract = Object.freeze({
10
+ kind: QUARK_SERVICE_KINDS.WEB,
11
+ name: "web",
12
+ required: true,
13
+ relativeRootDir: "apps/web",
14
+ relativePackageJsonPath: "apps/web/package.json",
15
+ relativeIacPath: ".railway/railway.ts",
16
+ runtime: Object.freeze({
17
+ type: "node",
18
+ relativeEntrypoint: "apps/web/.next/standalone/apps/web/server.js",
19
+ healthcheckPath: "/api/health",
20
+ }),
21
+ });
22
+
23
+ const workerServiceContract = Object.freeze({
24
+ kind: QUARK_SERVICE_KINDS.WORKER,
25
+ name: "worker",
26
+ required: false,
27
+ relativeRootDir: "apps/worker",
28
+ relativePackageJsonPath: "apps/worker/package.json",
29
+ relativeIacPath: ".railway/railway.ts",
30
+ runtime: Object.freeze({
31
+ type: "node",
32
+ relativeEntrypoint: "apps/worker/src/index.js",
33
+ }),
34
+ });
35
+
36
+ const opencodeServiceContract = Object.freeze({
37
+ kind: QUARK_SERVICE_KINDS.OPENCODE,
38
+ name: "opencode",
39
+ required: false,
40
+ relativeRootDir: "apps/opencode",
41
+ relativePackageJsonPath: "apps/opencode/package.json",
42
+ relativeIacPath: ".railway/railway.ts",
43
+ runtime: Object.freeze({
44
+ type: "node",
45
+ relativeEntrypoint: "apps/opencode/Dockerfile",
46
+ }),
47
+ });
48
+
49
+ export const QUARK_SERVICE_CONTRACTS = Object.freeze({
50
+ [QUARK_SERVICE_KINDS.WEB]: webServiceContract,
51
+ [QUARK_SERVICE_KINDS.WORKER]: workerServiceContract,
52
+ [QUARK_SERVICE_KINDS.OPENCODE]: opencodeServiceContract,
53
+ });
54
+
55
+ export const SUPPORTED_QUARK_SERVICE_KINDS = Object.freeze([
56
+ QUARK_SERVICE_KINDS.WEB,
57
+ QUARK_SERVICE_KINDS.WORKER,
58
+ QUARK_SERVICE_KINDS.OPENCODE,
59
+ ]);
@@ -0,0 +1,183 @@
1
+ import assert from "node:assert/strict";
2
+ import { execSync } from "node:child_process";
3
+ import fs from "node:fs/promises";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { after, test } from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
+ const temporaryDirectories = [];
11
+
12
+ after(async () => {
13
+ await Promise.all(
14
+ temporaryDirectories.map((dir) =>
15
+ fs.rm(dir, { recursive: true, force: true, maxRetries: 3 }),
16
+ ),
17
+ );
18
+ });
19
+
20
+ async function makeTempDir() {
21
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), "deploy-int-"));
22
+ temporaryDirectories.push(dir);
23
+ return dir;
24
+ }
25
+
26
+ async function writeJson(filePath, value) {
27
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
28
+ await fs.writeFile(filePath, JSON.stringify(value, null, "\t"));
29
+ }
30
+
31
+ /**
32
+ * Create a fixture that looks like a Quark project with web + worker.
33
+ */
34
+ async function createQuarkFixture() {
35
+ const projectDir = await makeTempDir();
36
+
37
+ await writeJson(path.join(projectDir, "package.json"), {
38
+ name: "@test/quark-app",
39
+ private: true,
40
+ type: "module",
41
+ });
42
+
43
+ await writeJson(path.join(projectDir, "apps", "web", "package.json"), {
44
+ name: "@test/quark-web",
45
+ private: true,
46
+ type: "module",
47
+ });
48
+
49
+ await writeJson(path.join(projectDir, "apps", "worker", "package.json"), {
50
+ name: "@test/quark-worker",
51
+ private: true,
52
+ type: "module",
53
+ });
54
+
55
+ return projectDir;
56
+ }
57
+
58
+ function runQuarkCli(args) {
59
+ const cliEntry = path.join(__dirname, "../../src/index.js");
60
+ try {
61
+ const result = execSync(
62
+ `${process.execPath} ${cliEntry} ${args.join(" ")}`,
63
+ {
64
+ encoding: "utf8",
65
+ timeout: 10_000,
66
+ stdio: ["ignore", "pipe", "pipe"],
67
+ },
68
+ );
69
+ return result;
70
+ } catch (error) {
71
+ // execSync throws on non-zero exit — return stdout if available
72
+ if (error.stdout) return error.stdout.toString("utf8");
73
+ throw error;
74
+ }
75
+ }
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Level 2: Integration - deployToRailway with missing Railway CLI
79
+ // ---------------------------------------------------------------------------
80
+
81
+ test("checkRailwayCLI returns null when railway is not on PATH", async () => {
82
+ const { checkRailwayCLI } = await import("./adapters/railway.js");
83
+ const origPath = process.env.PATH;
84
+ process.env.PATH = "/dev/null";
85
+ try {
86
+ const result = await checkRailwayCLI();
87
+ assert.equal(result, null);
88
+ } finally {
89
+ process.env.PATH = origPath;
90
+ }
91
+ });
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Level 2: Integration - adapter functions with mocked project
95
+ // ---------------------------------------------------------------------------
96
+
97
+ test("isProjectLinked returns false for unlinked project", async () => {
98
+ const { isProjectLinked } = await import("./adapters/railway.js");
99
+ const projectDir = await createQuarkFixture();
100
+ const result = await isProjectLinked(projectDir);
101
+ assert.equal(result, false);
102
+ });
103
+
104
+ test("isProjectLinked returns true after creating .railway directory with config", async () => {
105
+ const { isProjectLinked } = await import("./adapters/railway.js");
106
+ const projectDir = await createQuarkFixture();
107
+ const railwayDir = path.join(projectDir, ".railway");
108
+ await fs.mkdir(railwayDir, { recursive: true });
109
+ await fs.writeFile(
110
+ path.join(railwayDir, "config.json"),
111
+ JSON.stringify({ projectId: "test-123" }),
112
+ );
113
+ const result = await isProjectLinked(projectDir);
114
+ assert.equal(result, true);
115
+ });
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // Level 2: Integration - resolveQuarkDeployProject with fixtures
119
+ // ---------------------------------------------------------------------------
120
+
121
+ test("resolveQuarkDeployProject discovers web + worker in fixture", async () => {
122
+ const { resolveQuarkDeployProject } = await import("./discovery.js");
123
+ const projectDir = await createQuarkFixture();
124
+ const discovery = await resolveQuarkDeployProject(projectDir);
125
+
126
+ assert.equal(discovery.kind, "quark");
127
+ assert.equal(discovery.services.length, 2);
128
+ assert.equal(discovery.services[0].kind, "web");
129
+ assert.equal(discovery.services[1].kind, "worker");
130
+ });
131
+
132
+ test("resolveQuarkDeployProject fails when web is missing", async () => {
133
+ const { resolveQuarkDeployProject } = await import("./discovery.js");
134
+ const projectDir = await makeTempDir();
135
+
136
+ await writeJson(path.join(projectDir, "package.json"), {
137
+ name: "@test/quark-app",
138
+ private: true,
139
+ });
140
+
141
+ await assert.rejects(() => resolveQuarkDeployProject(projectDir), {
142
+ code: "QUARK_DEPLOY_DISCOVERY_FAILED",
143
+ });
144
+ });
145
+
146
+ // ---------------------------------------------------------------------------
147
+ // Level 2: Integration - inspectProject with fixture
148
+ // ---------------------------------------------------------------------------
149
+
150
+ test("inspectProject loads and inspects fixture without error", async () => {
151
+ const { inspectProject } = await import("./inspect.js");
152
+ const projectDir = await createQuarkFixture();
153
+
154
+ // Suppress stdout — inspectProject prints a report via console.log, and
155
+ // unstructured text on the test runner's stdout pipe can corrupt the
156
+ // binary v8-serialized result frames (nodejs/node#64061), flaking CI.
157
+ await inspectProject({ cwd: projectDir, output: () => {} });
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Level 2: CLI command routing
162
+ // ---------------------------------------------------------------------------
163
+
164
+ test("quark deploy --help shows subcommands", () => {
165
+ const stdout = runQuarkCli(["deploy", "--help"]);
166
+
167
+ assert.match(stdout, /railway/);
168
+ assert.match(stdout, /inspect/);
169
+ });
170
+
171
+ test("quark deploy railway --help shows options", () => {
172
+ const stdout = runQuarkCli(["deploy", "railway", "--help"]);
173
+
174
+ assert.match(stdout, /project-name/);
175
+ assert.match(stdout, /project-id/);
176
+ assert.match(stdout, /dry-run/);
177
+ });
178
+
179
+ test("quark deploy inspect --help shows help", () => {
180
+ const stdout = runQuarkCli(["deploy", "inspect", "--help"]);
181
+
182
+ assert.ok(stdout.length > 0);
183
+ });