@techstream/quark-create-app 1.8.0 → 1.10.0
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/README.md +2 -2
- package/package.json +3 -3
- package/src/index.js +415 -150
- package/src/utils.js +36 -0
- package/src/utils.test.js +63 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.github/copilot-instructions.md +55 -0
- package/templates/base-project/.github/workflows/release.yml +37 -8
- package/templates/base-project/CLAUDE.md +273 -0
- package/templates/base-project/README.md +72 -30
- package/templates/base-project/apps/web/next.config.js +5 -1
- package/templates/base-project/apps/web/package.json +7 -5
- package/templates/base-project/apps/web/public/quark.svg +46 -0
- package/templates/base-project/apps/web/railway.json +2 -2
- package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
- package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
- package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +56 -17
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +53 -0
- package/templates/base-project/apps/web/src/app/globals.css +121 -15
- package/templates/base-project/apps/web/src/app/icon.svg +46 -0
- package/templates/base-project/apps/web/src/app/layout.js +1 -0
- package/templates/base-project/apps/web/src/app/not-found.js +35 -0
- package/templates/base-project/apps/web/src/app/page.js +38 -5
- package/templates/base-project/apps/web/src/lib/theme.js +23 -0
- package/templates/base-project/apps/web/src/proxy.js +10 -2
- package/templates/base-project/package.json +16 -1
- package/templates/base-project/packages/db/package.json +4 -4
- package/templates/base-project/packages/db/src/client.js +6 -1
- package/templates/base-project/packages/db/src/index.js +1 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/scripts/doctor.js +261 -0
- package/templates/base-project/turbo.json +2 -1
- package/templates/config/package.json +1 -0
- package/templates/config/src/index.js +1 -3
- package/templates/config/src/validate-env.js +79 -3
- package/templates/jobs/package.json +2 -1
- package/templates/ui/README.md +67 -0
- package/templates/ui/package.json +1 -0
- package/templates/ui/src/badge.js +32 -0
- package/templates/ui/src/badge.test.js +42 -0
- package/templates/ui/src/button.js +64 -15
- package/templates/ui/src/button.test.js +34 -5
- package/templates/ui/src/card.js +58 -0
- package/templates/ui/src/card.test.js +59 -0
- package/templates/ui/src/checkbox.js +35 -0
- package/templates/ui/src/checkbox.test.js +35 -0
- package/templates/ui/src/dialog.js +139 -0
- package/templates/ui/src/dialog.test.js +15 -0
- package/templates/ui/src/index.js +16 -0
- package/templates/ui/src/input.js +15 -0
- package/templates/ui/src/input.test.js +27 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/label.test.js +22 -0
- package/templates/ui/src/select.js +42 -0
- package/templates/ui/src/select.test.js +27 -0
- package/templates/ui/src/skeleton.js +14 -0
- package/templates/ui/src/skeleton.test.js +22 -0
- package/templates/ui/src/table.js +75 -0
- package/templates/ui/src/table.test.js +69 -0
- package/templates/ui/src/textarea.js +15 -0
- package/templates/ui/src/textarea.test.js +27 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +132 -0
- package/templates/ui/src/toast.js +229 -0
- package/templates/ui/src/toast.test.js +23 -0
- package/templates/{base-project/apps/worker → worker}/package.json +2 -2
- package/templates/{base-project/apps/worker → worker}/src/index.js +38 -23
- package/templates/{base-project/apps/worker → worker}/src/index.test.js +19 -20
- package/templates/base-project/apps/web/public/file.svg +0 -1
- package/templates/base-project/apps/web/public/globe.svg +0 -1
- package/templates/base-project/apps/web/public/next.svg +0 -1
- package/templates/base-project/apps/web/public/vercel.svg +0 -1
- package/templates/base-project/apps/web/public/window.svg +0 -1
- /package/templates/{base-project/apps/worker → worker}/README.md +0 -0
- /package/templates/{base-project/apps/worker → worker}/railway.json +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/email.js +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/files.js +0 -0
- /package/templates/{base-project/apps/worker → worker}/src/handlers/index.js +0 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* scripts/doctor.js
|
|
4
|
+
*
|
|
5
|
+
* Audits this project for unfinished post-scaffold customisation.
|
|
6
|
+
* Run it at any time — it is safe, read-only by default.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* pnpm doctor # Audit only
|
|
10
|
+
* pnpm doctor:fix # Audit + auto-remove Quark aesthetic scaffolding
|
|
11
|
+
*
|
|
12
|
+
* Extend the CHECKS array below to add your own project-specific rules.
|
|
13
|
+
* This script has zero external dependencies.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const ROOT = path.resolve(__dirname, "..");
|
|
22
|
+
const FIX = process.argv.includes("--fix");
|
|
23
|
+
|
|
24
|
+
// ─── ANSI helpers ─────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const c = {
|
|
27
|
+
reset: "\x1b[0m",
|
|
28
|
+
bold: "\x1b[1m",
|
|
29
|
+
dim: "\x1b[2m",
|
|
30
|
+
red: "\x1b[31m",
|
|
31
|
+
green: "\x1b[32m",
|
|
32
|
+
yellow: "\x1b[33m",
|
|
33
|
+
blue: "\x1b[34m",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const fmt = {
|
|
37
|
+
bold: (s) => `${c.bold}${s}${c.reset}`,
|
|
38
|
+
dim: (s) => `${c.dim}${s}${c.reset}`,
|
|
39
|
+
red: (s) => `${c.red}${s}${c.reset}`,
|
|
40
|
+
green: (s) => `${c.green}${s}${c.reset}`,
|
|
41
|
+
yellow: (s) => `${c.yellow}${s}${c.reset}`,
|
|
42
|
+
blue: (s) => `${c.blue}${s}${c.reset}`,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
function exists(rel) {
|
|
48
|
+
return fs.existsSync(path.join(ROOT, rel));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function read(rel) {
|
|
52
|
+
const abs = path.join(ROOT, rel);
|
|
53
|
+
return fs.existsSync(abs) ? fs.readFileSync(abs, "utf-8") : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Remove a file or directory tree (relative to ROOT). */
|
|
57
|
+
function remove(rel) {
|
|
58
|
+
const abs = path.join(ROOT, rel);
|
|
59
|
+
fs.rmSync(abs, { recursive: true, force: true });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Finding model ────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @typedef {{ category: string, status: 'error'|'warn'|'info', key: string,
|
|
66
|
+
* message: string, detail: string, fix: string, fixable: boolean }} Finding
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/** @type {Finding[]} */
|
|
70
|
+
const findings = [];
|
|
71
|
+
|
|
72
|
+
function warn(key, category, message, detail, fix, fixable = false) {
|
|
73
|
+
findings.push({ category, status: "warn", key, message, detail, fix, fixable });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function error(key, category, message, detail, fix, fixable = false) {
|
|
77
|
+
findings.push({ category, status: "error", key, message, detail, fix, fixable });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function info(key, category, message, detail, fix) {
|
|
81
|
+
findings.push({ category, status: "info", key, message, detail, fix, fixable: false });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Checks ───────────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
// Read .quark-link.json to understand what packages were included at scaffold time.
|
|
87
|
+
const quarkLink = (() => {
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(read(".quark-link.json") ?? "{}");
|
|
90
|
+
} catch {
|
|
91
|
+
return {};
|
|
92
|
+
}
|
|
93
|
+
})();
|
|
94
|
+
const hasUI = Array.isArray(quarkLink.packages) && quarkLink.packages.includes("ui");
|
|
95
|
+
|
|
96
|
+
// ── Check 1: Quark Animation still present ────────────────────────────────────
|
|
97
|
+
if (exists("apps/web/src/app/_components/QuarkAnimation.js")) {
|
|
98
|
+
warn(
|
|
99
|
+
"quark-animation",
|
|
100
|
+
"branding",
|
|
101
|
+
"QuarkAnimation is still in the project",
|
|
102
|
+
"apps/web/src/app/_components/QuarkAnimation.js",
|
|
103
|
+
"Replace or remove the animation and update the home page with your own hero content",
|
|
104
|
+
true,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Check 2: Playground page present (only relevant with UI package) ──────────
|
|
109
|
+
if (hasUI && exists("apps/web/src/app/playground")) {
|
|
110
|
+
warn(
|
|
111
|
+
"playground",
|
|
112
|
+
"branding",
|
|
113
|
+
"Playground page is still present",
|
|
114
|
+
"apps/web/src/app/playground/",
|
|
115
|
+
"Consider removing the playground before going to production",
|
|
116
|
+
true,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Check 3: APP_NAME / APP_DESCRIPTION still reference "Quark" ───────────────
|
|
121
|
+
const envContent = read(".env");
|
|
122
|
+
if (envContent) {
|
|
123
|
+
const nameMatch = envContent.match(/^APP_NAME=(.+)$/m);
|
|
124
|
+
const descMatch = envContent.match(/^APP_DESCRIPTION=(.+)$/m);
|
|
125
|
+
const nameVal = nameMatch?.[1]?.trim() ?? "";
|
|
126
|
+
const descVal = descMatch?.[1]?.trim() ?? "";
|
|
127
|
+
|
|
128
|
+
if (/\bquark\b/i.test(nameVal) || /\bquark\b/i.test(descVal)) {
|
|
129
|
+
warn(
|
|
130
|
+
"app-identity",
|
|
131
|
+
"metadata",
|
|
132
|
+
'APP_NAME or APP_DESCRIPTION still references "Quark"',
|
|
133
|
+
`.env → APP_NAME="${nameVal}", APP_DESCRIPTION="${descVal}"`,
|
|
134
|
+
"Update APP_NAME and APP_DESCRIPTION in your .env to match your project",
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── Check 4: Placeholder secrets (CHANGE_ME) left in .env ────────────────────
|
|
140
|
+
if (envContent && envContent.includes("CHANGE_ME")) {
|
|
141
|
+
const lines = envContent
|
|
142
|
+
.split("\n")
|
|
143
|
+
.map((l, i) => ({ line: i + 1, text: l }))
|
|
144
|
+
.filter(({ text }) => text.includes("CHANGE_ME"))
|
|
145
|
+
.map(({ line, text }) => ` line ${line}: ${text.split("=")[0]}`);
|
|
146
|
+
|
|
147
|
+
error(
|
|
148
|
+
"secrets",
|
|
149
|
+
"security",
|
|
150
|
+
"CHANGE_ME placeholders found in .env — rotate these before deploying",
|
|
151
|
+
lines.join("\n"),
|
|
152
|
+
"Replace every CHANGE_ME value with a real secret",
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Check 5: .env missing vars that exist in .env.example ────────────────────
|
|
157
|
+
const exampleContent = read(".env.example");
|
|
158
|
+
if (envContent && exampleContent) {
|
|
159
|
+
const defined = new Set(
|
|
160
|
+
envContent
|
|
161
|
+
.split("\n")
|
|
162
|
+
.filter((l) => l.includes("=") && !l.startsWith("#"))
|
|
163
|
+
.map((l) => l.split("=")[0].trim()),
|
|
164
|
+
);
|
|
165
|
+
const missing = exampleContent
|
|
166
|
+
.split("\n")
|
|
167
|
+
.filter((l) => l.includes("=") && !l.startsWith("#"))
|
|
168
|
+
.map((l) => l.split("=")[0].trim())
|
|
169
|
+
.filter((k) => k && !defined.has(k));
|
|
170
|
+
|
|
171
|
+
if (missing.length > 0) {
|
|
172
|
+
warn(
|
|
173
|
+
"env-missing",
|
|
174
|
+
"configuration",
|
|
175
|
+
`.env.example defines ${missing.length} key(s) not present in .env`,
|
|
176
|
+
missing.map((k) => ` ${k}`).join("\n"),
|
|
177
|
+
"Add the missing keys to your .env (copy from .env.example and fill in values)",
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ── Check 6: README still contains Quark template content ────────────────────
|
|
183
|
+
const readmeContent = read("README.md");
|
|
184
|
+
if (readmeContent && /quark/i.test(readmeContent)) {
|
|
185
|
+
info(
|
|
186
|
+
"readme",
|
|
187
|
+
"documentation",
|
|
188
|
+
"README.md still contains references to Quark",
|
|
189
|
+
"README.md",
|
|
190
|
+
"Update the README to describe your own project",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── --fix: auto-remove fixable items ────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
const STATUS_ICON = { error: "✗", warn: "⚠", info: "·" };
|
|
197
|
+
const STATUS_COLOR = { error: fmt.red, warn: fmt.yellow, info: fmt.dim };
|
|
198
|
+
|
|
199
|
+
// Print the report first so users see what was found before anything is changed.
|
|
200
|
+
console.log(fmt.bold(fmt.blue("\n🩺 Quark Doctor\n")));
|
|
201
|
+
|
|
202
|
+
if (findings.length === 0) {
|
|
203
|
+
console.log(fmt.green(" ✓ Nothing to do — project looks clean!\n"));
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Group by category
|
|
208
|
+
const categories = [...new Set(findings.map((f) => f.category))];
|
|
209
|
+
for (const cat of categories) {
|
|
210
|
+
console.log(fmt.bold(` ${cat}`));
|
|
211
|
+
for (const f of findings.filter((f) => f.category === cat)) {
|
|
212
|
+
const icon = STATUS_ICON[f.status];
|
|
213
|
+
const colorFn = STATUS_COLOR[f.status];
|
|
214
|
+
console.log(` ${colorFn(`${icon} ${f.message}`)}`);
|
|
215
|
+
if (f.detail) {
|
|
216
|
+
for (const line of f.detail.split("\n")) {
|
|
217
|
+
console.log(fmt.dim(` ${line}`));
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (!FIX || !f.fixable) {
|
|
221
|
+
console.log(fmt.dim(` → ${f.fix}`));
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
console.log();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const errorCount = findings.filter((f) => f.status === "error").length;
|
|
228
|
+
const warnCount = findings.filter((f) => f.status === "warn").length;
|
|
229
|
+
const fixableCount = findings.filter((f) => f.fixable).length;
|
|
230
|
+
|
|
231
|
+
const parts = [];
|
|
232
|
+
if (errorCount) parts.push(fmt.red(`${errorCount} error${errorCount > 1 ? "s" : ""}`));
|
|
233
|
+
if (warnCount) parts.push(fmt.yellow(`${warnCount} warning${warnCount > 1 ? "s" : ""}`));
|
|
234
|
+
console.log(fmt.bold(` Summary: ${parts.join(", ")}`));
|
|
235
|
+
|
|
236
|
+
if (FIX) {
|
|
237
|
+
const fixable = findings.filter((f) => f.fixable);
|
|
238
|
+
if (fixable.length === 0) {
|
|
239
|
+
console.log(fmt.dim(" No auto-fixable items found.\n"));
|
|
240
|
+
} else {
|
|
241
|
+
console.log(fmt.bold(fmt.blue("\n🔧 Applying fixes…\n")));
|
|
242
|
+
for (const f of fixable) {
|
|
243
|
+
if (f.key === "quark-animation") {
|
|
244
|
+
remove("apps/web/src/app/_components/QuarkAnimation.js");
|
|
245
|
+
console.log(fmt.green(` ✓ Removed QuarkAnimation.js`));
|
|
246
|
+
} else if (f.key === "playground") {
|
|
247
|
+
remove("apps/web/src/app/playground");
|
|
248
|
+
console.log(fmt.green(` ✓ Removed apps/web/src/app/playground/`));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
console.log();
|
|
252
|
+
}
|
|
253
|
+
} else if (fixableCount > 0) {
|
|
254
|
+
console.log(
|
|
255
|
+
fmt.dim(` ${fixableCount} item(s) can be auto-removed with: pnpm doctor:fix\n`),
|
|
256
|
+
);
|
|
257
|
+
} else {
|
|
258
|
+
console.log();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (errorCount > 0) process.exit(1);
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
export const config = {
|
|
2
2
|
appName: process.env.APP_NAME || "Quark",
|
|
3
|
-
appDescription:
|
|
4
|
-
process.env.APP_DESCRIPTION ||
|
|
5
|
-
"A modern monorepo with Next.js, React, and Prisma",
|
|
3
|
+
appDescription: process.env.APP_DESCRIPTION || "A Quark-powered application",
|
|
6
4
|
};
|
|
7
5
|
|
|
8
6
|
export { getAllowedOrigins, getAppUrl, syncNextAuthUrl } from "./app-url.js";
|
|
@@ -37,10 +37,16 @@ const envSchema = {
|
|
|
37
37
|
// Email provider
|
|
38
38
|
EMAIL_PROVIDER: {
|
|
39
39
|
required: false,
|
|
40
|
-
description: 'Email provider — "smtp" (default) or "
|
|
40
|
+
description: 'Email provider — "smtp" (default), "resend", or "zeptomail"',
|
|
41
41
|
},
|
|
42
42
|
EMAIL_FROM: { required: false, description: "Sender email address" },
|
|
43
43
|
RESEND_API_KEY: { required: false, description: "Resend API key" },
|
|
44
|
+
ZEPTOMAIL_TOKEN: { required: false, description: "Zeptomail API token" },
|
|
45
|
+
ZEPTOMAIL_URL: { required: false, description: "Zeptomail API base URL" },
|
|
46
|
+
ZEPTOMAIL_BOUNCE_EMAIL: {
|
|
47
|
+
required: false,
|
|
48
|
+
description: "Zeptomail bounce email address",
|
|
49
|
+
},
|
|
44
50
|
|
|
45
51
|
// NextAuth
|
|
46
52
|
NEXTAUTH_SECRET: {
|
|
@@ -58,6 +64,11 @@ const envSchema = {
|
|
|
58
64
|
required: false,
|
|
59
65
|
description: "Application name — used in metadata, emails, and page titles",
|
|
60
66
|
},
|
|
67
|
+
APP_DESCRIPTION: {
|
|
68
|
+
required: false,
|
|
69
|
+
description:
|
|
70
|
+
"Application description — used for SEO metadata and social previews",
|
|
71
|
+
},
|
|
61
72
|
APP_URL: {
|
|
62
73
|
required: false,
|
|
63
74
|
description:
|
|
@@ -69,6 +80,12 @@ const envSchema = {
|
|
|
69
80
|
},
|
|
70
81
|
PORT: { required: false, description: "Web server port" },
|
|
71
82
|
|
|
83
|
+
// Worker
|
|
84
|
+
WORKER_CONCURRENCY: {
|
|
85
|
+
required: false,
|
|
86
|
+
description: "Number of concurrent jobs per queue (default: 5)",
|
|
87
|
+
},
|
|
88
|
+
|
|
72
89
|
// Storage
|
|
73
90
|
STORAGE_PROVIDER: {
|
|
74
91
|
required: false,
|
|
@@ -80,10 +97,19 @@ const envSchema = {
|
|
|
80
97
|
},
|
|
81
98
|
S3_BUCKET: { required: false, description: "S3 bucket name" },
|
|
82
99
|
S3_REGION: { required: false, description: "S3 region" },
|
|
83
|
-
S3_ENDPOINT: {
|
|
100
|
+
S3_ENDPOINT: {
|
|
101
|
+
required: false,
|
|
102
|
+
description:
|
|
103
|
+
"S3-compatible endpoint URL (required for non-AWS providers: R2, MinIO, etc.)",
|
|
104
|
+
},
|
|
84
105
|
S3_ACCESS_KEY_ID: { required: false, description: "S3 access key" },
|
|
85
106
|
S3_SECRET_ACCESS_KEY: { required: false, description: "S3 secret key" },
|
|
86
107
|
S3_PUBLIC_URL: { required: false, description: "S3 public URL prefix" },
|
|
108
|
+
ASSET_CDN_URL: {
|
|
109
|
+
required: false,
|
|
110
|
+
description:
|
|
111
|
+
"Public CDN base URL for asset delivery — provider-agnostic (CloudFront, Cloudflare, Bunny, etc.). Falls back to /api/files when unset.",
|
|
112
|
+
},
|
|
87
113
|
};
|
|
88
114
|
|
|
89
115
|
/**
|
|
@@ -129,6 +155,25 @@ export function validateEnv(service = "web") {
|
|
|
129
155
|
|
|
130
156
|
// --- Cross-field validation ---
|
|
131
157
|
|
|
158
|
+
// Placeholder value security check
|
|
159
|
+
const placeholderPattern = /^CHANGE_ME_/i;
|
|
160
|
+
const criticalKeys = [
|
|
161
|
+
"NEXTAUTH_SECRET",
|
|
162
|
+
"POSTGRES_PASSWORD",
|
|
163
|
+
"RESEND_API_KEY",
|
|
164
|
+
"ZEPTOMAIL_TOKEN",
|
|
165
|
+
"S3_SECRET_ACCESS_KEY",
|
|
166
|
+
"SMTP_PASSWORD",
|
|
167
|
+
];
|
|
168
|
+
for (const key of criticalKeys) {
|
|
169
|
+
const value = process.env[key];
|
|
170
|
+
if (value && placeholderPattern.test(value)) {
|
|
171
|
+
errors.push(
|
|
172
|
+
`${key} contains a placeholder value — replace with a real secret (${envSchema[key]?.description || ""})`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
132
177
|
// Database: either DATABASE_URL or POSTGRES_USER must be set (skip in test)
|
|
133
178
|
if (!isTest) {
|
|
134
179
|
const hasDbUrl = !!process.env.DATABASE_URL;
|
|
@@ -141,16 +186,33 @@ export function validateEnv(service = "web") {
|
|
|
141
186
|
}
|
|
142
187
|
|
|
143
188
|
// Redis: warn if not configured (defaults to localhost in dev, will fail in prod)
|
|
189
|
+
const currentEnv = (process.env.NODE_ENV || "").toLowerCase();
|
|
144
190
|
if (
|
|
145
191
|
!process.env.REDIS_URL &&
|
|
146
192
|
!process.env.REDIS_HOST &&
|
|
147
|
-
|
|
193
|
+
(currentEnv === "production" || currentEnv === "staging")
|
|
148
194
|
) {
|
|
149
195
|
warnings.push(
|
|
150
196
|
"Redis not configured: set REDIS_URL or REDIS_HOST (defaults to localhost)",
|
|
151
197
|
);
|
|
152
198
|
}
|
|
153
199
|
|
|
200
|
+
// SEO metadata: APP_DESCRIPTION should be explicitly set before production
|
|
201
|
+
const isProductionLike =
|
|
202
|
+
currentEnv === "production" || currentEnv === "staging";
|
|
203
|
+
if (!isTest && isProductionLike) {
|
|
204
|
+
const appDescription = process.env.APP_DESCRIPTION;
|
|
205
|
+
if (!appDescription) {
|
|
206
|
+
warnings.push(
|
|
207
|
+
"APP_DESCRIPTION not set: metadata description will fall back to a generic value. Set APP_DESCRIPTION before production.",
|
|
208
|
+
);
|
|
209
|
+
} else if (/^CHANGE_ME_|^TODO_/i.test(appDescription)) {
|
|
210
|
+
warnings.push(
|
|
211
|
+
"APP_DESCRIPTION appears to be a placeholder value. Update it before production.",
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
154
216
|
// Conditional: S3 storage requires bucket + credentials
|
|
155
217
|
if (process.env.STORAGE_PROVIDER === "s3") {
|
|
156
218
|
for (const key of [
|
|
@@ -171,6 +233,20 @@ export function validateEnv(service = "web") {
|
|
|
171
233
|
errors.push("Missing RESEND_API_KEY — required when EMAIL_PROVIDER=resend");
|
|
172
234
|
}
|
|
173
235
|
|
|
236
|
+
// Conditional: Zeptomail provider requires token and URL
|
|
237
|
+
if (process.env.EMAIL_PROVIDER === "zeptomail") {
|
|
238
|
+
if (!process.env.ZEPTOMAIL_TOKEN) {
|
|
239
|
+
errors.push(
|
|
240
|
+
"Missing ZEPTOMAIL_TOKEN — required when EMAIL_PROVIDER=zeptomail",
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
if (!process.env.ZEPTOMAIL_URL) {
|
|
244
|
+
errors.push(
|
|
245
|
+
"Missing ZEPTOMAIL_URL — required when EMAIL_PROVIDER=zeptomail",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
174
250
|
// Log warnings (non-fatal)
|
|
175
251
|
for (const warning of warnings) {
|
|
176
252
|
console.warn(`[env] ⚠️ ${warning}`);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# @yourscope/ui
|
|
2
|
+
|
|
3
|
+
Scaffolded UI primitives for your Quark project. These components are **yours** — modify, extend, or replace them freely.
|
|
4
|
+
|
|
5
|
+
> This package is scaffolded via `quark-create-app`. There is no version sync back to Quark after scaffolding.
|
|
6
|
+
|
|
7
|
+
## Import
|
|
8
|
+
|
|
9
|
+
```javascript
|
|
10
|
+
import { Button, Card, Badge } from '@yourscope/ui';
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Components
|
|
14
|
+
|
|
15
|
+
### Button
|
|
16
|
+
Props: `variant` ('primary' | 'secondary' | 'danger' | 'ghost', default: 'primary'), `size` ('sm' | 'md' | 'lg', default: 'md'), `className`, all native button attributes.
|
|
17
|
+
|
|
18
|
+
### Input
|
|
19
|
+
Props: `className`, all native input attributes.
|
|
20
|
+
|
|
21
|
+
### Label
|
|
22
|
+
Props: `className`, all native label attributes.
|
|
23
|
+
|
|
24
|
+
### Textarea
|
|
25
|
+
Props: `className`, all native textarea attributes.
|
|
26
|
+
|
|
27
|
+
### Select
|
|
28
|
+
Props: `className`, `children` (option elements), all native select attributes.
|
|
29
|
+
|
|
30
|
+
### Checkbox
|
|
31
|
+
Props: `id`, `label` (string), `className`, all native checkbox input attributes.
|
|
32
|
+
|
|
33
|
+
### Badge
|
|
34
|
+
Props: `variant` ('default' | 'success' | 'warning' | 'danger' | 'info', default: 'default'), `className`.
|
|
35
|
+
|
|
36
|
+
### Card / CardHeader / CardTitle / CardContent / CardFooter
|
|
37
|
+
Composable card container. All parts accept `className`.
|
|
38
|
+
|
|
39
|
+
### Table / TableHeader / TableBody / TableRow / TableHead / TableCell
|
|
40
|
+
Composable table. `Table` wraps in a scrollable container. All parts accept `className`.
|
|
41
|
+
|
|
42
|
+
### Skeleton
|
|
43
|
+
Props: `className` (use to set width/height for the placeholder shape).
|
|
44
|
+
|
|
45
|
+
### Dialog
|
|
46
|
+
`"use client"` — Props: `open` (bool), `onClose` (fn), `title` (string), `children`, `className`.
|
|
47
|
+
|
|
48
|
+
### Toast / useToast
|
|
49
|
+
`"use client"` — `Toast` props: `message`, `variant` ('default' | 'success' | 'error'), `onClose` (fn), `visible` (bool).
|
|
50
|
+
`useToast()` returns `{ show(message, variant?), hide, toastProps }`. Spread `toastProps` onto `<Toast />`.
|
|
51
|
+
|
|
52
|
+
```javascript
|
|
53
|
+
// Example
|
|
54
|
+
const { show, toastProps } = useToast();
|
|
55
|
+
return (
|
|
56
|
+
<>
|
|
57
|
+
<Button onClick={() => show('Saved!', 'success')}>Save</Button>
|
|
58
|
+
<Toast {...toastProps} />
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Design notes
|
|
64
|
+
- Tailwind CSS only. No CSS-in-JS, no external dependencies.
|
|
65
|
+
- All components accept `className` for overrides.
|
|
66
|
+
- Server Component compatible except Dialog and Toast (marked `"use client"`).
|
|
67
|
+
- Accessible: ARIA attributes, focus management on interactive elements.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
const base =
|
|
4
|
+
"inline-flex items-center rounded-sm border px-2.5 py-0.5 text-xs font-medium tracking-wide";
|
|
5
|
+
|
|
6
|
+
const THEMES = {
|
|
7
|
+
light: {
|
|
8
|
+
default: "border-gray-200 bg-gray-100 text-gray-800",
|
|
9
|
+
success: "border-green-200 bg-green-100 text-green-800",
|
|
10
|
+
warning: "border-yellow-200 bg-yellow-100 text-yellow-800",
|
|
11
|
+
danger: "border-red-200 bg-red-100 text-red-800",
|
|
12
|
+
info: "border-blue-200 bg-blue-100 text-blue-800",
|
|
13
|
+
},
|
|
14
|
+
dark: {
|
|
15
|
+
default: "border-[#1e2535] bg-[#1e2535]/50 text-[#6b7a99]",
|
|
16
|
+
success: "border-emerald-800/50 bg-emerald-900/20 text-emerald-400",
|
|
17
|
+
warning: "border-yellow-800/50 bg-yellow-900/20 text-yellow-400",
|
|
18
|
+
danger: "border-[#ff4757]/30 bg-[#ff4757]/10 text-[#ff4757]",
|
|
19
|
+
info: "border-[#377dff]/30 bg-[#377dff]/10 text-[#377dff]",
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function Badge({
|
|
24
|
+
variant = "default",
|
|
25
|
+
theme = "light",
|
|
26
|
+
className = "",
|
|
27
|
+
...props
|
|
28
|
+
}) {
|
|
29
|
+
const t = THEMES[theme] ?? THEMES.light;
|
|
30
|
+
const cls = `${base} ${t[variant] ?? t.default} ${className}`.trim();
|
|
31
|
+
return React.createElement("span", { className: cls, ...props });
|
|
32
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import { Badge } from "./badge.js";
|
|
4
|
+
|
|
5
|
+
test("Badge - exports correctly", () => {
|
|
6
|
+
assert(typeof Badge === "function");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("Badge - renders with default props", () => {
|
|
10
|
+
const result = Badge({});
|
|
11
|
+
assert.ok(result);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("Badge - supports default variant", () => {
|
|
15
|
+
const result = Badge({ variant: "default" });
|
|
16
|
+
assert.ok(result);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("Badge - supports success variant", () => {
|
|
20
|
+
const result = Badge({ variant: "success" });
|
|
21
|
+
assert.ok(result);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("Badge - supports warning variant", () => {
|
|
25
|
+
const result = Badge({ variant: "warning" });
|
|
26
|
+
assert.ok(result);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("Badge - supports danger variant", () => {
|
|
30
|
+
const result = Badge({ variant: "danger" });
|
|
31
|
+
assert.ok(result);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("Badge - supports info variant", () => {
|
|
35
|
+
const result = Badge({ variant: "info" });
|
|
36
|
+
assert.ok(result);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("Badge - accepts className override", () => {
|
|
40
|
+
const result = Badge({ className: "custom" });
|
|
41
|
+
assert.ok(result);
|
|
42
|
+
});
|
|
@@ -1,19 +1,68 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
const variants = {
|
|
6
|
-
primary: "bg-blue-600 text-white hover:bg-blue-700",
|
|
7
|
-
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
|
|
8
|
-
};
|
|
3
|
+
const base =
|
|
4
|
+
"inline-flex items-center justify-center rounded-sm font-medium transition-all duration-200 cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed disabled:shadow-none active:translate-y-px";
|
|
9
5
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
6
|
+
const THEMES = {
|
|
7
|
+
light: {
|
|
8
|
+
primary:
|
|
9
|
+
"bg-blue-600 text-white shadow-sm hover:bg-blue-700 hover:shadow focus-visible:ring-blue-500",
|
|
10
|
+
secondary:
|
|
11
|
+
"border border-gray-200 bg-white text-gray-800 shadow-sm hover:bg-gray-50 hover:border-gray-300 hover:shadow focus-visible:ring-gray-400",
|
|
12
|
+
danger:
|
|
13
|
+
"bg-red-600 text-white shadow-sm hover:bg-red-700 hover:shadow focus-visible:ring-red-500",
|
|
14
|
+
ghost:
|
|
15
|
+
"bg-transparent text-gray-700 hover:bg-gray-100 hover:text-gray-900 focus-visible:ring-gray-400",
|
|
16
|
+
success:
|
|
17
|
+
"bg-emerald-600 text-white shadow-sm hover:bg-emerald-700 hover:shadow focus-visible:ring-emerald-500",
|
|
18
|
+
warning:
|
|
19
|
+
"bg-amber-500 text-white shadow-sm hover:bg-amber-600 hover:shadow focus-visible:ring-amber-400",
|
|
20
|
+
info: "bg-cyan-600 text-white shadow-sm hover:bg-cyan-700 hover:shadow focus-visible:ring-cyan-500",
|
|
21
|
+
outline:
|
|
22
|
+
"border border-blue-600 text-blue-600 bg-transparent hover:bg-blue-50 focus-visible:ring-blue-500",
|
|
23
|
+
solid:
|
|
24
|
+
"bg-blue-700 text-white shadow-sm hover:bg-blue-800 hover:shadow focus-visible:ring-blue-600",
|
|
25
|
+
},
|
|
26
|
+
dark: {
|
|
27
|
+
primary:
|
|
28
|
+
"bg-[#377dff]/10 border border-[#377dff]/40 text-[#377dff] hover:bg-[#377dff]/20 hover:border-[#377dff]/80 focus-visible:ring-[#377dff]/40 focus-visible:ring-offset-0",
|
|
29
|
+
secondary:
|
|
30
|
+
"border border-[#1e2535] text-[#6b7a99] hover:border-[#377dff]/30 hover:text-[#e0e0e0] focus-visible:ring-[#377dff]/30 focus-visible:ring-offset-0",
|
|
31
|
+
danger:
|
|
32
|
+
"bg-[#ff4757]/10 border border-[#ff4757]/40 text-[#ff4757] hover:bg-[#ff4757]/20 hover:border-[#ff4757]/80 focus-visible:ring-[#ff4757]/40 focus-visible:ring-offset-0",
|
|
33
|
+
ghost:
|
|
34
|
+
"text-[#4a4a6a] hover:bg-[#1e2535] hover:text-[#e0e0e0] focus-visible:ring-[#377dff]/30 focus-visible:ring-offset-0",
|
|
35
|
+
success:
|
|
36
|
+
"bg-emerald-500/10 border border-emerald-500/40 text-emerald-400 hover:bg-emerald-500/20 hover:border-emerald-500/80 focus-visible:ring-emerald-400/40 focus-visible:ring-offset-0",
|
|
37
|
+
warning:
|
|
38
|
+
"bg-amber-500/10 border border-amber-400/40 text-amber-400 hover:bg-amber-500/20 hover:border-amber-400/80 focus-visible:ring-amber-400/40 focus-visible:ring-offset-0",
|
|
39
|
+
info: "bg-cyan-500/10 border border-cyan-400/40 text-cyan-400 hover:bg-cyan-500/20 hover:border-cyan-400/80 focus-visible:ring-cyan-400/40 focus-visible:ring-offset-0",
|
|
40
|
+
outline:
|
|
41
|
+
"border border-[#377dff]/60 text-[#377dff] bg-transparent hover:bg-[#377dff]/10 focus-visible:ring-[#377dff]/40 focus-visible:ring-offset-0",
|
|
42
|
+
solid:
|
|
43
|
+
"bg-[#377dff] text-white hover:bg-[#2563eb] focus-visible:ring-[#377dff]/60 focus-visible:ring-offset-0",
|
|
44
|
+
},
|
|
19
45
|
};
|
|
46
|
+
|
|
47
|
+
const sizes = {
|
|
48
|
+
sm: "h-8 px-3 text-sm",
|
|
49
|
+
md: "h-10 px-4 text-sm",
|
|
50
|
+
lg: "h-11 px-6 text-base",
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export function Button({
|
|
54
|
+
variant = "primary",
|
|
55
|
+
size = "md",
|
|
56
|
+
theme = "light",
|
|
57
|
+
className = "",
|
|
58
|
+
...props
|
|
59
|
+
}) {
|
|
60
|
+
const t = THEMES[theme] ?? THEMES.light;
|
|
61
|
+
const cls =
|
|
62
|
+
`${base} ${t[variant] ?? t.primary} ${sizes[size] ?? sizes.md} ${className}`.trim();
|
|
63
|
+
return React.createElement("button", {
|
|
64
|
+
type: "button",
|
|
65
|
+
className: cls,
|
|
66
|
+
...props,
|
|
67
|
+
});
|
|
68
|
+
}
|