@usequark/quark-create-app 1.25.3 → 1.25.5

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.5",
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) ---
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import {
4
4
  QUARK_DEPLOY_PROJECT_KIND,
5
5
  QUARK_SERVICE_CONTRACTS,
6
+ QUARK_SERVICE_KINDS,
6
7
  SUPPORTED_QUARK_SERVICE_KINDS,
7
8
  } from "./contract.js";
8
9
 
@@ -10,6 +11,17 @@ export const QUARK_DEPLOY_DIAGNOSTIC_CODES = Object.freeze({
10
11
  MISSING_REQUIRED_SERVICE: "missing_required_service",
11
12
  });
12
13
 
14
+ /**
15
+ * Codes for conditions that do not block a deploy but are worth saying out loud.
16
+ *
17
+ * Kept separate from DIAGNOSTIC_CODES because `resolveQuarkDeployProject` throws on
18
+ * any diagnostic, and a worker-less project is a legitimate choice — `jobs` is an
19
+ * optional scaffold feature. Failing the deploy would punish a valid configuration.
20
+ */
21
+ export const QUARK_DEPLOY_WARNING_CODES = Object.freeze({
22
+ MISSING_WORKER_SERVICE: "missing_worker_service",
23
+ });
24
+
13
25
  function resolveProjectPath(projectDir, relativePath) {
14
26
  return path.join(projectDir, ...relativePath.split("/"));
15
27
  }
@@ -37,6 +49,17 @@ function createMissingRequiredServiceDiagnostic(serviceContract) {
37
49
  };
38
50
  }
39
51
 
52
+ function createMissingWorkerWarning() {
53
+ return {
54
+ code: QUARK_DEPLOY_WARNING_CODES.MISSING_WORKER_SERVICE,
55
+ serviceKind: "worker",
56
+ required: false,
57
+ expectedPath: QUARK_SERVICE_CONTRACTS.worker.relativePackageJsonPath,
58
+ message:
59
+ "No Quark worker service found. Scheduled jobs will not run — including CLEANUP_ORPHANED_FILES, which is the only thing that sweeps the File rows left orphaned when a user is deleted (onDelete: SetNull keeps the rows and the blobs). Those rows and blobs accumulate indefinitely. Add the jobs feature (`npx @usequark/quark-create-app add jobs`) if this project uploads files.",
60
+ };
61
+ }
62
+
40
63
  function createDiscoveredService(projectDir, serviceContract, packageJson) {
41
64
  return {
42
65
  kind: serviceContract.kind,
@@ -68,6 +91,9 @@ export function formatQuarkDeployDiagnostics(diagnostics) {
68
91
  export async function discoverQuarkDeployProject(projectDir) {
69
92
  const services = [];
70
93
  const diagnostics = [];
94
+ const warnings = [];
95
+ let sawWeb = false;
96
+ let sawWorker = false;
71
97
 
72
98
  for (const serviceKind of SUPPORTED_QUARK_SERVICE_KINDS) {
73
99
  const serviceContract = QUARK_SERVICE_CONTRACTS[serviceKind];
@@ -85,16 +111,26 @@ export async function discoverQuarkDeployProject(projectDir) {
85
111
  continue;
86
112
  }
87
113
 
114
+ if (serviceKind === QUARK_SERVICE_KINDS.WEB) sawWeb = true;
115
+ if (serviceKind === QUARK_SERVICE_KINDS.WORKER) sawWorker = true;
116
+
88
117
  services.push(
89
118
  createDiscoveredService(projectDir, serviceContract, packageJson),
90
119
  );
91
120
  }
92
121
 
122
+ // A web project with no worker is valid — `jobs` is optional — but the orphaned
123
+ // file cleanup only runs there, so say so rather than letting it fail silently.
124
+ if (sawWeb && !sawWorker) {
125
+ warnings.push(createMissingWorkerWarning());
126
+ }
127
+
93
128
  return {
94
129
  kind: QUARK_DEPLOY_PROJECT_KIND,
95
130
  rootDir: projectDir,
96
131
  services,
97
132
  diagnostics,
133
+ warnings,
98
134
  };
99
135
  }
100
136
 
@@ -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";
@@ -45,13 +45,23 @@ export async function inspectProject(options = {}) {
45
45
 
46
46
  // --- Diagnostics ---
47
47
  if (discovery.diagnostics.length > 0) {
48
- output(chalk.yellow(" Diagnostics\n"));
48
+ output(chalk.yellow(" Diagnostics"));
49
49
  for (const diagnostic of discovery.diagnostics) {
50
50
  output(` ${chalk.yellow("⚠")} ${diagnostic.message}`);
51
51
  }
52
52
  output();
53
53
  }
54
54
 
55
+ // --- Warnings ---
56
+ // Non-blocking, so printed after diagnostics and separately from them.
57
+ if (discovery.warnings.length > 0) {
58
+ output(chalk.yellow(" Warnings"));
59
+ for (const warning of discovery.warnings) {
60
+ output(` ${chalk.yellow("⚠")} ${warning.message}`);
61
+ }
62
+ output();
63
+ }
64
+
55
65
  // --- Railway readiness ---
56
66
  output(chalk.cyan(" Railway Readiness\n"));
57
67
 
package/src/index.js CHANGED
@@ -10,6 +10,7 @@ import { execa } from "execa";
10
10
  import fs from "fs-extra";
11
11
  import prompts from "prompts";
12
12
  import { SCAFFOLD_GITIGNORE } from "./scaffold-gitignore.js";
13
+ import { warnIfJobsOmitted } from "./scaffold-warning.js";
13
14
  import { findEnclosingGitRepo, formatProjectDisplayName } from "./utils.js";
14
15
 
15
16
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -611,7 +612,7 @@ program
611
612
  )
612
613
  .option(
613
614
  "--packages <packages>",
614
- "Comma-separated list of optional packages to include (ui,jobs,pwa)",
615
+ "Comma-separated optional packages to include (ui,jobs,pwa). db, config, ui always scaffolded. Mobile is post-creation: quark add mobile",
615
616
  )
616
617
  .option(
617
618
  "--prompt <prompt>",
@@ -952,6 +953,8 @@ program
952
953
  ` Selected packages: ${features.join(", ") || "none"} (non-interactive mode)`,
953
954
  ),
954
955
  );
956
+
957
+ warnIfJobsOmitted(features);
955
958
  } else if (!options.prompts) {
956
959
  // Use defaults when --no-prompts is set without --packages
957
960
  console.log(chalk.cyan("\n 🎯 Configuring optional packages..."));
@@ -1014,6 +1017,8 @@ program
1014
1017
 
1015
1018
  features = resolveFeatureSelection(selectedFeatures);
1016
1019
 
1020
+ warnIfJobsOmitted(features);
1021
+
1017
1022
  const requestedFeatures = [...selectedFeatures];
1018
1023
  const autoIncluded = features.filter(
1019
1024
  (feature) => !requestedFeatures.includes(feature),
@@ -0,0 +1,39 @@
1
+ import chalk from "chalk";
2
+
3
+ /**
4
+ * Warn when the `jobs` feature was not selected for a scaffold.
5
+ *
6
+ * `File.uploadedBy` is `onDelete: SetNull`, so deleting a user orphans their file
7
+ * rows rather than removing them. `CLEANUP_ORPHANED_FILES` is the only thing that
8
+ * sweeps those rows and their blobs, and it runs in the worker — which is scaffolded
9
+ * only alongside `jobs`. Declining `jobs` therefore leaves orphaned rows and blobs
10
+ * accumulating indefinitely, with nothing reading `uploadedById = null` to surface
11
+ * them.
12
+ *
13
+ * Said at the point of the decision rather than discovered in production. It is a
14
+ * warning and not a refusal: `jobs` is optional and a project with no uploads has no
15
+ * orphan problem.
16
+ *
17
+ * @param {string[]} features - Resolved feature list for the scaffold.
18
+ * @returns {boolean} Whether the warning was printed.
19
+ */
20
+ export function warnIfJobsOmitted(features, log = console.log) {
21
+ if (features.includes("jobs")) return false;
22
+
23
+ log(
24
+ chalk.yellow(
25
+ ` ⚠ Background jobs omitted: the orphaned-file cleanup will not exist.`,
26
+ ),
27
+ );
28
+ log(
29
+ chalk.dim(
30
+ ` Deleting a user leaves their uploaded files orphaned in storage — the schema\n keeps the rows rather than removing them — and without a worker nothing sweeps them.`,
31
+ ),
32
+ );
33
+ log(
34
+ chalk.dim(
35
+ ` Add it later with: npx @usequark/quark-create-app add jobs\n`,
36
+ ),
37
+ );
38
+ return true;
39
+ }
@@ -25,7 +25,7 @@
25
25
  "@usequark/quark-ui": "workspace:*",
26
26
  "ioredis": "^6.0.0",
27
27
  "jose": "^6.2.12",
28
- "next": "16.3.6",
28
+ "next": "16.3.8",
29
29
  "next-auth": "5.0.0-beta.32",
30
30
  "pg": "^8.23.0",
31
31
  "react": "19.3.0",
@@ -1,4 +1,8 @@
1
- import { validateBody, withCsrfProtection } from "@usequark/quark-core";
1
+ import {
2
+ ConflictError,
3
+ validateBody,
4
+ withCsrfProtection,
5
+ } from "@usequark/quark-core";
2
6
  import { user, userUpdateSchema } from "@usequark/quark-db";
3
7
  import { NextResponse } from "next/server";
4
8
  import { requireRole } from "@/lib/auth-middleware";
@@ -36,11 +40,33 @@ export const PATCH = withCsrfProtection(async (request, { params }) => {
36
40
  }
37
41
  });
38
42
 
43
+ /**
44
+ * DELETE /api/users/[id]
45
+ * Delete a user. Admin only.
46
+ *
47
+ * Self-deletion is refused. The route resolves its target purely from the path
48
+ * segment and otherwise only checks the caller's role, so an admin could delete
49
+ * their own row — and if they were the last admin, leave the deployment with no
50
+ * way to administer it. Refusing here rather than warning is deliberate: the
51
+ * request cannot succeed without leaving either the caller or the deployment
52
+ * locked out, so there is no outcome the caller would want.
53
+ *
54
+ * This guard is per-request, not a check on the remaining admin count. Two
55
+ * concurrent deletes of the two last admins can still both pass it. Enforcing
56
+ * that invariant needs a transaction around the count and the delete, which is a
57
+ * larger change than this endpoint's current shape supports.
58
+ */
39
59
  export const DELETE = withCsrfProtection(async (_request, { params }) => {
40
60
  try {
41
- await requireRole("admin");
61
+ const session = await requireRole("admin");
42
62
  const { id } = await params;
43
63
 
64
+ if (session?.user?.id === id) {
65
+ throw new ConflictError(
66
+ "You cannot delete the account you are signed in as",
67
+ );
68
+ }
69
+
44
70
  const existingUser = await user.findById(id);
45
71
  if (!existingUser) {
46
72
  return NextResponse.json({ message: "User not found" }, { status: 404 });
@@ -101,7 +101,8 @@ CREATE TABLE "File" (
101
101
  -- CreateTable
102
102
  CREATE TABLE "AuditLog" (
103
103
  "id" TEXT NOT NULL,
104
- "userId" TEXT NOT NULL,
104
+ "userId" TEXT,
105
+ "actorEmail" TEXT,
105
106
  "action" TEXT NOT NULL,
106
107
  "entity" TEXT NOT NULL,
107
108
  "entityId" TEXT NOT NULL,
@@ -198,5 +199,5 @@ ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId"
198
199
  ALTER TABLE "File" ADD CONSTRAINT "File_uploadedById_fkey" FOREIGN KEY ("uploadedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
199
200
 
200
201
  -- AddForeignKey
201
- ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
202
+ ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
202
203
 
@@ -141,18 +141,28 @@ model File {
141
141
  }
142
142
 
143
143
  // Audit Log Model
144
+ //
145
+ // `userId` is nullable and SetNull, not Cascade. An audit record that is deleted
146
+ // along with the user it describes cannot serve as an audit trail — and deleting a
147
+ // user destroys the rows recording what that user did, including the rows recording
148
+ // the deletion itself.
149
+ //
150
+ // `actorEmail` snapshots the actor's address at write time. With SetNull alone the
151
+ // row survives but stops naming who acted, so a trail that outlives its actor would
152
+ // be unattributable. The snapshot keeps the record readable on its own terms.
144
153
  model AuditLog {
145
- id String @id @default(cuid())
146
- userId String
147
- action String
148
- entity String
149
- entityId String
150
- changes Json?
151
- metadata Json?
152
- createdAt DateTime @default(now())
153
- updatedAt DateTime @updatedAt
154
-
155
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
154
+ id String @id @default(cuid())
155
+ userId String?
156
+ actorEmail String?
157
+ action String
158
+ entity String
159
+ entityId String
160
+ changes Json?
161
+ metadata Json?
162
+ createdAt DateTime @default(now())
163
+ updatedAt DateTime @updatedAt
164
+
165
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
156
166
 
157
167
  @@index([userId])
158
168
  @@index([action])
@@ -15,6 +15,22 @@ const USER_SAFE_SELECT = {
15
15
  updatedAt: true,
16
16
  };
17
17
 
18
+ /**
19
+ * Look up an actor's email for the AuditLog snapshot.
20
+ *
21
+ * The lookup cannot miss: the foreign key rejects a `userId` that names no user,
22
+ * so `onDelete: SetNull` is the only way `userId` becomes null, and it happens after
23
+ * the snapshot was written. A null `userId` here means the caller supplied none.
24
+ */
25
+ async function resolveActorEmail(userId) {
26
+ if (!userId) return null;
27
+ const actor = await prisma.user.findUnique({
28
+ where: { id: userId },
29
+ select: { email: true },
30
+ });
31
+ return actor?.email ?? null;
32
+ }
33
+
18
34
  export { USER_SAFE_SELECT };
19
35
 
20
36
  // User queries
@@ -188,13 +204,21 @@ export const verificationToken = {
188
204
  };
189
205
 
190
206
  // AuditLog queries
207
+ //
208
+ // `user` is optional: a deleted user leaves `userId` null but the row survives
209
+ // (see the AuditLog model). Every read therefore returns `actorEmail` alongside,
210
+ // so a caller can name the actor without dereferencing `user`. Render `actorEmail`
211
+ // — `user` is for callers that need the live row, e.g. to follow through to the
212
+ // user's current role.
213
+ const AUDIT_ACTOR_SELECT = { id: true, email: true, name: true };
214
+
191
215
  export const auditLog = {
192
216
  findAll: (options = {}) => {
193
217
  const { skip = 0, take = 50 } = options;
194
218
  return prisma.auditLog.findMany({
195
219
  skip,
196
220
  take,
197
- include: { user: { select: { id: true, email: true, name: true } } },
221
+ include: { user: { select: AUDIT_ACTOR_SELECT } },
198
222
  orderBy: { createdAt: "desc" },
199
223
  });
200
224
  },
@@ -204,7 +228,7 @@ export const auditLog = {
204
228
  where: { userId },
205
229
  skip,
206
230
  take,
207
- include: { user: { select: { id: true, email: true, name: true } } },
231
+ include: { user: { select: AUDIT_ACTOR_SELECT } },
208
232
  orderBy: { createdAt: "desc" },
209
233
  });
210
234
  },
@@ -214,7 +238,7 @@ export const auditLog = {
214
238
  where: { entity },
215
239
  skip,
216
240
  take,
217
- include: { user: { select: { id: true, email: true, name: true } } },
241
+ include: { user: { select: AUDIT_ACTOR_SELECT } },
218
242
  orderBy: { createdAt: "desc" },
219
243
  });
220
244
  },
@@ -224,14 +248,25 @@ export const auditLog = {
224
248
  where: { action },
225
249
  skip,
226
250
  take,
227
- include: { user: { select: { id: true, email: true, name: true } } },
251
+ include: { user: { select: AUDIT_ACTOR_SELECT } },
228
252
  orderBy: { createdAt: "desc" },
229
253
  });
230
254
  },
231
- create: (data) => {
255
+ /**
256
+ * Create an audit record, snapshotting the actor's email.
257
+ *
258
+ * The snapshot is written here rather than left to the caller because it is the
259
+ * only thing that keeps the record attributable once the user row is gone:
260
+ * `onDelete: SetNull` preserves the row but clears `userId`. A caller that
261
+ * forgets the field gets a permanent record naming nobody, so this derives it
262
+ * from `userId` when not supplied.
263
+ */
264
+ create: async (data) => {
265
+ const actorEmail =
266
+ data.actorEmail ?? (await resolveActorEmail(data.userId));
232
267
  return prisma.auditLog.create({
233
- data,
234
- include: { user: { select: { id: true, email: true, name: true } } },
268
+ data: { ...data, actorEmail },
269
+ include: { user: { select: AUDIT_ACTOR_SELECT } },
235
270
  });
236
271
  },
237
272
  };
@@ -11,6 +11,31 @@ onlyBuiltDependencies:
11
11
  - simple-git-hooks
12
12
 
13
13
  overrides:
14
+ # next-auth@5.0.0-beta.32 and @auth/core@0.41.3 declare nodemailer as an
15
+ # OPTIONAL peer with the range `^7.0.7 || ^8.0.5`. Every advisory against
16
+ # nodemailer needs >=10.0.6, so no version inside that peer range is
17
+ # patched, and neither package has shipped a newer release that widens it.
18
+ #
19
+ # Nothing in this repo uses Auth.js's Nodemailer email provider: auth.js
20
+ # configures Credentials, Github and Google only, and @auth/core's index
21
+ # never imports nodemailer statically (only providers/nodemailer.js does).
22
+ # Outgoing mail goes through @usequark/quark-core, which depends on
23
+ # nodemailer ^10.0.10 directly. Removing the unused peer edge deletes the
24
+ # vulnerable 7.0.13 copy from the tree instead of forcing it two majors past
25
+ # a range its author chose. Delete these two lines if you ever add
26
+ # next-auth/providers/nodemailer.
27
+ "next-auth>nodemailer": "-"
28
+ "@auth/core>nodemailer": "-"
29
+ # stripe@17.7.0 declares `qs: ^6.11.0`, so 6.16.0 is inside its own range and
30
+ # `pnpm update` cannot move it (qs is not a direct dependency of any workspace
31
+ # package). GHSA-g4mx-9h6g-cwc7 / GHSA-hrpp-f998-fjpq both need >=6.16.0.
32
+ qs@>=6.14.2 <=6.15.3: ">=6.16.0"
33
+ # xcode@3.0.1 declares `uuid: ^7.0.3`; GHSA-w5hq-g745-h8pq needs >=11.1.1,
34
+ # four majors ahead. Safe here because xcode only calls uuid.v4() (lib/
35
+ # pbxProject.js), which is unchanged in 11.x, and 11.1.1 still supports
36
+ # CommonJS require(). This is build-time only - xcode is reached through
37
+ # @expo/config-plugins while generating native projects.
38
+ uuid@<11.1.1: ">=11.1.1"
14
39
  fast-xml-builder@<1.1.7: ">=1.1.7"
15
40
  fast-xml-parser@<5.10.1: ">=5.10.1"
16
41
  lodash@>=4.0.0 <=4.17.22: ">=4.17.23"
@@ -15,6 +15,51 @@ const tsExtensions = new Set([".ts", ".tsx"]);
15
15
  const throwNewErrorPattern = /\bthrow new Error\s*\(/;
16
16
  const consolePattern = /\bconsole\.(?:log|warn|error|info)\s*\(/;
17
17
 
18
+ /**
19
+ * `next-auth` and `@auth/core` declare nodemailer as an *optional* peer pinned to
20
+ * `^7.0.7 || ^8.0.5`. No version in that range is free of an advisory, so
21
+ * pnpm-workspace.yaml removes the peer edge entirely instead:
22
+ *
23
+ * "next-auth>nodemailer": "-"
24
+ * "@auth/core>nodemailer": "-"
25
+ *
26
+ * That is what clears 13 of the 21 advisories, and it is safe only while nothing
27
+ * imports Auth.js's Nodemailer provider. The failure mode if that changes is bad:
28
+ * `providers/nodemailer.js` does `import { createTransport } from "nodemailer"` at
29
+ * module scope, so the import throws ERR_MODULE_NOT_FOUND on the next server start -
30
+ * not at build time, and not with any hint that an override caused it.
31
+ *
32
+ * This check turns that into a build failure with the fix attached. It fires on the
33
+ * import specifier rather than on the override, so it stays correct if the override
34
+ * is ever reworded, and it covers both the monorepo and the scaffold template.
35
+ *
36
+ * The pattern has to accept every way a module specifier can be reached, because a
37
+ * guard that misses one spelling is worse than none: it reads as protection while
38
+ * letting the breakage through. All of these resolve the provider and so must fail:
39
+ *
40
+ * import N from "next-auth/providers/nodemailer" // static default
41
+ * import "next-auth/providers/nodemailer" // bare side-effect
42
+ * export { default } from "next-auth/providers/nodemailer"
43
+ * await import("next-auth/providers/nodemailer") // dynamic
44
+ * import("next-auth/providers/nodemailer").then(m => m.default)
45
+ * require("next-auth/providers/nodemailer") // CommonJS
46
+ *
47
+ * The optional `\(` is what the first version got wrong: dynamic `import(` puts a
48
+ * paren between the keyword and the quote, so an alternation of
49
+ * `from|import|require\s*\(` matched the static forms and silently missed every
50
+ * dynamic one.
51
+ *
52
+ * Each keyword carries a leading `\b` so an identifier that merely contains one -
53
+ * `important`, `myrequire` - cannot trigger it, and the specifier is anchored so
54
+ * only this exact module matches.
55
+ *
56
+ * If you genuinely want the provider: delete the two overrides above, then add
57
+ * `nodemailer` (>=10.0.6, past every advisory) to the app's own dependencies and
58
+ * allowlist this check for that file.
59
+ */
60
+ const authNodemailerProviderPattern =
61
+ /\b(?:from|import|require)\s*\(?\s*["'`]next-auth\/providers\/nodemailer["'`]/;
62
+
18
63
  /**
19
64
  * Tailwind v4 removed the bare `[--var]` shorthand that v3 accepted. Under v4 a
20
65
  * class such as `text-[--navbar-text-muted]` is still a *valid* utility name, so
@@ -125,6 +170,36 @@ function findMatchingLines(content, pattern) {
125
170
  return matches;
126
171
  }
127
172
 
173
+ /**
174
+ * Same job as findMatchingLines, but matches against the whole file rather than one
175
+ * line at a time, and reports the line each match starts on.
176
+ *
177
+ * Needed for the nodemailer guard, whose pattern legitimately spans a line break:
178
+ * `import Nodemailer from\n "next-auth/providers/nodemailer";` is valid JavaScript
179
+ * that the line-at-a-time version cannot see, because the keyword and the specifier
180
+ * land on different lines. Line-at-a-time is the right default for the other checks -
181
+ * they only ever match within one line - so this is opt-in.
182
+ *
183
+ * The match is retried with a lastIndex-aware scan because a pattern carrying the `g`
184
+ * flag would otherwise return only the first hit; `d` additionally keeps the index
185
+ * pointing at the start of the match rather than at a capture group.
186
+ */
187
+ function findMatchingLinesMultiline(content, pattern) {
188
+ const scanner = new RegExp(
189
+ pattern.source,
190
+ pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`,
191
+ );
192
+ const matches = [];
193
+ let match = scanner.exec(content);
194
+ while (match !== null) {
195
+ matches.push(content.slice(0, match.index).split("\n").length);
196
+ // A zero-width match would otherwise loop forever.
197
+ scanner.lastIndex = match.index + (match[0].length || 1);
198
+ match = scanner.exec(content);
199
+ }
200
+ return matches;
201
+ }
202
+
128
203
  async function collectFiles(directoryPath) {
129
204
  const entries = await readdir(directoryPath, { withFileTypes: true });
130
205
  const files = [];
@@ -213,6 +288,17 @@ async function main() {
213
288
  );
214
289
  }
215
290
  }
291
+
292
+ if (isSourceFile(relativePath)) {
293
+ for (const lineNumber of findMatchingLinesMultiline(
294
+ content,
295
+ authNodemailerProviderPattern,
296
+ )) {
297
+ violations.push(
298
+ `${relativePath}:${lineNumber}: next-auth/providers/nodemailer cannot resolve - pnpm-workspace.yaml removes that peer edge to clear 13 nodemailer advisories, and the peer range (^7.0.7 || ^8.0.5) has no patched version. Delete the two nodemailer overrides and add nodemailer >=10.0.6 as a direct dependency to use this provider.`,
299
+ );
300
+ }
301
+ }
216
302
  }
217
303
 
218
304
  if (violations.length > 0) {
@@ -14,18 +14,13 @@ Every skill below ships with this project and is always available. Skills are ab
14
14
  | add-model | `add-model/SKILL.md` | Add a new Prisma model, query helpers, and tests to the db package. |
15
15
  | add-endpoint | `add-endpoint/SKILL.md` | Add an API route (or Server Action) with Zod validation, CSRF, and role guards. |
16
16
  | add-dashboard | `add-dashboard/SKILL.md` | Add a metrics/overview dashboard page with metric cards and loading states. |
17
- | admin-dashboard | `admin-dashboard/SKILL.md` | Build a full admin/back-office area: guarded routes, CRUD screens, audit logging. |
18
17
 
19
18
  ## Domain starters
20
19
 
21
- These teach the AI to build a complete vertical on demand. No starter code is scaffolded - the AI generates it from the skill.
20
+ These teach the AI to build a complete vertical on demand. No starter code is scaffolded; the AI generates it from the skill.
22
21
 
23
22
  | Skill | Path | Purpose |
24
23
  |---|---|---|
25
- | bookings | `bookings/SKILL.md` | Booking/reservation/appointment/scheduling systems. |
26
- | crm | `crm/SKILL.md` | Contact/pipeline/customer relationship systems. |
27
- | cms | `cms/SKILL.md` | Content modeling, authoring, and publishing systems. |
28
- | ai | `ai/SKILL.md` | AI assistant features (chat, completions) on app data. |
29
24
  | payment | `payment/SKILL.md` | Stripe payment integration, checkout, webhooks, fulfillment. |
30
25
  | ecommerce | `ecommerce/SKILL.md` | Product catalog, cart, checkout, orders, inventory. Sub-skills: `ecommerce-catalog`, `ecommerce-cart`, `ecommerce-checkout`. |
31
26
  | i18n | `i18n/SKILL.md` | Internationalization, locale routing, localized CMS content, hreflang SEO. |
@@ -7,7 +7,8 @@
7
7
  "npm_lifecycle_event",
8
8
  "QUARK_SKIP_AUTO_CLEAN",
9
9
  "QUARK_SKIP_DEV_PREFLIGHT",
10
- "QUARK_SKIP_GIT_INIT"
10
+ "QUARK_SKIP_GIT_INIT",
11
+ "CHECK_AUDIT_FIXTURE"
11
12
  ],
12
13
  "tasks": {
13
14
  "clean": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "dependencies": {
11
11
  "lucide-react": "^1.48.0",
12
- "next": "16.3.6"
12
+ "next": "16.3.8"
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/react": "^19.3.0",