@usequark/quark-create-app 1.25.4 → 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.4",
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",
@@ -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
 
@@ -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",