@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 +4 -1
- package/src/deploy/adapters/iac.js +119 -16
- package/src/deploy/adapters/index.js +4 -0
- package/src/deploy/adapters/railway.js +141 -12
- package/src/deploy/deploy.js +43 -22
- package/src/deploy/discovery.js +36 -0
- package/src/deploy/index.js +4 -0
- package/src/deploy/inspect.js +11 -1
- package/src/index.js +6 -1
- package/src/scaffold-warning.js +39 -0
- package/templates/base-project/apps/web/package.json +1 -1
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +28 -2
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +3 -2
- package/templates/base-project/packages/db/prisma/schema.prisma +21 -11
- package/templates/base-project/packages/db/src/queries.js +42 -7
- package/templates/base-project/pnpm-workspace.yaml +25 -0
- package/templates/base-project/scripts/check-standards.mjs +86 -0
- package/templates/base-project/skills/quark-skills/SKILL.md +1 -6
- package/templates/base-project/turbo.json +2 -1
- package/templates/ui/package.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequark/quark-create-app",
|
|
3
|
-
"version": "1.25.
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
: "";
|
|
146
|
+
if (svc.kind === "web") {
|
|
147
|
+
fields.push('healthcheck: "/api/health"', "healthcheckTimeout: 120");
|
|
148
|
+
}
|
|
116
149
|
|
|
117
|
-
//
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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(
|
|
131
|
-
\t\
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
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 -
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
230
|
+
const projectName = await resolveProjectName(projectId, { cwd });
|
|
231
|
+
return { created: false, linked: true, projectName };
|
|
103
232
|
}
|
|
104
233
|
|
|
105
234
|
if (!projectName) {
|
package/src/deploy/deploy.js
CHANGED
|
@@ -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:
|
|
183
|
+
projectName: projectLabel,
|
|
178
184
|
variableRefs: {
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
console.log(chalk.
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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) ---
|
package/src/deploy/discovery.js
CHANGED
|
@@ -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
|
|
package/src/deploy/index.js
CHANGED
|
@@ -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";
|
package/src/deploy/inspect.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
+
}
|
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import {
|
|
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 });
|
package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql
CHANGED
|
@@ -101,7 +101,8 @@ CREATE TABLE "File" (
|
|
|
101
101
|
-- CreateTable
|
|
102
102
|
CREATE TABLE "AuditLog" (
|
|
103
103
|
"id" TEXT NOT NULL,
|
|
104
|
-
"userId" TEXT
|
|
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
|
|
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
|
|
146
|
-
userId
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
251
|
+
include: { user: { select: AUDIT_ACTOR_SELECT } },
|
|
228
252
|
orderBy: { createdAt: "desc" },
|
|
229
253
|
});
|
|
230
254
|
},
|
|
231
|
-
|
|
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:
|
|
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
|
|
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. |
|