@usequark/quark-create-app 1.25.3 → 1.25.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.25.3",
3
+ "version": "1.25.4",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -36,6 +36,9 @@
36
36
  "templates",
37
37
  "README.md"
38
38
  ],
39
+ "devDependencies": {
40
+ "esbuild": "^0.28.2"
41
+ },
39
42
  "dependencies": {
40
43
  "chalk": "^6.0.0",
41
44
  "commander": "^15.0.0",
@@ -25,6 +25,25 @@ const IAC_HELPERS = [
25
25
  "volume",
26
26
  ];
27
27
 
28
+ /**
29
+ * Builds a Railway variable reference as a TypeScript *string literal*.
30
+ *
31
+ * Railway's `${{Service.VAR}}` syntax is only reference syntax inside a quoted
32
+ * string. Emitted bare it is not a valid expression — `${{` parses as an object
33
+ * literal that opens and never closes — so the file fails to parse before
34
+ * Railway ever sees it. The value must therefore carry its own quotes.
35
+ *
36
+ * Only for values embedded in generated TypeScript. A value passed to
37
+ * `railway variable set` on a command line wants the bare, unquoted form.
38
+ *
39
+ * @param {string} serviceName - Railway service the variable lives on
40
+ * @param {string} variableName - Variable name on that service
41
+ * @returns {string} A quoted reference, e.g. `"${{Postgres.DATABASE_URL}}"`
42
+ */
43
+ export function iacRef(serviceName, variableName) {
44
+ return `"$\{{${serviceName}.${variableName}}}"`;
45
+ }
46
+
28
47
  /**
29
48
  * Escapes a string for safe embedding in a TypeScript string literal.
30
49
  * Handles backslashes, quotes, newlines, and template literal interpolation.
@@ -76,6 +95,9 @@ function toIdentifier(value) {
76
95
  * @param {object} options.variableRefs - Map of variable key → Railway reference string
77
96
  * @param {object} options.serviceVars - Map of service name → extra env vars
78
97
  * @param {string} [options.projectName] - Railway project name (defaults to "quark-app")
98
+ * @returns {Promise<{content: string, changed: boolean, previous: string|null}>}
99
+ * `changed` is false when the file on disk already matched, in which case no
100
+ * write happened. `previous` is the content that was replaced, or null.
79
101
  */
80
102
  export async function generateIacFile({
81
103
  iacPath,
@@ -108,13 +130,24 @@ export async function generateIacFile({
108
130
 
109
131
  const preDeploy = "pnpm db:migrate:deploy";
110
132
 
133
+ // Each entry is one complete `key: value` field, and the object body is
134
+ // rendered by joining them. Concatenating pre-rendered blocks instead
135
+ // means each block has to guess whether it owns the separator between it
136
+ // and its neighbour — and when two blocks both assume they do, the file
137
+ // ships with `healthcheckTimeout: 120,,`, which does not parse. A field
138
+ // list makes that unrepresentable: there is exactly one join.
139
+ const fields = [
140
+ `build: "${escapeTsString(buildCmd)}"`,
141
+ `start: "${escapeTsString(startCmd)}"`,
142
+ `preDeploy: "${escapeTsString(preDeploy)}"`,
143
+ ];
144
+
111
145
  // Healthcheck (web only)
112
- const healthcheck =
113
- svc.kind === "web"
114
- ? `\n\t\thealthcheck: "/api/health",\n\t\thealthcheckTimeout: 120,`
115
- : "";
146
+ if (svc.kind === "web") {
147
+ fields.push('healthcheck: "/api/health"', "healthcheckTimeout: 120");
148
+ }
116
149
 
117
- // Build env vars — escape all string values
150
+ // Env vars — escape all string values
118
151
  const extraVars = serviceVars[svc.name] || {};
119
152
  const envEntries = [
120
153
  ...Object.entries(variableRefs),
@@ -122,16 +155,16 @@ export async function generateIacFile({
122
155
  ...Object.entries(extraVars),
123
156
  ];
124
157
 
125
- const envBlock =
126
- envEntries.length > 0
127
- ? `,\n\t\tenv: {\n${envEntries.map(([k, v]) => `\t\t\t${escapeTsString(k)}: ${v},`).join("\n")}\n\t\t}`
128
- : "";
158
+ if (envEntries.length > 0) {
159
+ const envLines = envEntries
160
+ .map(([k, v]) => `\t\t\t${escapeTsString(k)}: ${v},`)
161
+ .join("\n");
162
+ fields.push(`env: {\n${envLines}\n\t\t}`);
163
+ }
129
164
 
130
- resourceLines.push(`\tconst ${svcIdentifier} = service("${svcLabel}", {
131
- \t\tbuild: "${escapeTsString(buildCmd)}",
132
- \t\tstart: "${escapeTsString(startCmd)}",
133
- \t\tpreDeploy: "${escapeTsString(preDeploy)}",${healthcheck}${envBlock}
134
- \t});`);
165
+ resourceLines.push(
166
+ `\tconst ${svcIdentifier} = service("${svcLabel}", {\n\t\t${fields.join(",\n\t\t")},\n\t});`,
167
+ );
135
168
  }
136
169
 
137
170
  const resources = services.map((s) => toIdentifier(s.name)).join(", ");
@@ -156,7 +189,27 @@ ${resourceLines.join("\n\n")}
156
189
  ${body}`;
157
190
 
158
191
  await fs.ensureDir(dirname(iacPath));
159
- await fs.writeFile(iacPath, content, "utf8");
192
+
193
+ // `.railway/railway.ts` is a tracked file that every scaffold ships with a
194
+ // hand-written copy of. Overwriting it silently is how a deploy once
195
+ // replaced a committed file with generated output and the user only found
196
+ // out from `git status` afterwards. So: skip the write when nothing changed,
197
+ // and hand the previous content back when something did, so the caller can
198
+ // show the diff. The file is tracked, so `git checkout` remains the escape
199
+ // hatch — this only makes the change visible at the moment it happens.
200
+ let previous = null;
201
+ try {
202
+ previous = await fs.readFile(iacPath, "utf8");
203
+ } catch {
204
+ previous = null;
205
+ }
206
+
207
+ const changed = previous !== content;
208
+ if (changed) {
209
+ await fs.writeFile(iacPath, content, "utf8");
210
+ }
211
+
212
+ return { content, changed, previous };
160
213
  }
161
214
 
162
215
  /**
@@ -181,18 +234,68 @@ export async function applyIacConfig({ cwd, timeout = 120_000 } = {}) {
181
234
  }
182
235
  }
183
236
 
237
+ /**
238
+ * Checks whether the `railway` package already resolves from `cwd`.
239
+ *
240
+ * Walks up from `cwd` the way Node's resolver does, so a hoisted install in a
241
+ * pnpm workspace root is found from a package directory too.
242
+ *
243
+ * @param {object} options
244
+ * @param {string} options.cwd - Project root directory
245
+ * @returns {Promise<boolean>}
246
+ */
247
+ export async function hasRailwaySdk({ cwd } = {}) {
248
+ const { default: fs } = await import("fs-extra");
249
+ const { dirname, join, parse, resolve } = await import("node:path");
250
+
251
+ const root = parse(resolve(cwd)).root;
252
+ let dir = resolve(cwd);
253
+
254
+ while (dir !== root) {
255
+ if (
256
+ await fs.pathExists(join(dir, "node_modules", "railway", "package.json"))
257
+ ) {
258
+ return true;
259
+ }
260
+ dir = dirname(dir);
261
+ }
262
+
263
+ return false;
264
+ }
265
+
184
266
  /**
185
267
  * Installs the Railway SDK (required for TypeScript IaC evaluation).
186
268
  *
269
+ * No-op when the package already resolves, so a repeat deploy does not touch
270
+ * package.json or the lockfile again.
271
+ *
272
+ * `-w` is passed explicitly because every scaffolded project is a pnpm
273
+ * workspace and the SDK is added at the root. Current pnpm versions accept the
274
+ * bare form, but older ones reject it with `ERR_PNPM_ADDING_TO_ROOT` and name
275
+ * the same command as the remedy, so being explicit is the only version this
276
+ * has to reason about.
277
+ *
187
278
  * @param {object} options
188
279
  * @param {string} options.cwd - Project root directory
189
280
  * @param {string} [options.packageManager="pnpm"] - Package manager to use
281
+ * @returns {Promise<{installed: boolean, alreadyPresent: boolean}>}
190
282
  */
191
283
  export async function installRailwaySdk({ cwd, packageManager = "pnpm" } = {}) {
192
- await execa(packageManager, ["add", "-D", "railway"], {
284
+ if (await hasRailwaySdk({ cwd })) {
285
+ return { installed: false, alreadyPresent: true };
286
+ }
287
+
288
+ const args = ["add", "-D"];
289
+ if (packageManager === "pnpm") {
290
+ args.push("-w");
291
+ }
292
+ args.push("railway");
293
+
294
+ await execa(packageManager, args, {
193
295
  cwd,
194
296
  timeout: 60_000,
195
297
  });
298
+ return { installed: true, alreadyPresent: false };
196
299
  }
197
300
 
198
301
  /**
@@ -3,6 +3,8 @@ export {
3
3
  escapeTsString,
4
4
  generateIacFile,
5
5
  getDeploymentStatus,
6
+ hasRailwaySdk,
7
+ iacRef,
6
8
  installRailwaySdk,
7
9
  } from "./iac.js";
8
10
  export {
@@ -21,7 +23,9 @@ export {
21
23
  listProjects,
22
24
  RAILWAY,
23
25
  RailwayError,
26
+ readLinkedProject,
24
27
  removeServiceDomain,
28
+ resolveProjectName,
25
29
  setProjectVariables,
26
30
  tryLinkProject,
27
31
  } from "./railway.js";
@@ -42,13 +42,8 @@ export async function checkRailwayLogin() {
42
42
  }
43
43
 
44
44
  export async function isProjectLinked(cwd) {
45
- const railwayDir = path.join(cwd, ".railway");
46
- try {
47
- const entries = await fs.readdir(railwayDir);
48
- return entries.some((e) => e.endsWith(".json"));
49
- } catch {
50
- return false;
51
- }
45
+ const { projectId } = await readLinkedProject({ cwd });
46
+ return projectId !== null;
52
47
  }
53
48
 
54
49
  export async function tryLinkProject(projectId, { cwd } = {}) {
@@ -76,6 +71,124 @@ export async function listProjects({ cwd } = {}) {
76
71
  }
77
72
  }
78
73
 
74
+ /**
75
+ * Reads the Railway project this directory is linked to.
76
+ *
77
+ * Two layouts are in play, because the CLI moved:
78
+ *
79
+ * - Railway CLI 3.x wrote `.railway/config.json` inside the project directory,
80
+ * holding only `projectId` — no name to be found in it.
81
+ * - Railway CLI 5.x (verified against 5.62.1) keeps every link in
82
+ * `~/.railway/config.json`, in a `projects` map keyed by absolute directory,
83
+ * with `project` (the id) and `name` alongside it. A linked project
84
+ * directory contains `.railway/railway.ts` and nothing else.
85
+ *
86
+ * Both are read, per-project first. The `~/.railway` file also holds the user's
87
+ * OAuth tokens; only the `projects` map is read and nothing else from it is
88
+ * ever returned or logged.
89
+ *
90
+ * @param {object} options
91
+ * @param {string} options.cwd - Project root directory
92
+ * @returns {Promise<{projectId: string|null, projectName: string|null}>}
93
+ */
94
+ export async function readLinkedProject({ cwd } = {}) {
95
+ const fromProjectDir = await readProjectDirLink(cwd);
96
+ if (fromProjectDir.projectId) return fromProjectDir;
97
+
98
+ return readUserConfigLink(cwd);
99
+ }
100
+
101
+ /** Railway CLI 3.x layout: `.railway/config.json` in the project directory. */
102
+ async function readProjectDirLink(cwd) {
103
+ const railwayDir = path.join(cwd, ".railway");
104
+ try {
105
+ const entries = await fs.readdir(railwayDir);
106
+ for (const entry of entries.filter((e) => e.endsWith(".json"))) {
107
+ try {
108
+ const parsed = JSON.parse(
109
+ await fs.readFile(path.join(railwayDir, entry), "utf8"),
110
+ );
111
+ const projectId = parsed?.projectId ?? null;
112
+ const projectName = parsed?.projectName ?? parsed?.name ?? null;
113
+ if (projectId || projectName) return { projectId, projectName };
114
+ } catch {
115
+ // Not the file we were after, or unparseable — try the next.
116
+ }
117
+ }
118
+ } catch {
119
+ // No .railway directory, or not readable.
120
+ }
121
+ return { projectId: null, projectName: null };
122
+ }
123
+
124
+ /**
125
+ * Railway CLI 5.x layout: `~/.railway/config.json`, `projects` keyed by
126
+ * absolute directory path.
127
+ */
128
+ async function readUserConfigLink(cwd) {
129
+ const empty = { projectId: null, projectName: null };
130
+
131
+ const home = process.env.HOME ?? process.env.USERPROFILE;
132
+ if (!home) return empty;
133
+
134
+ try {
135
+ const parsed = JSON.parse(
136
+ await fs.readFile(path.join(home, ".railway", "config.json"), "utf8"),
137
+ );
138
+ const projects = parsed?.projects;
139
+ if (!projects || typeof projects !== "object") return empty;
140
+
141
+ const target = path.resolve(cwd);
142
+ const entry =
143
+ projects[target] ??
144
+ Object.values(projects).find(
145
+ (v) =>
146
+ typeof v?.projectPath === "string" &&
147
+ path.resolve(v.projectPath) === target,
148
+ );
149
+ if (!entry) return empty;
150
+
151
+ return {
152
+ projectId: entry.project ?? null,
153
+ projectName: entry.name ?? null,
154
+ };
155
+ } catch {
156
+ // No user config, unparseable, or unreadable — treat as unlinked.
157
+ return empty;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Resolves a project id to its name via the API, for when the link record
163
+ * carries only an id.
164
+ *
165
+ * @param {string} projectId
166
+ * @param {object} options
167
+ * @param {string} options.cwd - Project root directory
168
+ * @returns {Promise<string|null>}
169
+ */
170
+ export async function resolveProjectName(projectId, { cwd } = {}) {
171
+ if (!projectId) return null;
172
+ const match = (await listProjects({ cwd })).find((p) => p.id === projectId);
173
+ return match?.name ?? null;
174
+ }
175
+
176
+ /**
177
+ * Best-effort name for a linked project: the link record when it carries one,
178
+ * otherwise resolved from its id.
179
+ *
180
+ * @param {object} options
181
+ * @param {string} options.cwd - Project root directory
182
+ * @returns {Promise<{projectId: string|null, projectName: string|null}>}
183
+ */
184
+ async function linkedProjectWithName({ cwd }) {
185
+ const linked = await readLinkedProject({ cwd });
186
+ if (linked.projectName || !linked.projectId) return linked;
187
+
188
+ const projectName = await resolveProjectName(linked.projectId, { cwd });
189
+ return { projectId: linked.projectId, projectName };
190
+ }
191
+
79
192
  export async function ensureRailwayProject({
80
193
  projectName,
81
194
  projectId,
@@ -86,11 +199,26 @@ export async function ensureRailwayProject({
86
199
  if (alreadyLinked) {
87
200
  try {
88
201
  await execa(RAILWAY, ["status"], { cwd, timeout: 15_000 });
89
- return { created: false, linked: true, projectName: null };
202
+ // Report the linked name rather than null: callers label generated
203
+ // IaC and app metadata from it, and falling back to the local
204
+ // directory name is how a project called `quark-site` ended up
205
+ // advertising itself as whatever directory it was deployed from.
206
+ const linked = await linkedProjectWithName({ cwd });
207
+ return {
208
+ created: false,
209
+ linked: true,
210
+ projectName: linked.projectName,
211
+ };
90
212
  } catch {
91
- // Stale link - clean up so we don't create an orphan project
92
- const railwayDir = path.join(cwd, ".railway");
93
- await fs.rm(railwayDir, { recursive: true, force: true });
213
+ // Stale link - clear it so we don't create an orphan project.
214
+ // `railway unlink` is what actually removes the link; it lives in
215
+ // ~/.railway on CLI 5.x, so deleting .railway/ here would only
216
+ // destroy the IaC file while leaving the stale link in place.
217
+ try {
218
+ await execa(RAILWAY, ["unlink"], { cwd, timeout: 15_000 });
219
+ } catch {
220
+ // Nothing to unlink, or it failed — fall through either way.
221
+ }
94
222
  }
95
223
  }
96
224
 
@@ -99,7 +227,8 @@ export async function ensureRailwayProject({
99
227
  cwd,
100
228
  timeout: 30_000,
101
229
  });
102
- return { created: false, linked: true, projectName: null };
230
+ const projectName = await resolveProjectName(projectId, { cwd });
231
+ return { created: false, linked: true, projectName };
103
232
  }
104
233
 
105
234
  if (!projectName) {
@@ -12,6 +12,8 @@ import {
12
12
  getDeploymentStatus,
13
13
  getExistingVariable,
14
14
  getServiceUrl,
15
+ hasRailwaySdk,
16
+ iacRef,
15
17
  installRailwaySdk,
16
18
  removeServiceDomain,
17
19
  setProjectVariables,
@@ -99,13 +101,17 @@ export async function deployToRailway(options = {}) {
99
101
 
100
102
  // --- Step 4: Ensure Railway project ---
101
103
  console.log(chalk.cyan(" Ensuring Railway project..."));
104
+ let resolvedProjectName = projectName || null;
102
105
  try {
103
106
  const project = await ensureRailwayProject({ projectName, projectId, cwd });
107
+ // Prefer the name Railway actually resolved. The local directory name is
108
+ // only a fallback for naming a brand new project — it must not become the
109
+ // project's identity just because the deploy ran from a differently-named
110
+ // checkout.
111
+ resolvedProjectName = project.projectName || resolvedProjectName;
104
112
  if (project.created) {
105
113
  console.log(
106
- chalk.green(
107
- ` ✔ Created Railway project "${project.projectName || projectName || ""}"`,
108
- ),
114
+ chalk.green(` ✔ Created Railway project "${resolvedProjectName}"`),
109
115
  );
110
116
  } else {
111
117
  console.log(chalk.green(" ✔ Railway project linked"));
@@ -141,7 +147,7 @@ export async function deployToRailway(options = {}) {
141
147
  }
142
148
 
143
149
  // --- Step 6: Preserve existing secrets across deploys ---
144
- const projectLabel = path.basename(cwd);
150
+ const projectLabel = resolvedProjectName || path.basename(cwd);
145
151
  let authSecret;
146
152
  let nextAuthSecret;
147
153
 
@@ -170,14 +176,18 @@ export async function deployToRailway(options = {}) {
170
176
  console.log(chalk.cyan(" Generating IaC configuration..."));
171
177
  const iacPath = `${cwd}/.railway/railway.ts`;
172
178
 
173
- await generateIacFile({
179
+ const iacResult = await generateIacFile({
174
180
  iacPath,
175
181
  services: discovery.services,
176
182
  // Pass raw name — generateIacFile handles escaping internally
177
- projectName: projectName || projectLabel,
183
+ projectName: projectLabel,
178
184
  variableRefs: {
179
- DATABASE_URL: `\${{${pgServiceName}.DATABASE_URL}}`,
180
- REDIS_URL: `\${{${redisServiceName}.REDIS_URL}}`,
185
+ // Quoted: these land inside generated TypeScript as object values,
186
+ // where `${{...}}` must be a string literal to parse at all. The
187
+ // equivalent bare form is still used in step 9, where the value is a
188
+ // command-line argument rather than an expression.
189
+ DATABASE_URL: iacRef(pgServiceName, "DATABASE_URL"),
190
+ REDIS_URL: iacRef(redisServiceName, "REDIS_URL"),
181
191
  NODE_ENV: '"production"',
182
192
  APP_NAME: `"${escapeTsString(projectLabel)}"`,
183
193
  APP_DESCRIPTION: `"${escapeTsString(`${projectLabel} - Quark application`)}"`,
@@ -197,23 +207,34 @@ export async function deployToRailway(options = {}) {
197
207
  },
198
208
  },
199
209
  });
210
+ if (iacResult.changed) {
211
+ console.log(
212
+ chalk.yellow(
213
+ " · .railway/railway.ts rewritten — review with `git diff .railway/railway.ts`",
214
+ ),
215
+ );
216
+ }
200
217
  console.log(chalk.green(` ✔ IaC file generated at .railway/railway.ts`));
201
218
 
202
219
  // --- Step 8: Install Railway SDK (required for IaC evaluation) ---
203
- console.log(chalk.cyan(" Installing Railway SDK..."));
204
- try {
205
- await installRailwaySdk({ cwd });
206
- console.log(chalk.green(" ✔ Railway SDK installed"));
207
- } catch (error) {
208
- // SDK is mandatory — railway config apply cannot evaluate .railway/railway.ts without it
209
- console.error(
210
- chalk.red(` ✖ Failed to install Railway SDK: ${error.message}`),
211
- );
212
- throw new Error(
213
- `Railway SDK installation failed. The generated IaC file requires the "railway" package.\n` +
214
- `Install it manually: pnpm add -D railway\n` +
215
- `Original error: ${error.message}`,
216
- );
220
+ if (await hasRailwaySdk({ cwd })) {
221
+ console.log(chalk.dim(" · Railway SDK already installed"));
222
+ } else {
223
+ console.log(chalk.cyan(" Installing Railway SDK..."));
224
+ try {
225
+ await installRailwaySdk({ cwd });
226
+ console.log(chalk.green(" ✔ Railway SDK installed"));
227
+ } catch (error) {
228
+ // SDK is mandatory — railway config apply cannot evaluate .railway/railway.ts without it
229
+ console.error(
230
+ chalk.red(` ✖ Failed to install Railway SDK: ${error.message}`),
231
+ );
232
+ throw new Error(
233
+ `Railway SDK installation failed. The generated IaC file requires the "railway" package.\n` +
234
+ `Install it manually: pnpm add -D -w railway\n` +
235
+ `Original error: ${error.message}`,
236
+ );
237
+ }
217
238
  }
218
239
 
219
240
  // --- Step 9: Set dynamic variable references (infrastructure wiring) ---
@@ -12,13 +12,17 @@ export {
12
12
  getDeploymentStatus,
13
13
  getExistingVariable,
14
14
  getServiceUrl,
15
+ hasRailwaySdk,
16
+ iacRef,
15
17
  installRailwaySdk,
16
18
  isProjectLinked,
17
19
  listProjects,
18
20
  RAILWAY,
19
21
  RAILWAY_DIAGNOSTIC_CODES,
20
22
  RailwayError,
23
+ readLinkedProject,
21
24
  removeServiceDomain,
25
+ resolveProjectName,
22
26
  setProjectVariables,
23
27
  tryLinkProject,
24
28
  } from "./adapters/index.js";