@usequark/quark-create-app 1.23.11
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 +210 -0
- package/package.json +56 -0
- package/src/dependabot-config.test.js +128 -0
- package/src/deploy/adapters/iac.js +203 -0
- package/src/deploy/adapters/index.js +27 -0
- package/src/deploy/adapters/railway.js +508 -0
- package/src/deploy/adapters/railway.test.js +318 -0
- package/src/deploy/contract.js +59 -0
- package/src/deploy/deploy.integration.test.js +183 -0
- package/src/deploy/deploy.js +430 -0
- package/src/deploy/deploy.test.js +105 -0
- package/src/deploy/discovery.js +115 -0
- package/src/deploy/discovery.test.js +141 -0
- package/src/deploy/index.js +38 -0
- package/src/deploy/inspect.js +130 -0
- package/src/index.js +2920 -0
- package/src/mobile-template.test.js +232 -0
- package/src/prepare-hooks.test.js +535 -0
- package/src/scaffold-guards.test.js +140 -0
- package/src/scaffold-output.test.js +228 -0
- package/src/template-scripts.test.js +160 -0
- package/src/utils.js +77 -0
- package/src/utils.test.js +95 -0
- package/src/worker-dockerfile.test.js +85 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.dockerignore +14 -0
- package/templates/base-project/.env.railway.example +87 -0
- package/templates/base-project/.github/copilot-instructions.md +65 -0
- package/templates/base-project/.github/dependabot.yml +31 -0
- package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
- package/templates/base-project/.github/workflows/ci.yml +116 -0
- package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
- package/templates/base-project/.github/workflows/install-windows.yml +27 -0
- package/templates/base-project/.github/workflows/release.yml +67 -0
- package/templates/base-project/.railway/railway.ts +40 -0
- package/templates/base-project/CLAUDE.md +163 -0
- package/templates/base-project/MAIN.md +21 -0
- package/templates/base-project/README.md +164 -0
- package/templates/base-project/apps/web/.env.example +99 -0
- package/templates/base-project/apps/web/Dockerfile +38 -0
- package/templates/base-project/apps/web/biome.json +8 -0
- package/templates/base-project/apps/web/jsconfig.json +9 -0
- package/templates/base-project/apps/web/next.config.js +154 -0
- package/templates/base-project/apps/web/package.json +42 -0
- package/templates/base-project/apps/web/postcss.config.mjs +7 -0
- package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
- package/templates/base-project/apps/web/public/quark.svg +47 -0
- package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
- package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
- 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/_components/SyncedPanels.js +212 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
- package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
- package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
- package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
- package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
- package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
- package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
- package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
- package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
- package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +58 -0
- package/templates/base-project/apps/web/src/app/globals.css +1166 -0
- package/templates/base-project/apps/web/src/app/icon.svg +47 -0
- package/templates/base-project/apps/web/src/app/layout.js +57 -0
- package/templates/base-project/apps/web/src/app/manifest.js +12 -0
- package/templates/base-project/apps/web/src/app/not-found.js +39 -0
- package/templates/base-project/apps/web/src/app/page.js +262 -0
- package/templates/base-project/apps/web/src/app/robots.js +23 -0
- package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
- package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
- package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +191 -0
- package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
- package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
- package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
- package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
- package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
- package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
- package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
- package/templates/base-project/apps/web/src/preflight.js +55 -0
- package/templates/base-project/apps/web/src/proxy.js +328 -0
- package/templates/base-project/biome.json +52 -0
- package/templates/base-project/docker-compose.override.yml +17 -0
- package/templates/base-project/docker-compose.yml +37 -0
- package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
- package/templates/base-project/docs/START_HERE.md +70 -0
- package/templates/base-project/docs/adr/README.md +42 -0
- package/templates/base-project/package.json +58 -0
- package/templates/base-project/packages/db/package.json +38 -0
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
- package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
- package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
- package/templates/base-project/packages/db/prisma/seed.js +152 -0
- package/templates/base-project/packages/db/prisma.config.js +27 -0
- package/templates/base-project/packages/db/src/client.js +75 -0
- package/templates/base-project/packages/db/src/connection.js +44 -0
- package/templates/base-project/packages/db/src/context.js +275 -0
- package/templates/base-project/packages/db/src/index.js +5 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/packages/db/src/queries.js +292 -0
- package/templates/base-project/packages/db/src/schemas.js +30 -0
- package/templates/base-project/packages/db/src/umami.js +299 -0
- package/templates/base-project/pnpm-workspace.yaml +20 -0
- package/templates/base-project/scripts/check-loading.mjs +49 -0
- package/templates/base-project/scripts/check-standards.mjs +186 -0
- package/templates/base-project/scripts/clean-workspace.mjs +479 -0
- package/templates/base-project/scripts/dev-preflight.mjs +48 -0
- package/templates/base-project/scripts/doctor.js +408 -0
- package/templates/base-project/scripts/prepare.js +490 -0
- package/templates/base-project/scripts/run-tests.mjs +71 -0
- package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
- package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
- package/templates/base-project/skills/add-model/SKILL.md +28 -0
- package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
- package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
- package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
- package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
- package/templates/base-project/skills/i18n/SKILL.md +211 -0
- package/templates/base-project/skills/payment/SKILL.md +280 -0
- package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
- package/templates/base-project/turbo.json +143 -0
- package/templates/config/package.json +20 -0
- package/templates/config/src/app-url.js +134 -0
- package/templates/config/src/environment.js +284 -0
- package/templates/config/src/index.js +16 -0
- package/templates/config/src/load-config.js +157 -0
- package/templates/config/src/rate-limit.js +136 -0
- package/templates/config/src/redis.js +43 -0
- package/templates/config/src/validate-env.js +532 -0
- package/templates/jobs/README.md +52 -0
- package/templates/jobs/package.json +9 -0
- package/templates/jobs/src/definitions.js +13 -0
- package/templates/jobs/src/index.js +1 -0
- package/templates/mobile/.env.example +16 -0
- package/templates/mobile/CHECKLIST.md +40 -0
- package/templates/mobile/README.md +130 -0
- package/templates/mobile/app/(app)/_layout.tsx +83 -0
- package/templates/mobile/app/(app)/index.tsx +147 -0
- package/templates/mobile/app/(app)/profile.tsx +204 -0
- package/templates/mobile/app/(app)/settings.tsx +80 -0
- package/templates/mobile/app/(auth)/_layout.tsx +10 -0
- package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
- package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
- package/templates/mobile/app/+not-found.tsx +32 -0
- package/templates/mobile/app/_layout.tsx +18 -0
- package/templates/mobile/app.json +47 -0
- package/templates/mobile/assets/adaptive-icon.png +0 -0
- package/templates/mobile/assets/icon.png +0 -0
- package/templates/mobile/assets/notification-icon.png +0 -0
- package/templates/mobile/assets/splash.png +0 -0
- package/templates/mobile/biome.json +27 -0
- package/templates/mobile/components/AuthGate.tsx +19 -0
- package/templates/mobile/components/ThemeProvider.tsx +23 -0
- package/templates/mobile/eas.json +29 -0
- package/templates/mobile/hooks/use-auth.ts +127 -0
- package/templates/mobile/lib/api-client.ts +138 -0
- package/templates/mobile/lib/api.ts +83 -0
- package/templates/mobile/lib/auth-providers-google.ts +74 -0
- package/templates/mobile/lib/auth-providers.ts +74 -0
- package/templates/mobile/lib/auth.ts +100 -0
- package/templates/mobile/lib/config.ts +14 -0
- package/templates/mobile/lib/errors.ts +46 -0
- package/templates/mobile/lib/notifications.ts +77 -0
- package/templates/mobile/lib/storage.ts +25 -0
- package/templates/mobile/lib/theme-tokens.ts +16 -0
- package/templates/mobile/package.json +37 -0
- package/templates/mobile/tsconfig.json +10 -0
- package/templates/pwa/app/_components/PWARegister.js +15 -0
- package/templates/pwa/app/manifest.js +32 -0
- package/templates/pwa/public/sw.js +72 -0
- package/templates/ui/README.md +44 -0
- package/templates/ui/package.json +21 -0
- package/templates/ui/src/badge.js +23 -0
- package/templates/ui/src/button.js +111 -0
- package/templates/ui/src/card.js +128 -0
- package/templates/ui/src/checkbox.js +20 -0
- package/templates/ui/src/container.js +13 -0
- package/templates/ui/src/dialog.js +137 -0
- package/templates/ui/src/error-banner.js +15 -0
- package/templates/ui/src/footer.js +239 -0
- package/templates/ui/src/form-field.integration-test.js +142 -0
- package/templates/ui/src/form-field.js +50 -0
- package/templates/ui/src/index.js +24 -0
- package/templates/ui/src/input.js +11 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/lightbox.integration-test.js +167 -0
- package/templates/ui/src/lightbox.js +243 -0
- package/templates/ui/src/logo.js +52 -0
- package/templates/ui/src/navbar.js +602 -0
- package/templates/ui/src/password-input.js +31 -0
- package/templates/ui/src/rich-text.js +514 -0
- package/templates/ui/src/select.js +317 -0
- package/templates/ui/src/skeleton.js +10 -0
- package/templates/ui/src/spinner.js +31 -0
- package/templates/ui/src/table.js +169 -0
- package/templates/ui/src/textarea.js +11 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +196 -0
- package/templates/ui/src/toast.js +206 -0
- package/templates/worker/Dockerfile +64 -0
- package/templates/worker/README.md +56 -0
- package/templates/worker/package.json +36 -0
- package/templates/worker/src/config/pricing.js +46 -0
- package/templates/worker/src/handlers/email-job-validation.js +36 -0
- package/templates/worker/src/handlers/email.js +98 -0
- package/templates/worker/src/handlers/files.js +59 -0
- package/templates/worker/src/handlers/index.js +20 -0
- package/templates/worker/src/handlers/push-notification.js +234 -0
- package/templates/worker/src/index.js +505 -0
- package/templates/worker/src/lib/railway.js +438 -0
- package/templates/worker/src/lib/tokens.js +81 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,2920 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import net from "node:net";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import { execa } from "execa";
|
|
10
|
+
import fs from "fs-extra";
|
|
11
|
+
import prompts from "prompts";
|
|
12
|
+
import { findEnclosingGitRepo, formatProjectDisplayName } from "./utils.js";
|
|
13
|
+
|
|
14
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const templatesDir = path.join(__dirname, "../templates");
|
|
16
|
+
const pkg = await fs.readJSON(path.join(__dirname, "../package.json"));
|
|
17
|
+
// db, config, and ui are always scaffolded: the base web app (layout, auth)
|
|
18
|
+
// imports the ui package, so a scaffold without it is broken.
|
|
19
|
+
const REQUIRED_PACKAGES = ["db", "config", "ui"];
|
|
20
|
+
const SCAFFOLD_CHECK_IGNORED_FILES = new Set([".env", ".quark-link.json"]);
|
|
21
|
+
|
|
22
|
+
// AI harness โ directory where embedded skills are placed for auto-loading.
|
|
23
|
+
const HARNESS_SKILL_DIRS = {
|
|
24
|
+
opencode: ".opencode/skills",
|
|
25
|
+
claude: ".claude/skills",
|
|
26
|
+
copilot: ".github/skills",
|
|
27
|
+
};
|
|
28
|
+
const DEFAULT_HARNESS = "opencode";
|
|
29
|
+
|
|
30
|
+
const program = new Command();
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.name("quark")
|
|
34
|
+
.description("Quark CLI - scaffold, update, and deploy Quark projects")
|
|
35
|
+
.version(pkg.version);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Generate a cryptographically secure random string
|
|
39
|
+
* @param {number} length - Length of the random string (default: 32)
|
|
40
|
+
* @returns {string} Base64 encoded random string
|
|
41
|
+
*/
|
|
42
|
+
function generateSecureSecret(length = 32) {
|
|
43
|
+
return crypto
|
|
44
|
+
.randomBytes(length)
|
|
45
|
+
.toString("base64")
|
|
46
|
+
.replace(/[/+=]/g, "")
|
|
47
|
+
.substring(0, length);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate a secure random password
|
|
52
|
+
* @param {number} length - Length of the password (default: 24)
|
|
53
|
+
* @returns {string} Alphanumeric password
|
|
54
|
+
*/
|
|
55
|
+
function generateSecurePassword(length = 24) {
|
|
56
|
+
const chars = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789";
|
|
57
|
+
const bytes = crypto.randomBytes(length);
|
|
58
|
+
let result = "";
|
|
59
|
+
for (let i = 0; i < length; i++) {
|
|
60
|
+
result += chars[bytes[i] % chars.length];
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check if a TCP port is available on localhost.
|
|
67
|
+
* Uses a connect test (not bind) to reliably detect Docker-bound ports on macOS.
|
|
68
|
+
* @param {number} port
|
|
69
|
+
* @returns {Promise<boolean>}
|
|
70
|
+
*/
|
|
71
|
+
function isPortAvailable(port) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const socket = new net.Socket();
|
|
74
|
+
socket.setTimeout(500);
|
|
75
|
+
socket.once("connect", () => {
|
|
76
|
+
socket.destroy();
|
|
77
|
+
resolve(false); // something is listening - port is in use
|
|
78
|
+
});
|
|
79
|
+
socket.once("error", () => {
|
|
80
|
+
socket.destroy();
|
|
81
|
+
resolve(true); // ECONNREFUSED - port is free
|
|
82
|
+
});
|
|
83
|
+
socket.once("timeout", () => {
|
|
84
|
+
socket.destroy();
|
|
85
|
+
resolve(true); // no response - port is free
|
|
86
|
+
});
|
|
87
|
+
socket.connect(port, "127.0.0.1");
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Find the next available port starting from a given port
|
|
93
|
+
* @param {number} startPort
|
|
94
|
+
* @param {number} [maxAttempts=20]
|
|
95
|
+
* @returns {Promise<number>}
|
|
96
|
+
*/
|
|
97
|
+
async function findAvailablePort(startPort, maxAttempts = 20) {
|
|
98
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
99
|
+
const port = startPort + i;
|
|
100
|
+
if (await isPortAvailable(port)) {
|
|
101
|
+
return port;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return startPort; // fallback to default if all checked ports are busy
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Copy a template directory to the target location, with variable substitution
|
|
109
|
+
*/
|
|
110
|
+
async function copyTemplate(templateName, targetDir, variables = {}) {
|
|
111
|
+
const templatePath = path.join(templatesDir, templateName);
|
|
112
|
+
|
|
113
|
+
if (!(await fs.pathExists(templatePath))) {
|
|
114
|
+
throw new Error(`Template not found: ${templateName}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Copy the template
|
|
118
|
+
await fs.copy(templatePath, targetDir);
|
|
119
|
+
|
|
120
|
+
// Replace variables in package.json files
|
|
121
|
+
if (Object.keys(variables).length > 0) {
|
|
122
|
+
const packageJsonPath = path.join(targetDir, "package.json");
|
|
123
|
+
if (await fs.pathExists(packageJsonPath)) {
|
|
124
|
+
let content = await fs.readFile(packageJsonPath, "utf-8");
|
|
125
|
+
|
|
126
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
127
|
+
const pattern = new RegExp(
|
|
128
|
+
key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
|
|
129
|
+
"g",
|
|
130
|
+
);
|
|
131
|
+
content = content.replace(pattern, value);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
await fs.writeFile(packageJsonPath, content);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve symlinks so paths reported by git and paths reported by the caller
|
|
141
|
+
* can be compared reliably (macOS tmpdirs are symlinked).
|
|
142
|
+
*/
|
|
143
|
+
function canonicalPath(dir) {
|
|
144
|
+
try {
|
|
145
|
+
return fs.realpathSync(dir);
|
|
146
|
+
} catch {
|
|
147
|
+
return path.resolve(dir);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Find the root of the git repository that contains a directory, if any.
|
|
153
|
+
* Returns null when the directory is not inside a git work tree.
|
|
154
|
+
*/
|
|
155
|
+
async function findEnclosingGitRoot(projectDir) {
|
|
156
|
+
try {
|
|
157
|
+
const { stdout } = await execa("git", ["rev-parse", "--show-toplevel"], {
|
|
158
|
+
cwd: projectDir,
|
|
159
|
+
});
|
|
160
|
+
const root = stdout.trim();
|
|
161
|
+
return root ? canonicalPath(root) : null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Initialize a git repository and create an initial commit.
|
|
169
|
+
*
|
|
170
|
+
* When the project directory is already inside a git work tree, no nested
|
|
171
|
+
* repository is created: the enclosing repository tracks the project files.
|
|
172
|
+
* Set QUARK_FORCE_GIT_INIT=true to create a separate nested repository instead.
|
|
173
|
+
*
|
|
174
|
+
* @returns {Promise<{ initialized: boolean, existingRoot: string | null }>}
|
|
175
|
+
*/
|
|
176
|
+
async function initializeGit(projectDir) {
|
|
177
|
+
if (process.env.QUARK_FORCE_GIT_INIT !== "true") {
|
|
178
|
+
const existingRoot = await findEnclosingGitRoot(projectDir);
|
|
179
|
+
|
|
180
|
+
if (existingRoot) {
|
|
181
|
+
console.log(
|
|
182
|
+
chalk.dim(` ยท Already inside a git repository: ${existingRoot}`),
|
|
183
|
+
);
|
|
184
|
+
if (existingRoot === canonicalPath(projectDir)) {
|
|
185
|
+
console.log(
|
|
186
|
+
chalk.dim(` Leaving the existing repository untouched.`),
|
|
187
|
+
);
|
|
188
|
+
} else {
|
|
189
|
+
console.log(
|
|
190
|
+
chalk.dim(
|
|
191
|
+
` Skipping nested git init - this project will be tracked by that repository.`,
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
console.log(
|
|
196
|
+
chalk.dim(
|
|
197
|
+
` Set QUARK_FORCE_GIT_INIT=true to create a separate nested repository.`,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
return { initialized: false, existingRoot };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
// Initialize git repo
|
|
206
|
+
await execa("git", ["init"], { cwd: projectDir });
|
|
207
|
+
|
|
208
|
+
// Add all files
|
|
209
|
+
await execa("git", ["add", "."], { cwd: projectDir });
|
|
210
|
+
|
|
211
|
+
// Create initial commit.
|
|
212
|
+
// Supply a local identity via -c to avoid failures on machines that
|
|
213
|
+
// have no global user.email / user.name configured (common on Windows).
|
|
214
|
+
await execa(
|
|
215
|
+
"git",
|
|
216
|
+
[
|
|
217
|
+
"-c",
|
|
218
|
+
"user.email=scaffold@quark.local",
|
|
219
|
+
"-c",
|
|
220
|
+
"user.name=Quark Scaffold",
|
|
221
|
+
"commit",
|
|
222
|
+
"-m",
|
|
223
|
+
"Initial commit: Quark project scaffold",
|
|
224
|
+
],
|
|
225
|
+
{
|
|
226
|
+
cwd: projectDir,
|
|
227
|
+
},
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
return { initialized: true, existingRoot: null };
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.warn(
|
|
233
|
+
chalk.yellow(`โ ๏ธ Git initialization failed: ${error.message}`),
|
|
234
|
+
);
|
|
235
|
+
return { initialized: false, existingRoot: null };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Substitute __QUARK_* placeholders in a single scaffold file.
|
|
241
|
+
* Writes only when the content actually changed.
|
|
242
|
+
*/
|
|
243
|
+
async function substitutePlaceholders(filePath, values) {
|
|
244
|
+
let content = await fs.readFile(filePath, "utf-8");
|
|
245
|
+
const before = content;
|
|
246
|
+
|
|
247
|
+
for (const [token, value] of Object.entries(values)) {
|
|
248
|
+
// Replacer function so `$` sequences in values are treated literally.
|
|
249
|
+
content = content.replaceAll(token, () => value);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (content !== before) {
|
|
253
|
+
await fs.writeFile(filePath, content);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Recursively substitute __QUARK_* placeholders in every Markdown file.
|
|
259
|
+
*/
|
|
260
|
+
async function substitutePlaceholdersInMarkdownFiles(dir, values) {
|
|
261
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
262
|
+
|
|
263
|
+
for (const entry of entries) {
|
|
264
|
+
const fullPath = path.join(dir, entry.name);
|
|
265
|
+
if (entry.isDirectory()) {
|
|
266
|
+
await substitutePlaceholdersInMarkdownFiles(fullPath, values);
|
|
267
|
+
} else if (/\.md$/i.test(entry.name)) {
|
|
268
|
+
await substitutePlaceholders(fullPath, values);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Check whether a project directory has uncommitted (unstaged or staged) changes.
|
|
275
|
+
*
|
|
276
|
+
* The diff is scoped to the project directory so that a Quark project nested in
|
|
277
|
+
* a larger repository is not blocked by dirty sibling files elsewhere in that
|
|
278
|
+
* repository.
|
|
279
|
+
*/
|
|
280
|
+
async function hasUncommittedChanges(projectDir) {
|
|
281
|
+
try {
|
|
282
|
+
await execa("git", ["diff", "--exit-code", "--", "."], {
|
|
283
|
+
cwd: projectDir,
|
|
284
|
+
});
|
|
285
|
+
await execa("git", ["diff", "--cached", "--exit-code", "--", "."], {
|
|
286
|
+
cwd: projectDir,
|
|
287
|
+
});
|
|
288
|
+
return false;
|
|
289
|
+
} catch {
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Update package.json name with scope
|
|
296
|
+
*/
|
|
297
|
+
async function updatePackageJsonName(filePath, scope) {
|
|
298
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
299
|
+
const packageJson = JSON.parse(content);
|
|
300
|
+
|
|
301
|
+
// Extract package name (e.g., "@myquark/ui" -> "ui")
|
|
302
|
+
const parts = packageJson.name.split("/");
|
|
303
|
+
const packageName = parts[parts.length - 1];
|
|
304
|
+
|
|
305
|
+
packageJson.name = `@${scope}/${packageName}`;
|
|
306
|
+
|
|
307
|
+
await fs.writeFile(filePath, `${JSON.stringify(packageJson, null, " ")}\n`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function getWorkspacePackagesForFeatures(features) {
|
|
311
|
+
const packages = new Set();
|
|
312
|
+
|
|
313
|
+
for (const feature of features) {
|
|
314
|
+
const meta = FEATURE_META[feature];
|
|
315
|
+
if (meta?.skill) continue; // skill-based verticals have no workspace package
|
|
316
|
+
const packageNames = meta?.packages ?? [feature];
|
|
317
|
+
|
|
318
|
+
for (const packageName of packageNames) {
|
|
319
|
+
packages.add(packageName);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return [...packages];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function getPairedTemplatesForFeatures(features) {
|
|
327
|
+
const pairs = [];
|
|
328
|
+
|
|
329
|
+
for (const feature of features) {
|
|
330
|
+
const meta = FEATURE_META[feature];
|
|
331
|
+
if (!meta || meta.skill) continue; // skill-based verticals have no paired routes
|
|
332
|
+
|
|
333
|
+
for (const pair of meta.pairs) {
|
|
334
|
+
if (!pairs.includes(pair)) {
|
|
335
|
+
pairs.push(pair);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return pairs;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function buildFeatureRows(features) {
|
|
344
|
+
const rows = {
|
|
345
|
+
ui: "| UI package | Included | `packages/ui/README.md` |",
|
|
346
|
+
jobs: "| Jobs + worker | Included | `packages/jobs/README.md`, `apps/worker/src/handlers/` |",
|
|
347
|
+
pwa: "| PWA support | Included | `apps/web/public/sw.js`, `apps/web/src/app/manifest.js` |",
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
const selectedRows = features
|
|
351
|
+
.filter((feature) => rows[feature])
|
|
352
|
+
.map((feature) => rows[feature]);
|
|
353
|
+
|
|
354
|
+
if (selectedRows.length === 0) {
|
|
355
|
+
return "| Optional scaffolded features | None selected yet | Add one later with `npx @techstream/quark-create-app add <feature>` |";
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return selectedRows.join("\n");
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function buildOptionalAppLines(features) {
|
|
362
|
+
const lines = [];
|
|
363
|
+
if (features.includes("jobs")) {
|
|
364
|
+
lines.push("โ โโโ worker/ # BullMQ background worker\n");
|
|
365
|
+
}
|
|
366
|
+
return lines.join("");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function buildFirstEditLines(features) {
|
|
370
|
+
const lines = {
|
|
371
|
+
ui: "- `packages/ui/src/` - adjust primitives or add app-specific UI components",
|
|
372
|
+
jobs: "- `packages/jobs/src/definitions.js` and `apps/worker/src/handlers/` - define and process background jobs",
|
|
373
|
+
pwa: "- `apps/web/public/sw.js` - service worker caching config, `src/app/manifest.js` - app manifest (replaces the base manifest)",
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const selectedLines = features
|
|
377
|
+
.filter((feature) => lines[feature])
|
|
378
|
+
.map((feature) => lines[feature]);
|
|
379
|
+
|
|
380
|
+
if (selectedLines.length === 0) {
|
|
381
|
+
return "- No optional scaffolded packages were selected yet. Add one later when the product needs it.";
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return selectedLines.join("\n");
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function buildFeatureGuideLines(features) {
|
|
388
|
+
const lines = {
|
|
389
|
+
ui: "- `packages/ui/README.md` - component catalog, import rules, and extension notes",
|
|
390
|
+
jobs: "- `packages/jobs/README.md` - queue names, worker pairing, and the job-extension workflow",
|
|
391
|
+
pwa: "- `apps/web/public/sw.js` - service worker caching strategies and offline behavior",
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const selectedLines = features
|
|
395
|
+
.filter((feature) => lines[feature])
|
|
396
|
+
.map((feature) => lines[feature]);
|
|
397
|
+
|
|
398
|
+
if (selectedLines.length === 0) {
|
|
399
|
+
return "- No optional package guides are present yet. Start with `packages/db` and `apps/web`.";
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return selectedLines.join("\n");
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function normalizeScaffoldDate(scaffoldedDate) {
|
|
406
|
+
return (scaffoldedDate || new Date().toISOString()).split("T")[0];
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function formatFeatureSummary(features) {
|
|
410
|
+
return features.length > 0 ? features.join(", ") : "none";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Registry pin for @techstream/quark-core in scaffolded apps.
|
|
415
|
+
* Mirrors sync-templates.js CORE_VERSION_PIN (major.0.0 from baked templates).
|
|
416
|
+
*/
|
|
417
|
+
function getCoreVersionPin() {
|
|
418
|
+
try {
|
|
419
|
+
const webPkg = fs.readJSONSync(
|
|
420
|
+
path.join(templatesDir, "base-project/apps/web/package.json"),
|
|
421
|
+
);
|
|
422
|
+
const pin = webPkg?.dependencies?.["@techstream/quark-core"];
|
|
423
|
+
if (typeof pin === "string" && pin !== "workspace:*") {
|
|
424
|
+
return pin;
|
|
425
|
+
}
|
|
426
|
+
} catch {
|
|
427
|
+
// Templates may be unavailable in some test contexts.
|
|
428
|
+
}
|
|
429
|
+
return "^2.0.0";
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Replace @techstream/quark-* workspace deps with @scope/* for local packages.
|
|
434
|
+
* Also removes deps for packages that were not selected.
|
|
435
|
+
* @techstream/quark-core is a published registry package โ rewrite workspace:*
|
|
436
|
+
* to the registry pin instead of deleting or rescoping it.
|
|
437
|
+
*/
|
|
438
|
+
function replaceDepsScope(deps, scope, selectedPackages) {
|
|
439
|
+
if (!deps) return;
|
|
440
|
+
for (const [key, value] of Object.entries(deps)) {
|
|
441
|
+
if (key.startsWith("@techstream/quark-") && value === "workspace:*") {
|
|
442
|
+
// Published registry package โ keep name, pin to registry version.
|
|
443
|
+
if (key === "@techstream/quark-core") {
|
|
444
|
+
deps[key] = getCoreVersionPin();
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const packageName = key.replace("@techstream/quark-", "");
|
|
448
|
+
delete deps[key];
|
|
449
|
+
// Only keep the dep if the package was selected (or is always required)
|
|
450
|
+
if (
|
|
451
|
+
packageName === "db" ||
|
|
452
|
+
packageName === "config" ||
|
|
453
|
+
packageName === "ui" ||
|
|
454
|
+
selectedPackages.includes(packageName)
|
|
455
|
+
) {
|
|
456
|
+
deps[`@${scope}/${packageName}`] = value;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Remove unselected workspace package entries from next.config.js transpilePackages.
|
|
464
|
+
* Must run AFTER replaceImportsInSourceFiles so package names are already scoped.
|
|
465
|
+
*/
|
|
466
|
+
async function patchNextConfig(webDir, scope, selectedPackages) {
|
|
467
|
+
const configPath = path.join(webDir, "next.config.js");
|
|
468
|
+
if (!(await fs.pathExists(configPath))) return;
|
|
469
|
+
|
|
470
|
+
let content = await fs.readFile(configPath, "utf-8");
|
|
471
|
+
|
|
472
|
+
const optionalEntries = {
|
|
473
|
+
ui: `@${scope}/ui`,
|
|
474
|
+
jobs: `@${scope}/jobs`,
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
for (const [feature, pkg] of Object.entries(optionalEntries)) {
|
|
478
|
+
if (!selectedPackages.includes(feature)) {
|
|
479
|
+
// Remove the line containing this package from transpilePackages.
|
|
480
|
+
// Use \r?\n so the regex matches both LF and CRLF line endings
|
|
481
|
+
// (CRLF can appear on Windows when git autocrlf=true is set).
|
|
482
|
+
content = content.replace(
|
|
483
|
+
new RegExp(`[ \\t]*"${pkg.replace("/", "\\/")}",?\\r?\\n`, "g"),
|
|
484
|
+
"",
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
await fs.writeFile(configPath, content);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Replace @techstream/quark-* import paths in all .js source files
|
|
494
|
+
* for workspace packages (db, jobs, ui, config) with @scope/* equivalents.
|
|
495
|
+
* Registry packages (@techstream/quark-core) are left untouched.
|
|
496
|
+
*/
|
|
497
|
+
async function replaceImportsInSourceFiles(dir, scope) {
|
|
498
|
+
const workspacePackages = ["db", "jobs", "ui", "config"];
|
|
499
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
500
|
+
|
|
501
|
+
for (const entry of entries) {
|
|
502
|
+
const fullPath = path.join(dir, entry.name);
|
|
503
|
+
|
|
504
|
+
if (
|
|
505
|
+
entry.isDirectory() &&
|
|
506
|
+
entry.name !== "node_modules" &&
|
|
507
|
+
entry.name !== ".next"
|
|
508
|
+
) {
|
|
509
|
+
await replaceImportsInSourceFiles(fullPath, scope);
|
|
510
|
+
} else if (entry.isFile() && /\.(js|ts|jsx|tsx|mjs)$/.test(entry.name)) {
|
|
511
|
+
let content = await fs.readFile(fullPath, "utf-8");
|
|
512
|
+
let changed = false;
|
|
513
|
+
|
|
514
|
+
for (const pkg of workspacePackages) {
|
|
515
|
+
const pattern = new RegExp(`@techstream/quark-${pkg}`, "g");
|
|
516
|
+
if (pattern.test(content)) {
|
|
517
|
+
content = content.replace(pattern, `@${scope}/${pkg}`);
|
|
518
|
+
changed = true;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (changed) {
|
|
523
|
+
await fs.writeFile(fullPath, content);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Validate project name to prevent path traversal and ensure safe directory creation
|
|
531
|
+
*/
|
|
532
|
+
function validateProjectName(name) {
|
|
533
|
+
if (!name || typeof name !== "string") {
|
|
534
|
+
throw new Error("Project name is required");
|
|
535
|
+
}
|
|
536
|
+
// Only allow safe characters: alphanumeric, hyphens, underscores, dots
|
|
537
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"Project name may only contain letters, numbers, hyphens, underscores, and dots",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
// Block path traversal patterns
|
|
543
|
+
if (name.startsWith(".") || name.includes("..")) {
|
|
544
|
+
throw new Error("Project name must not start with '.' or contain '..'");
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const resolved = path.resolve(process.cwd(), name);
|
|
548
|
+
if (!resolved.startsWith(process.cwd())) {
|
|
549
|
+
throw new Error("Project name must not escape the current directory");
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return resolved;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function resolveSignupPreference(optionValue) {
|
|
556
|
+
if (optionValue === undefined) {
|
|
557
|
+
return undefined;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const normalized = optionValue.trim().toLowerCase();
|
|
561
|
+
|
|
562
|
+
if (["enabled", "enable", "true", "yes", "on"].includes(normalized)) {
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (["disabled", "disable", "false", "no", "off"].includes(normalized)) {
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
throw new Error(
|
|
571
|
+
`Invalid value for --signup: ${optionValue}. Use "enabled" or "disabled".`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
program
|
|
576
|
+
.argument("[project-name]", "Name of the project to create")
|
|
577
|
+
.option(
|
|
578
|
+
"--no-prompts",
|
|
579
|
+
"Skip interactive prompts and use default/provided values",
|
|
580
|
+
)
|
|
581
|
+
.option(
|
|
582
|
+
"--signup <mode>",
|
|
583
|
+
'Public self-service signup mode for the scaffolded project ("enabled" or "disabled")',
|
|
584
|
+
)
|
|
585
|
+
.option(
|
|
586
|
+
"--packages <packages>",
|
|
587
|
+
"Comma-separated list of optional packages to include (ui,jobs,pwa)",
|
|
588
|
+
)
|
|
589
|
+
.option(
|
|
590
|
+
"--prompt <prompt>",
|
|
591
|
+
"Product brief used to populate MAIN.md (AI View)",
|
|
592
|
+
)
|
|
593
|
+
.option(
|
|
594
|
+
"--harness <harness>",
|
|
595
|
+
"AI harness for embedded skills (opencode, claude, copilot)",
|
|
596
|
+
)
|
|
597
|
+
.option("--skip-install", "Skip pnpm install and Prisma generate steps")
|
|
598
|
+
.option("--skip-docker", "Skip Docker orphan-volume cleanup")
|
|
599
|
+
.action(async (projectName, options) => {
|
|
600
|
+
console.log(
|
|
601
|
+
chalk.blue.bold(
|
|
602
|
+
`\n\uD83D\uDE80 Creating your new Quark project: ${projectName}\n`,
|
|
603
|
+
),
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
// Verify pnpm is available before doing any work
|
|
607
|
+
if (!options.skipInstall) {
|
|
608
|
+
try {
|
|
609
|
+
await execa("pnpm", ["--version"]);
|
|
610
|
+
} catch {
|
|
611
|
+
console.error(chalk.red("\n โ pnpm is required but was not found.\n"));
|
|
612
|
+
console.error(chalk.yellow(" Install it with one of:"));
|
|
613
|
+
console.error(
|
|
614
|
+
chalk.white(
|
|
615
|
+
" corepack enable (recommended, built into Node)",
|
|
616
|
+
),
|
|
617
|
+
);
|
|
618
|
+
console.error(chalk.white(" npm install -g pnpm"));
|
|
619
|
+
console.error(chalk.white(" brew install pnpm (macOS)\n"));
|
|
620
|
+
console.error(chalk.gray(" Then re-run this command.\n"));
|
|
621
|
+
process.exit(1);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// If no project name provided, prompt for it (interactive only)
|
|
626
|
+
if (!projectName) {
|
|
627
|
+
if (options.prompts === false) {
|
|
628
|
+
console.error(
|
|
629
|
+
chalk.red(
|
|
630
|
+
"\n โ Project name is required in non-interactive mode.\n",
|
|
631
|
+
),
|
|
632
|
+
);
|
|
633
|
+
process.exit(1);
|
|
634
|
+
}
|
|
635
|
+
const nameResponse = await prompts({
|
|
636
|
+
type: "text",
|
|
637
|
+
name: "value",
|
|
638
|
+
message: "Project name:",
|
|
639
|
+
validate: (v) => {
|
|
640
|
+
if (!v || !/^[a-zA-Z0-9._-]+$/.test(v)) {
|
|
641
|
+
return "Only letters, numbers, hyphens, underscores, and dots allowed";
|
|
642
|
+
}
|
|
643
|
+
if (v.startsWith(".") || v.includes("..")) {
|
|
644
|
+
return "Must not start with '.' or contain '..'";
|
|
645
|
+
}
|
|
646
|
+
return true;
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
if (!nameResponse.value) {
|
|
650
|
+
console.log(chalk.yellow("\nโ ๏ธ Setup cancelled."));
|
|
651
|
+
process.exit(0);
|
|
652
|
+
}
|
|
653
|
+
projectName = nameResponse.value;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const targetDir = validateProjectName(projectName);
|
|
657
|
+
const scope = projectName.toLowerCase().replace(/[^a-z0-9-]/g, "");
|
|
658
|
+
const appDisplayName = formatProjectDisplayName(projectName);
|
|
659
|
+
const appDescription = `${appDisplayName} application`;
|
|
660
|
+
|
|
661
|
+
// Creating a project inside an existing git work tree nests the project
|
|
662
|
+
// folder (and, if we ran `git init`, a second repository) below the repo
|
|
663
|
+
// root. pnpm workspaces, turbo and CI workflows all expect package.json
|
|
664
|
+
// at the repository root, so warn loudly and never create a nested
|
|
665
|
+
// repository.
|
|
666
|
+
const enclosingRepo = await findEnclosingGitRepo(process.cwd());
|
|
667
|
+
if (enclosingRepo) {
|
|
668
|
+
console.log(
|
|
669
|
+
chalk.yellow(
|
|
670
|
+
`\n โ ๏ธ This project will be created inside an existing git repository:\n` +
|
|
671
|
+
` repository root : ${enclosingRepo}\n` +
|
|
672
|
+
` project folder : ${path.relative(enclosingRepo, targetDir) || projectName}\n\n` +
|
|
673
|
+
` pnpm, turbo and GitHub Actions expect package.json at the\n` +
|
|
674
|
+
` repository root, so a nested project folder breaks installs\n` +
|
|
675
|
+
` and CI. Prefer one of:\n` +
|
|
676
|
+
` โข scaffold outside this repository, then push the project\n` +
|
|
677
|
+
` as its own repository, or\n` +
|
|
678
|
+
` โข scaffold into a temporary folder and move the files to\n` +
|
|
679
|
+
` the repository root before the first commit.\n\n` +
|
|
680
|
+
` Git initialization will be skipped to avoid creating a\n` +
|
|
681
|
+
` nested repository.\n\n` +
|
|
682
|
+
` GitHub only runs workflows stored at the repository root, so\n` +
|
|
683
|
+
` <project>/.github/workflows/*.yml will be ignored by GitHub\n` +
|
|
684
|
+
` Actions.\n`,
|
|
685
|
+
),
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Auto-detect interactive mode: if any config options are provided,
|
|
690
|
+
// skip prompts automatically. Only show prompts when no options given.
|
|
691
|
+
const hasConfigOptions =
|
|
692
|
+
options.packages !== undefined ||
|
|
693
|
+
options.signup !== undefined ||
|
|
694
|
+
options.prompt !== undefined ||
|
|
695
|
+
options.harness;
|
|
696
|
+
if (hasConfigOptions && options.prompts !== false) {
|
|
697
|
+
console.log(
|
|
698
|
+
chalk.dim(
|
|
699
|
+
" โน Configuration options provided โ skipping interactive prompts",
|
|
700
|
+
),
|
|
701
|
+
);
|
|
702
|
+
options.prompts = false;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Deprecation warning for --no-prompts (now implicit when options are provided)
|
|
706
|
+
if (options.prompts === false && !hasConfigOptions) {
|
|
707
|
+
console.log(
|
|
708
|
+
chalk.yellow(
|
|
709
|
+
" โ --no-prompts is deprecated. Options now auto-skip prompts.",
|
|
710
|
+
),
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
const requestedSignupPreference = resolveSignupPreference(options.signup);
|
|
715
|
+
|
|
716
|
+
// Clean up orphaned Docker volumes from a previous project with the same name.
|
|
717
|
+
// Docker Compose names volumes as "<project>_postgres_data", "<project>_redis_data".
|
|
718
|
+
// These persist even if the project directory is manually deleted, causing
|
|
719
|
+
// authentication failures when the new project generates different credentials.
|
|
720
|
+
// We also need to stop any running containers that reference these volumes.
|
|
721
|
+
if (!options.skipDocker)
|
|
722
|
+
try {
|
|
723
|
+
const volumePrefix = `${projectName}_`;
|
|
724
|
+
const { stdout } = await execa("docker", [
|
|
725
|
+
"volume",
|
|
726
|
+
"ls",
|
|
727
|
+
"--filter",
|
|
728
|
+
`name=${volumePrefix}`,
|
|
729
|
+
"--format",
|
|
730
|
+
"{{.Name}}",
|
|
731
|
+
]);
|
|
732
|
+
const orphanedVolumes = stdout
|
|
733
|
+
.split("\n")
|
|
734
|
+
.filter((v) => v.startsWith(volumePrefix));
|
|
735
|
+
if (orphanedVolumes.length > 0) {
|
|
736
|
+
// Stop and remove any containers using these volumes first
|
|
737
|
+
const { stdout: containerOut } = await execa("docker", [
|
|
738
|
+
"ps",
|
|
739
|
+
"-a",
|
|
740
|
+
"--filter",
|
|
741
|
+
`name=${projectName}`,
|
|
742
|
+
"--format",
|
|
743
|
+
"{{.ID}}",
|
|
744
|
+
]);
|
|
745
|
+
const containers = containerOut.split("\n").filter(Boolean);
|
|
746
|
+
if (containers.length > 0) {
|
|
747
|
+
await execa("docker", ["rm", "-f", ...containers]);
|
|
748
|
+
}
|
|
749
|
+
// Remove the Docker network if it exists
|
|
750
|
+
try {
|
|
751
|
+
await execa("docker", ["network", "rm", `${projectName}_default`]);
|
|
752
|
+
} catch {
|
|
753
|
+
// Network may not exist - fine
|
|
754
|
+
}
|
|
755
|
+
// Now remove the orphaned volumes
|
|
756
|
+
for (const vol of orphanedVolumes) {
|
|
757
|
+
await execa("docker", ["volume", "rm", "-f", vol]);
|
|
758
|
+
}
|
|
759
|
+
console.log(chalk.green(" โ Cleaned up orphaned Docker volumes"));
|
|
760
|
+
}
|
|
761
|
+
} catch {
|
|
762
|
+
// Docker not available - fine
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Check if directory already exists
|
|
766
|
+
if (await fs.pathExists(targetDir)) {
|
|
767
|
+
if (!options.prompts) {
|
|
768
|
+
// In non-interactive mode, automatically remove existing directory
|
|
769
|
+
console.log(
|
|
770
|
+
chalk.yellow(
|
|
771
|
+
` Directory "${projectName}" already exists. Removing... (non-interactive mode)`,
|
|
772
|
+
),
|
|
773
|
+
);
|
|
774
|
+
} else {
|
|
775
|
+
const { overwrite } = await prompts({
|
|
776
|
+
type: "confirm",
|
|
777
|
+
name: "overwrite",
|
|
778
|
+
message: `Directory "${projectName}" already exists. Remove it and recreate?`,
|
|
779
|
+
initial: false,
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
if (!overwrite) {
|
|
783
|
+
console.log(chalk.yellow("Aborted."));
|
|
784
|
+
process.exit(1);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Stop any running Docker containers for this project
|
|
789
|
+
try {
|
|
790
|
+
await execa("docker", ["compose", "down"], {
|
|
791
|
+
cwd: targetDir,
|
|
792
|
+
stdio: "ignore",
|
|
793
|
+
});
|
|
794
|
+
console.log(chalk.green(" โ Stopped existing Docker containers"));
|
|
795
|
+
} catch {
|
|
796
|
+
// No docker-compose file or Docker not running - fine
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
await fs.remove(targetDir);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
try {
|
|
803
|
+
// Create the base directory
|
|
804
|
+
await fs.ensureDir(targetDir);
|
|
805
|
+
|
|
806
|
+
// Step 1: Copy base project template
|
|
807
|
+
console.log(chalk.cyan(" ๐ฆ Scaffolding base project structure..."));
|
|
808
|
+
await copyTemplate("base-project", targetDir, {
|
|
809
|
+
"@myquark": `@${scope}`,
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
// Step 1b: Place embedded skills in the selected AI harness directory.
|
|
813
|
+
const harness = options.harness || DEFAULT_HARNESS;
|
|
814
|
+
const harnessSkillDir = HARNESS_SKILL_DIRS[harness];
|
|
815
|
+
if (harnessSkillDir) {
|
|
816
|
+
const neutralSkills = path.join(targetDir, "skills");
|
|
817
|
+
if (await fs.pathExists(neutralSkills)) {
|
|
818
|
+
const destSkills = path.join(targetDir, harnessSkillDir);
|
|
819
|
+
await fs.ensureDir(destSkills);
|
|
820
|
+
await fs.copy(neutralSkills, destSkills);
|
|
821
|
+
await fs.remove(neutralSkills);
|
|
822
|
+
console.log(
|
|
823
|
+
chalk.green(
|
|
824
|
+
` โ Embedded skills โ ${harnessSkillDir} (${harness})`,
|
|
825
|
+
),
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
// Point the scaffolded docs at the harness skill directory.
|
|
829
|
+
for (const doc of ["MAIN.md", "CLAUDE.md", "README.md"]) {
|
|
830
|
+
const docPath = path.join(targetDir, doc);
|
|
831
|
+
if (await fs.pathExists(docPath)) {
|
|
832
|
+
let content = await fs.readFile(docPath, "utf-8");
|
|
833
|
+
content = content.replace(/\.opencode\/skills/g, harnessSkillDir);
|
|
834
|
+
await fs.writeFile(docPath, content);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
} else {
|
|
838
|
+
console.log(
|
|
839
|
+
chalk.yellow(
|
|
840
|
+
` โ Unknown harness "${harness}". Skills left in skills/ (no auto-load).`,
|
|
841
|
+
),
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Step 2: Create apps directory
|
|
846
|
+
await fs.ensureDir(path.join(targetDir, "apps"));
|
|
847
|
+
|
|
848
|
+
// Step 3: Create packages directory
|
|
849
|
+
await fs.ensureDir(path.join(targetDir, "packages"));
|
|
850
|
+
|
|
851
|
+
// Step 4: Copy required packages (always included)
|
|
852
|
+
console.log(chalk.cyan("\n ๐ฆ Setting up required packages..."));
|
|
853
|
+
|
|
854
|
+
// Database and config packages are always required
|
|
855
|
+
for (const reqPkg of REQUIRED_PACKAGES) {
|
|
856
|
+
const pkgDir = path.join(targetDir, "packages", reqPkg);
|
|
857
|
+
// db is already copied from base-project; config needs to be copied from its template
|
|
858
|
+
if (!(await fs.pathExists(pkgDir))) {
|
|
859
|
+
await fs.ensureDir(pkgDir);
|
|
860
|
+
await copyTemplate(reqPkg, pkgDir);
|
|
861
|
+
}
|
|
862
|
+
const pkgJsonPath = path.join(pkgDir, "package.json");
|
|
863
|
+
// Verify copy succeeded - guards against Windows fs race conditions where a
|
|
864
|
+
// directory handle is returned before child writes are fully committed.
|
|
865
|
+
if (!(await fs.pathExists(pkgJsonPath))) {
|
|
866
|
+
throw new Error(
|
|
867
|
+
`Required package '${reqPkg}' is missing package.json after copy.\n` +
|
|
868
|
+
` This can occur on Windows due to filesystem timing. Please re-run the scaffolder.\n` +
|
|
869
|
+
` Expected: ${pkgJsonPath}`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
const pkgJson = await fs.readJSON(pkgJsonPath);
|
|
873
|
+
pkgJson.name = `@${scope}/${reqPkg}`;
|
|
874
|
+
await fs.writeFile(
|
|
875
|
+
pkgJsonPath,
|
|
876
|
+
`${JSON.stringify(pkgJson, null, " ")}\n`,
|
|
877
|
+
);
|
|
878
|
+
console.log(chalk.green(` โ ${reqPkg} (required)`));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Step 5: Determine which optional packages to scaffold
|
|
882
|
+
let features;
|
|
883
|
+
let brief = options.prompt?.trim() || `${appDisplayName} application`;
|
|
884
|
+
if (!options.prompts && options.packages !== undefined) {
|
|
885
|
+
// Parse packages from CLI flag
|
|
886
|
+
console.log(chalk.cyan("\n ๐ฏ Configuring optional packages..."));
|
|
887
|
+
const validFeatures = Object.keys(FEATURE_META);
|
|
888
|
+
features = options.packages
|
|
889
|
+
.split(",")
|
|
890
|
+
.map((f) => f.trim())
|
|
891
|
+
.filter((f) => f.length > 0);
|
|
892
|
+
|
|
893
|
+
// Validate features
|
|
894
|
+
const invalidFeatures = features.filter(
|
|
895
|
+
(f) => !validFeatures.includes(f),
|
|
896
|
+
);
|
|
897
|
+
if (invalidFeatures.length > 0) {
|
|
898
|
+
throw new Error(
|
|
899
|
+
`Invalid packages: ${invalidFeatures.join(", ")}. Valid options are: ${validFeatures.join(", ")}`,
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Mobile is added post-creation via `quark add mobile`
|
|
904
|
+
if (features.includes("mobile")) {
|
|
905
|
+
throw new Error(
|
|
906
|
+
'Mobile app cannot be included during project creation. Run "quark add mobile" after scaffolding.',
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const requestedFeatures = [...features];
|
|
911
|
+
features = resolveFeatureSelection(features);
|
|
912
|
+
const autoIncluded = features.filter(
|
|
913
|
+
(feature) => !requestedFeatures.includes(feature),
|
|
914
|
+
);
|
|
915
|
+
if (autoIncluded.length > 0) {
|
|
916
|
+
console.log(
|
|
917
|
+
chalk.yellow(
|
|
918
|
+
` โน Automatically included required packages: ${autoIncluded.join(", ")}`,
|
|
919
|
+
),
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
console.log(
|
|
924
|
+
chalk.green(
|
|
925
|
+
` Selected packages: ${features.join(", ") || "none"} (non-interactive mode)`,
|
|
926
|
+
),
|
|
927
|
+
);
|
|
928
|
+
} else if (!options.prompts) {
|
|
929
|
+
// Use defaults when --no-prompts is set without --packages
|
|
930
|
+
console.log(chalk.cyan("\n ๐ฏ Configuring optional packages..."));
|
|
931
|
+
features = ["ui", "jobs"];
|
|
932
|
+
console.log(
|
|
933
|
+
chalk.green(
|
|
934
|
+
` Using default packages: ${features.join(", ")} (non-interactive mode)`,
|
|
935
|
+
),
|
|
936
|
+
);
|
|
937
|
+
} else {
|
|
938
|
+
// Interactive: simple Y/n prompts
|
|
939
|
+
console.log(chalk.cyan("\n ๐ก Let's set up your project...\n"));
|
|
940
|
+
const briefResponse = await prompts({
|
|
941
|
+
type: "text",
|
|
942
|
+
name: "brief",
|
|
943
|
+
message: "Describe your app in a sentence or two (seeds MAIN.md):",
|
|
944
|
+
initial: `${appDisplayName} application`,
|
|
945
|
+
});
|
|
946
|
+
brief = briefResponse.brief;
|
|
947
|
+
if (!brief) {
|
|
948
|
+
console.log(chalk.yellow("\nโ ๏ธ Setup cancelled."));
|
|
949
|
+
await fs.remove(targetDir);
|
|
950
|
+
process.exit(0);
|
|
951
|
+
}
|
|
952
|
+
brief = brief.trim();
|
|
953
|
+
|
|
954
|
+
console.log("");
|
|
955
|
+
const jobsResponse = await prompts({
|
|
956
|
+
type: "confirm",
|
|
957
|
+
name: "value",
|
|
958
|
+
message:
|
|
959
|
+
"Include background jobs (emails, webhooks, scheduled tasks)?",
|
|
960
|
+
initial: true,
|
|
961
|
+
});
|
|
962
|
+
if (typeof jobsResponse.value !== "boolean") {
|
|
963
|
+
console.log(chalk.yellow("\nโ ๏ธ Setup cancelled."));
|
|
964
|
+
await fs.remove(targetDir);
|
|
965
|
+
process.exit(0);
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const selectedFeatures = ["ui"];
|
|
969
|
+
if (jobsResponse.value) {
|
|
970
|
+
selectedFeatures.push("jobs");
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
const pwaResponse = await prompts({
|
|
974
|
+
type: "confirm",
|
|
975
|
+
name: "enabled",
|
|
976
|
+
message: "Enable PWA support (offline, installable)?",
|
|
977
|
+
initial: false,
|
|
978
|
+
});
|
|
979
|
+
if (typeof pwaResponse.enabled !== "boolean") {
|
|
980
|
+
console.log(chalk.yellow("\nโ ๏ธ Setup cancelled."));
|
|
981
|
+
await fs.remove(targetDir);
|
|
982
|
+
process.exit(0);
|
|
983
|
+
}
|
|
984
|
+
if (pwaResponse.enabled) {
|
|
985
|
+
selectedFeatures.push("pwa");
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
features = resolveFeatureSelection(selectedFeatures);
|
|
989
|
+
|
|
990
|
+
const requestedFeatures = [...selectedFeatures];
|
|
991
|
+
const autoIncluded = features.filter(
|
|
992
|
+
(feature) => !requestedFeatures.includes(feature),
|
|
993
|
+
);
|
|
994
|
+
if (autoIncluded.length > 0) {
|
|
995
|
+
console.log(
|
|
996
|
+
chalk.yellow(
|
|
997
|
+
` โน Automatically included required packages: ${autoIncluded.join(", ")}.`,
|
|
998
|
+
),
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const scaffoldPackages = getWorkspacePackagesForFeatures(features);
|
|
1004
|
+
const pairedTemplates = getPairedTemplatesForFeatures(features);
|
|
1005
|
+
let allowSignup;
|
|
1006
|
+
|
|
1007
|
+
if (requestedSignupPreference !== undefined) {
|
|
1008
|
+
allowSignup = requestedSignupPreference;
|
|
1009
|
+
console.log(
|
|
1010
|
+
chalk.cyan(
|
|
1011
|
+
`\n ๐ Public signup: ${allowSignup ? "enabled" : "disabled"} (from flag)`,
|
|
1012
|
+
),
|
|
1013
|
+
);
|
|
1014
|
+
} else if (!options.prompts) {
|
|
1015
|
+
allowSignup = true;
|
|
1016
|
+
console.log(
|
|
1017
|
+
chalk.cyan(
|
|
1018
|
+
"\n ๐ Public signup: enabled (default, non-interactive mode)",
|
|
1019
|
+
),
|
|
1020
|
+
);
|
|
1021
|
+
} else {
|
|
1022
|
+
console.log(chalk.cyan("\n ๐ Configuring authentication...\n"));
|
|
1023
|
+
const authResponse = await prompts({
|
|
1024
|
+
type: "confirm",
|
|
1025
|
+
name: "allowSignup",
|
|
1026
|
+
message: "Allow public self-service signup?",
|
|
1027
|
+
initial: true,
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
if (typeof authResponse.allowSignup !== "boolean") {
|
|
1031
|
+
console.log(chalk.yellow("\nโ ๏ธ Setup cancelled."));
|
|
1032
|
+
await fs.remove(targetDir);
|
|
1033
|
+
process.exit(0);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
allowSignup = authResponse.allowSignup;
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const signupEnvValue = allowSignup ? "true" : "false";
|
|
1040
|
+
|
|
1041
|
+
// Step 6: Copy selected optional packages
|
|
1042
|
+
if (scaffoldPackages.length > 0) {
|
|
1043
|
+
console.log(chalk.cyan("\n ๐ Setting up optional packages..."));
|
|
1044
|
+
|
|
1045
|
+
for (const packageName of scaffoldPackages) {
|
|
1046
|
+
const templatePath = path.join(templatesDir, packageName);
|
|
1047
|
+
if (!(await fs.pathExists(templatePath))) {
|
|
1048
|
+
console.log(
|
|
1049
|
+
chalk.yellow(
|
|
1050
|
+
` โ ${packageName} (template not yet available - skipped)`,
|
|
1051
|
+
),
|
|
1052
|
+
);
|
|
1053
|
+
continue;
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
const packageDir = path.join(targetDir, "packages", packageName);
|
|
1057
|
+
await fs.ensureDir(packageDir);
|
|
1058
|
+
await copyTemplate(packageName, packageDir);
|
|
1059
|
+
|
|
1060
|
+
// Update package.json with proper scope
|
|
1061
|
+
const packageJsonPath = path.join(packageDir, "package.json");
|
|
1062
|
+
await updatePackageJsonName(packageJsonPath, scope);
|
|
1063
|
+
|
|
1064
|
+
console.log(chalk.green(` โ ${packageName}`));
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// If jobs selected, also scaffold apps/worker from its template
|
|
1068
|
+
if (pairedTemplates.includes("worker")) {
|
|
1069
|
+
const workerDir = path.join(targetDir, "apps", "worker");
|
|
1070
|
+
await fs.ensureDir(workerDir);
|
|
1071
|
+
await copyTemplate("worker", workerDir);
|
|
1072
|
+
console.log(chalk.green(` โ worker (paired with jobs)`));
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Step 6b: Copy PWA files to web app if selected
|
|
1077
|
+
if (features.includes("pwa")) {
|
|
1078
|
+
console.log(chalk.cyan("\n ๐ Setting up PWA support..."));
|
|
1079
|
+
const pwaTemplateDir = path.join(templatesDir, "pwa");
|
|
1080
|
+
const webDir = path.join(targetDir, "apps/web");
|
|
1081
|
+
|
|
1082
|
+
// Replace the base manifest.js. Next.js serves app/manifest.js at
|
|
1083
|
+
// /manifest.webmanifest; adding a sibling app/manifest.json made the
|
|
1084
|
+
// build fail with "Cannot find module for page: /manifest.webmanifest".
|
|
1085
|
+
await fs.copy(
|
|
1086
|
+
path.join(pwaTemplateDir, "app/manifest.js"),
|
|
1087
|
+
path.join(webDir, "src/app/manifest.js"),
|
|
1088
|
+
);
|
|
1089
|
+
|
|
1090
|
+
// Point the PWA manifest at this project's scope.
|
|
1091
|
+
await substitutePlaceholders(path.join(webDir, "src/app/manifest.js"), {
|
|
1092
|
+
__QUARK_SCOPE__: scope,
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
// Copy sw.js to public/
|
|
1096
|
+
await fs.copy(
|
|
1097
|
+
path.join(pwaTemplateDir, "public/sw.js"),
|
|
1098
|
+
path.join(webDir, "public/sw.js"),
|
|
1099
|
+
);
|
|
1100
|
+
|
|
1101
|
+
// Copy PWARegister client component
|
|
1102
|
+
await fs.copy(
|
|
1103
|
+
path.join(pwaTemplateDir, "app/_components/PWARegister.js"),
|
|
1104
|
+
path.join(webDir, "src/app/_components/PWARegister.js"),
|
|
1105
|
+
);
|
|
1106
|
+
|
|
1107
|
+
// Inject PWARegister into layout.js
|
|
1108
|
+
const layoutPath = path.join(webDir, "src/app/layout.js");
|
|
1109
|
+
let layout = await fs.readFile(layoutPath, "utf-8");
|
|
1110
|
+
|
|
1111
|
+
// Add import (after the last existing import)
|
|
1112
|
+
layout = layout.replace(
|
|
1113
|
+
/import UmamiWebVitals from/,
|
|
1114
|
+
'import PWARegister from "./_components/PWARegister.js";\nimport UmamiWebVitals from',
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
// Add viewport export (before generateMetadata)
|
|
1118
|
+
layout = layout.replace(
|
|
1119
|
+
/export function generateMetadata/,
|
|
1120
|
+
'export const viewport = {\n\tthemeColor: "#000000",\n};\n\nexport function generateMetadata',
|
|
1121
|
+
);
|
|
1122
|
+
|
|
1123
|
+
// Add <PWARegister /> to body (before {children})
|
|
1124
|
+
layout = layout.replace(/<body>/, "<body>\n\t\t\t<PWARegister />");
|
|
1125
|
+
|
|
1126
|
+
await fs.writeFile(layoutPath, layout);
|
|
1127
|
+
|
|
1128
|
+
console.log(
|
|
1129
|
+
chalk.green(" โ PWA support enabled (manifest + service worker)"),
|
|
1130
|
+
);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
// Step 7: Update all package.json dependencies to use correct scope
|
|
1134
|
+
console.log(chalk.cyan("\n ๐ง Updating app dependencies..."));
|
|
1135
|
+
|
|
1136
|
+
// Collect all package.json files that need scope replacement (apps + packages)
|
|
1137
|
+
const allPkgPaths = [
|
|
1138
|
+
path.join(targetDir, "apps", "web", "package.json"),
|
|
1139
|
+
// Worker is only present when jobs is selected
|
|
1140
|
+
...(features.includes("jobs")
|
|
1141
|
+
? [path.join(targetDir, "apps", "worker", "package.json")]
|
|
1142
|
+
: []),
|
|
1143
|
+
|
|
1144
|
+
// Also update cross-dependencies in scaffolded packages (e.g. db โ config)
|
|
1145
|
+
...[...REQUIRED_PACKAGES, ...scaffoldPackages].map((pkg) =>
|
|
1146
|
+
path.join(targetDir, "packages", pkg, "package.json"),
|
|
1147
|
+
),
|
|
1148
|
+
];
|
|
1149
|
+
|
|
1150
|
+
for (const pkgPath of allPkgPaths) {
|
|
1151
|
+
if (await fs.pathExists(pkgPath)) {
|
|
1152
|
+
const pkg = await fs.readJSON(pkgPath);
|
|
1153
|
+
// Rename package name if it uses @myquark/ placeholder scope
|
|
1154
|
+
if (pkg.name?.startsWith("@myquark/")) {
|
|
1155
|
+
const shortName = pkg.name.replace("@myquark/", "");
|
|
1156
|
+
pkg.name = `@${scope}/${shortName}`;
|
|
1157
|
+
}
|
|
1158
|
+
replaceDepsScope(pkg.dependencies, scope, scaffoldPackages);
|
|
1159
|
+
replaceDepsScope(pkg.devDependencies, scope, scaffoldPackages);
|
|
1160
|
+
await fs.writeFile(pkgPath, `${JSON.stringify(pkg, null, " ")}\n`);
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Also rename root package.json
|
|
1165
|
+
const rootPkgPath = path.join(targetDir, "package.json");
|
|
1166
|
+
if (await fs.pathExists(rootPkgPath)) {
|
|
1167
|
+
const rootPkg = await fs.readJSON(rootPkgPath);
|
|
1168
|
+
rootPkg.name = `@${scope}/root`;
|
|
1169
|
+
await fs.writeFile(
|
|
1170
|
+
rootPkgPath,
|
|
1171
|
+
`${JSON.stringify(rootPkg, null, " ")}\n`,
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
console.log(chalk.green(` โ App dependencies updated`));
|
|
1176
|
+
|
|
1177
|
+
// Step 7b: Replace workspace package imports in source files
|
|
1178
|
+
console.log(chalk.cyan("\n ๐ Updating import paths..."));
|
|
1179
|
+
await replaceImportsInSourceFiles(targetDir, scope);
|
|
1180
|
+
console.log(chalk.green(` โ Import paths updated`));
|
|
1181
|
+
|
|
1182
|
+
// Step 7c: Patch next.config.js to remove unselected feature transpilePackages entries
|
|
1183
|
+
await patchNextConfig(
|
|
1184
|
+
path.join(targetDir, "apps", "web"),
|
|
1185
|
+
scope,
|
|
1186
|
+
scaffoldPackages,
|
|
1187
|
+
);
|
|
1188
|
+
console.log(chalk.green(` โ next.config.js patched`));
|
|
1189
|
+
|
|
1190
|
+
// Step 7d: Strip jobs-related code from register route when jobs not selected
|
|
1191
|
+
if (!features.includes("jobs")) {
|
|
1192
|
+
const registerPath = path.join(
|
|
1193
|
+
targetDir,
|
|
1194
|
+
"apps",
|
|
1195
|
+
"web",
|
|
1196
|
+
"src",
|
|
1197
|
+
"app",
|
|
1198
|
+
"api",
|
|
1199
|
+
"auth",
|
|
1200
|
+
"register",
|
|
1201
|
+
"route.js",
|
|
1202
|
+
);
|
|
1203
|
+
if (await fs.pathExists(registerPath)) {
|
|
1204
|
+
let content = await fs.readFile(registerPath, "utf-8");
|
|
1205
|
+
// Remove the jobs import line
|
|
1206
|
+
content = content.replace(
|
|
1207
|
+
/import\s*\{[^}]*\}\s*from\s*["']@[^/]+\/jobs["'];?\n/,
|
|
1208
|
+
"",
|
|
1209
|
+
);
|
|
1210
|
+
// Remove createQueue from the core import if present
|
|
1211
|
+
content = content.replace(/\tcreateQueue,\n/, "");
|
|
1212
|
+
// Remove the @quark:start:jobs ... @quark:end:jobs block (inclusive)
|
|
1213
|
+
content = content.replace(
|
|
1214
|
+
/[ \t]*\/\/ @quark:start:jobs[\s\S]*?\/\/ @quark:end:jobs\n?/,
|
|
1215
|
+
"",
|
|
1216
|
+
);
|
|
1217
|
+
await fs.writeFile(registerPath, content);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Step 8: Create .env.example file
|
|
1222
|
+
console.log(chalk.cyan("\n ๐ Creating environment configuration..."));
|
|
1223
|
+
const envExampleTemplate = `# โ ๏ธ IMPORTANT: Copy this file to .env and fill in the values for your environment.
|
|
1224
|
+
# NEVER commit the .env file to version control - it contains secrets!
|
|
1225
|
+
# $ cp .env.example .env
|
|
1226
|
+
|
|
1227
|
+
# --- Environment ---
|
|
1228
|
+
# Supported: development, test, staging, production (default: development)
|
|
1229
|
+
# NODE_ENV=development
|
|
1230
|
+
|
|
1231
|
+
# --- Database Configuration ---
|
|
1232
|
+
# These map to the service names in docker-compose.yml
|
|
1233
|
+
# โ ๏ธ SECURITY WARNING: Change these default passwords in production!
|
|
1234
|
+
# Generate strong passwords with: openssl rand -base64 32
|
|
1235
|
+
POSTGRES_HOST=localhost
|
|
1236
|
+
POSTGRES_PORT=5432
|
|
1237
|
+
POSTGRES_USER=${scope}_user
|
|
1238
|
+
POSTGRES_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
|
1239
|
+
POSTGRES_DB=${scope}_dev
|
|
1240
|
+
# Optional: Set DATABASE_URL to override the dynamic construction above
|
|
1241
|
+
# DATABASE_URL="postgresql://${scope}_user:CHANGE_ME_TO_STRONG_PASSWORD@localhost:5432/${scope}_dev?schema=public"
|
|
1242
|
+
|
|
1243
|
+
# --- Database Pool Configuration ---
|
|
1244
|
+
# Connection pool settings are managed automatically. Customize if needed:
|
|
1245
|
+
# For advanced tuning, see Prisma connection pool documentation.
|
|
1246
|
+
|
|
1247
|
+
# --- Redis Configuration ---
|
|
1248
|
+
REDIS_HOST=localhost
|
|
1249
|
+
REDIS_PORT=6379
|
|
1250
|
+
# Optional: Set REDIS_URL to override the dynamic construction above
|
|
1251
|
+
# REDIS_URL="redis://localhost:6379"
|
|
1252
|
+
|
|
1253
|
+
# --- Mail Configuration ---
|
|
1254
|
+
# Email can be sent via SMTP (local or production), Resend, or Zeptomail.
|
|
1255
|
+
# Choose one provider below based on your needs.
|
|
1256
|
+
|
|
1257
|
+
# Development: Mailpit local SMTP (defaults below work with docker-compose)
|
|
1258
|
+
MAIL_HOST=localhost
|
|
1259
|
+
MAIL_SMTP_PORT=1025
|
|
1260
|
+
MAIL_UI_PORT=8025
|
|
1261
|
+
|
|
1262
|
+
# Production SMTP: Set these instead of MAIL_* when using a real SMTP relay
|
|
1263
|
+
# SMTP_HOST=smtp.example.com
|
|
1264
|
+
# SMTP_PORT=587
|
|
1265
|
+
# SMTP_SECURE=true
|
|
1266
|
+
# SMTP_USER=your_smtp_user
|
|
1267
|
+
# SMTP_PASSWORD=your_smtp_password
|
|
1268
|
+
|
|
1269
|
+
# --- Email Provider Selection ---
|
|
1270
|
+
# Choose one: "smtp" (default), "resend", or "zeptomail"
|
|
1271
|
+
# EMAIL_PROVIDER=smtp
|
|
1272
|
+
# EMAIL_FROM=App Name <noreply@yourdomain.com>
|
|
1273
|
+
|
|
1274
|
+
# Zeptomail (recommended for production)
|
|
1275
|
+
# Get started at: https://www.zoho.com/zeptomail/
|
|
1276
|
+
# Your token is shown in Zeptomail console and includes the "Zoho-enczapikey" prefix:
|
|
1277
|
+
# e.g. ZEPTOMAIL_TOKEN=Zoho-enczapikey <your_key_here>
|
|
1278
|
+
# ZEPTOMAIL_TOKEN=Zoho-enczapikey your_zeptomail_api_key
|
|
1279
|
+
# ZEPTOMAIL_URL=https://api.zeptomail.com # Base URL; /v1.1/email is appended in code
|
|
1280
|
+
# ZEPTOMAIL_BOUNCE_EMAIL=bounce@yourdomain.com # optional
|
|
1281
|
+
|
|
1282
|
+
# Resend (alternative provider)
|
|
1283
|
+
# Get your API key at: https://resend.com/api-keys
|
|
1284
|
+
# RESEND_API_KEY=re_xxxxxxxxxxxxx
|
|
1285
|
+
|
|
1286
|
+
# --- Application URL ---
|
|
1287
|
+
# Canonical origin for the app. Auth.js pins every sign-in redirect and the
|
|
1288
|
+
# client-side Auth.js base URL to this, so it must match the origin you browse
|
|
1289
|
+
# on exactly (scheme + host + port).
|
|
1290
|
+
# In development it falls back to http://localhost:PORT - set it explicitly
|
|
1291
|
+
# whenever you use something else (127.0.0.1, a LAN IP, a tunnel, a different
|
|
1292
|
+
# port), otherwise the session cookie is issued for one host and the
|
|
1293
|
+
# post-sign-in redirect points at another, and you land back signed out.
|
|
1294
|
+
# In production, set this to your real domain:
|
|
1295
|
+
# APP_URL=https://yourdomain.com
|
|
1296
|
+
|
|
1297
|
+
# --- Application Identity ---
|
|
1298
|
+
# APP_NAME is used in metadata, emails, and page titles.
|
|
1299
|
+
# โ ๏ธ APP_DESCRIPTION affects SEO snippets - update before production.
|
|
1300
|
+
APP_NAME=${appDisplayName}
|
|
1301
|
+
APP_DESCRIPTION=${appDescription}
|
|
1302
|
+
|
|
1303
|
+
# --- NextAuth Configuration ---
|
|
1304
|
+
# โ ๏ธ CRITICAL: Generate a secure secret with: openssl rand -base64 32
|
|
1305
|
+
# This secret is used to encrypt JWT tokens and session data.
|
|
1306
|
+
# Must be at least 32 characters or startup validation fails.
|
|
1307
|
+
NEXTAUTH_SECRET=CHANGE_ME_TO_A_32_CHARACTERS_OR_LONGER_SECRET
|
|
1308
|
+
|
|
1309
|
+
# NextAuth callback URL. Normally leave this unset: it is derived from APP_URL.
|
|
1310
|
+
# Only set it if the auth endpoints live on a different origin, and never give
|
|
1311
|
+
# it a path - it must be a bare origin (scheme + host + port).
|
|
1312
|
+
# NEXTAUTH_URL=https://yourdomain.com
|
|
1313
|
+
|
|
1314
|
+
# --- Authentication ---
|
|
1315
|
+
# Toggle during development without re-scaffolding.
|
|
1316
|
+
AUTH_ALLOW_SIGNUP=${signupEnvValue}
|
|
1317
|
+
|
|
1318
|
+
# --- OAuth Providers (Not Yet Implemented) ---
|
|
1319
|
+
# OAuth support is planned for a future release.
|
|
1320
|
+
# GitHub OAuth - Get credentials at: https://github.com/settings/developers
|
|
1321
|
+
# GITHUB_ID=your_github_client_id
|
|
1322
|
+
# GITHUB_SECRET=your_github_client_secret
|
|
1323
|
+
|
|
1324
|
+
# Google OAuth - Get credentials at: https://console.cloud.google.com/apis/credentials
|
|
1325
|
+
# GOOGLE_CLIENT_ID=your_google_client_id
|
|
1326
|
+
# GOOGLE_CLIENT_SECRET=your_google_client_secret
|
|
1327
|
+
|
|
1328
|
+
# --- Web App Configuration ---
|
|
1329
|
+
PORT=3000
|
|
1330
|
+
|
|
1331
|
+
# Development-only: allow extra hosts that can load Next.js dev endpoints
|
|
1332
|
+
# from LAN/VPN devices. Comma-separate additional hosts or IPs as needed.
|
|
1333
|
+
# NEXT_DEV_ALLOWED_ORIGINS=192.168.1.9,my-macbook.local
|
|
1334
|
+
# Legacy alias still recognized: ALLOWED_DEV_ORIGINS=192.168.1.9,my-macbook.local
|
|
1335
|
+
|
|
1336
|
+
# --- Worker Configuration ---
|
|
1337
|
+
WORKER_CONCURRENCY=5
|
|
1338
|
+
|
|
1339
|
+
# --- File Storage ---
|
|
1340
|
+
# Provider: "local" (default) or "s3" (S3-compatible: AWS S3, Cloudflare R2, MinIO)
|
|
1341
|
+
STORAGE_PROVIDER=local
|
|
1342
|
+
# Local storage directory (only when STORAGE_PROVIDER=local)
|
|
1343
|
+
# STORAGE_LOCAL_DIR=./uploads
|
|
1344
|
+
|
|
1345
|
+
# S3 / Cloudflare R2 (only when STORAGE_PROVIDER=s3)
|
|
1346
|
+
# S3_BUCKET=your-bucket-name
|
|
1347
|
+
# S3_REGION=auto # Use "auto" for Cloudflare R2
|
|
1348
|
+
# S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
|
|
1349
|
+
# S3_ACCESS_KEY_ID=your-access-key
|
|
1350
|
+
# S3_SECRET_ACCESS_KEY=your-secret-key
|
|
1351
|
+
# S3_PUBLIC_URL=https://your-public-bucket-domain.com
|
|
1352
|
+
|
|
1353
|
+
# --- Upload Limits ---
|
|
1354
|
+
# UPLOAD_MAX_SIZE=10485760 # Max file size in bytes (default: 10MB)
|
|
1355
|
+
# UPLOAD_ALLOWED_TYPES=image/jpeg,image/png,image/gif,image/webp,image/avif,image/svg+xml,application/pdf
|
|
1356
|
+
|
|
1357
|
+
# --- Rate Limiting & Security ---
|
|
1358
|
+
# RATE_LIMIT_MAX=100 # Max requests per window (default: 1000 dev, 100 prod)
|
|
1359
|
+
# RATE_LIMIT_WINDOW_MS=900000 # Window in ms (default: 15 minutes)
|
|
1360
|
+
# API_BODY_SIZE_LIMIT=2097152 # 2MB (default)
|
|
1361
|
+
# UPLOAD_SIZE_LIMIT=10485760 # 10MB proxy-level limit (default)
|
|
1362
|
+
|
|
1363
|
+
# --- Logging & Cache ---
|
|
1364
|
+
# LOG_LEVEL=debug # debug, info, warn, error (default: debug dev, info prod)
|
|
1365
|
+
# CACHE_TTL=60 # Default cache TTL in seconds (default: 60 dev, 600 prod)
|
|
1366
|
+
|
|
1367
|
+
# --- Database Seeding ---
|
|
1368
|
+
# SEED_PROFILE=dev # Options: dev (default, includes audit logs + sample job), minimal (users only)
|
|
1369
|
+
`;
|
|
1370
|
+
await fs.writeFile(
|
|
1371
|
+
path.join(targetDir, ".env.example"),
|
|
1372
|
+
envExampleTemplate,
|
|
1373
|
+
);
|
|
1374
|
+
console.log(chalk.green(` โ .env.example`));
|
|
1375
|
+
|
|
1376
|
+
// Step 9: Find available ports and generate .env
|
|
1377
|
+
console.log(chalk.cyan("\n ๐ Checking port availability..."));
|
|
1378
|
+
const postgresPort = await findAvailablePort(5432);
|
|
1379
|
+
const redisPort = await findAvailablePort(6379);
|
|
1380
|
+
const mailSmtpPort = await findAvailablePort(1025);
|
|
1381
|
+
const mailUiPort = await findAvailablePort(8025);
|
|
1382
|
+
const webPort = await findAvailablePort(3000);
|
|
1383
|
+
|
|
1384
|
+
const portChanges = [];
|
|
1385
|
+
if (postgresPort !== 5432)
|
|
1386
|
+
portChanges.push(`PostgreSQL: ${postgresPort}`);
|
|
1387
|
+
if (redisPort !== 6379) portChanges.push(`Redis: ${redisPort}`);
|
|
1388
|
+
if (mailSmtpPort !== 1025) portChanges.push(`Mail SMTP: ${mailSmtpPort}`);
|
|
1389
|
+
if (mailUiPort !== 8025) portChanges.push(`Mail UI: ${mailUiPort}`);
|
|
1390
|
+
if (webPort !== 3000) portChanges.push(`Web: ${webPort}`);
|
|
1391
|
+
|
|
1392
|
+
if (portChanges.length > 0) {
|
|
1393
|
+
console.log(chalk.yellow(` โก Ports adjusted to avoid conflicts:`));
|
|
1394
|
+
for (const change of portChanges) {
|
|
1395
|
+
console.log(chalk.yellow(` โข ${change}`));
|
|
1396
|
+
}
|
|
1397
|
+
} else {
|
|
1398
|
+
console.log(chalk.green(` โ All default ports available`));
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
console.log(chalk.cyan("\n ๐ Generating secure environment file..."));
|
|
1402
|
+
|
|
1403
|
+
// Generate secure random values
|
|
1404
|
+
const dbPassword = generateSecurePassword(24);
|
|
1405
|
+
const nextAuthSecret = generateSecureSecret(32);
|
|
1406
|
+
|
|
1407
|
+
// Create .env with auto-generated secure values
|
|
1408
|
+
const envContent = `# --- Database Configuration ---
|
|
1409
|
+
POSTGRES_HOST=localhost
|
|
1410
|
+
POSTGRES_PORT=${postgresPort}
|
|
1411
|
+
POSTGRES_USER=${scope}_user
|
|
1412
|
+
POSTGRES_PASSWORD=${dbPassword}
|
|
1413
|
+
POSTGRES_DB=${scope}_dev
|
|
1414
|
+
|
|
1415
|
+
# --- Redis Configuration ---
|
|
1416
|
+
REDIS_HOST=localhost
|
|
1417
|
+
REDIS_PORT=${redisPort}
|
|
1418
|
+
|
|
1419
|
+
# --- Mail Configuration ---
|
|
1420
|
+
MAIL_HOST=localhost
|
|
1421
|
+
MAIL_SMTP_PORT=${mailSmtpPort}
|
|
1422
|
+
MAIL_UI_PORT=${mailUiPort}
|
|
1423
|
+
|
|
1424
|
+
# --- Application Identity ---
|
|
1425
|
+
# APP_NAME is used in metadata, emails, and page titles.
|
|
1426
|
+
# โ ๏ธ APP_DESCRIPTION affects SEO snippets - update before production.
|
|
1427
|
+
APP_NAME=${appDisplayName}
|
|
1428
|
+
APP_DESCRIPTION=${appDescription}
|
|
1429
|
+
|
|
1430
|
+
# --- NextAuth Configuration ---
|
|
1431
|
+
NEXTAUTH_SECRET=${nextAuthSecret}
|
|
1432
|
+
|
|
1433
|
+
# --- Authentication ---
|
|
1434
|
+
# Toggle during development without re-scaffolding.
|
|
1435
|
+
AUTH_ALLOW_SIGNUP=${signupEnvValue}
|
|
1436
|
+
|
|
1437
|
+
# --- Web App Configuration ---
|
|
1438
|
+
# APP_URL is derived from PORT automatically in development.
|
|
1439
|
+
# In production, set APP_URL explicitly in your environment.
|
|
1440
|
+
PORT=${webPort}
|
|
1441
|
+
|
|
1442
|
+
# --- Worker Configuration ---
|
|
1443
|
+
WORKER_CONCURRENCY=5
|
|
1444
|
+
|
|
1445
|
+
# --- File Storage ---
|
|
1446
|
+
STORAGE_PROVIDER=local
|
|
1447
|
+
|
|
1448
|
+
# --- Database Seeding ---
|
|
1449
|
+
# SEED_PROFILE=dev # Options: dev (default), minimal (users only - use for production initial seed)
|
|
1450
|
+
#`;
|
|
1451
|
+
await fs.writeFile(path.join(targetDir, ".env"), envContent);
|
|
1452
|
+
console.log(
|
|
1453
|
+
chalk.green(` โ .env (with auto-generated secure secrets)`),
|
|
1454
|
+
);
|
|
1455
|
+
|
|
1456
|
+
// Step 11: Create .quark-link.json to track Quark version
|
|
1457
|
+
const scaffoldedAt =
|
|
1458
|
+
process.env.QUARK_SCAFFOLD_DATE || new Date().toISOString();
|
|
1459
|
+
const quarkLinkJson = {
|
|
1460
|
+
quarkVersion: process.env.QUARK_VERSION || "latest",
|
|
1461
|
+
quarkSourcePath: process.env.QUARK_SOURCE_PATH || "../../quark",
|
|
1462
|
+
scaffoldedDate: scaffoldedAt,
|
|
1463
|
+
projectName,
|
|
1464
|
+
requiredPackages: REQUIRED_PACKAGES,
|
|
1465
|
+
packages: [...new Set([...REQUIRED_PACKAGES, ...features])],
|
|
1466
|
+
featureVersions: {},
|
|
1467
|
+
authAllowSignup: allowSignup,
|
|
1468
|
+
// Track that worker is paired with jobs (not independently selectable)
|
|
1469
|
+
hasWorker: features.includes("jobs"),
|
|
1470
|
+
brief,
|
|
1471
|
+
};
|
|
1472
|
+
await fs.writeFile(
|
|
1473
|
+
path.join(targetDir, ".quark-link.json"),
|
|
1474
|
+
JSON.stringify(quarkLinkJson, null, " "),
|
|
1475
|
+
);
|
|
1476
|
+
console.log(chalk.green(` โ .quark-link.json`));
|
|
1477
|
+
|
|
1478
|
+
// Step 10b + 10c: Generate all AI coding tool context files
|
|
1479
|
+
console.log(chalk.cyan("\n ๐ค Generating AI context files..."));
|
|
1480
|
+
|
|
1481
|
+
// Build shared variable map (used by SKILL.md, CLAUDE.md, .cursor/rules, copilot-instructions)
|
|
1482
|
+
const scaffoldDate = normalizeScaffoldDate(scaffoldedAt);
|
|
1483
|
+
const optionalLines = scaffoldPackages
|
|
1484
|
+
.map((f) => {
|
|
1485
|
+
const labels = {
|
|
1486
|
+
ui: "Shared UI components",
|
|
1487
|
+
jobs: "Job queue definitions",
|
|
1488
|
+
pwa: "PWA support",
|
|
1489
|
+
};
|
|
1490
|
+
return `โ โโโ ${f}/ # ${labels[f] || f}`;
|
|
1491
|
+
})
|
|
1492
|
+
.join("\n");
|
|
1493
|
+
const optionalAppLines = buildOptionalAppLines(features);
|
|
1494
|
+
const optionalBlock = optionalLines ? `${optionalLines}\n` : "";
|
|
1495
|
+
const featureRows = buildFeatureRows(features);
|
|
1496
|
+
const firstEditLines = buildFirstEditLines(features);
|
|
1497
|
+
const featureGuideLines = buildFeatureGuideLines(features);
|
|
1498
|
+
|
|
1499
|
+
// Every __QUARK_* placeholder used by scaffolded files. Keep this map as
|
|
1500
|
+
// the single source of truth so template files cannot ship with
|
|
1501
|
+
// un-substituted placeholders (guarded by scaffold-output.test.js).
|
|
1502
|
+
const placeholderValues = {
|
|
1503
|
+
__QUARK_SCOPE__: scope,
|
|
1504
|
+
__QUARK_PROJECT_NAME__: projectName,
|
|
1505
|
+
__QUARK_SCAFFOLD_DATE__: scaffoldDate,
|
|
1506
|
+
__QUARK_OPTIONAL_APPS__: optionalAppLines,
|
|
1507
|
+
__QUARK_OPTIONAL_PACKAGES__: optionalBlock,
|
|
1508
|
+
__QUARK_FEATURE_ROWS__: featureRows,
|
|
1509
|
+
__QUARK_FIRST_EDITS__: firstEditLines,
|
|
1510
|
+
__QUARK_FEATURE_GUIDES__: featureGuideLines,
|
|
1511
|
+
__QUARK_PROJECT_BRIEF__: brief,
|
|
1512
|
+
};
|
|
1513
|
+
|
|
1514
|
+
// Step 10b: Substitute variables in project-context SKILL.md
|
|
1515
|
+
const skillPath = path.join(
|
|
1516
|
+
targetDir,
|
|
1517
|
+
".github",
|
|
1518
|
+
"skills",
|
|
1519
|
+
"project-context",
|
|
1520
|
+
"SKILL.md",
|
|
1521
|
+
);
|
|
1522
|
+
if (await fs.pathExists(skillPath)) {
|
|
1523
|
+
await substitutePlaceholders(skillPath, placeholderValues);
|
|
1524
|
+
console.log(
|
|
1525
|
+
chalk.green(` โ .github/skills/project-context/SKILL.md`),
|
|
1526
|
+
);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
// Step 10c: Substitute variables in all AI coding tool context files
|
|
1530
|
+
const aiContextFiles = [
|
|
1531
|
+
"README.md",
|
|
1532
|
+
"CLAUDE.md",
|
|
1533
|
+
".cursor/rules/quark.mdc",
|
|
1534
|
+
".github/copilot-instructions.md",
|
|
1535
|
+
"docs/adr/README.md",
|
|
1536
|
+
];
|
|
1537
|
+
for (const relPath of aiContextFiles) {
|
|
1538
|
+
const filePath = path.join(targetDir, relPath);
|
|
1539
|
+
if (await fs.pathExists(filePath)) {
|
|
1540
|
+
await substitutePlaceholders(filePath, placeholderValues);
|
|
1541
|
+
console.log(chalk.green(` โ ${relPath}`));
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Step 10d: Substitute variables in scaffolded docs, Railway config,
|
|
1546
|
+
// and embedded skills (outside the AI-context file list above).
|
|
1547
|
+
const scaffoldAssetFiles = [
|
|
1548
|
+
"MAIN.md",
|
|
1549
|
+
".env.railway.example",
|
|
1550
|
+
".railway/railway.ts",
|
|
1551
|
+
];
|
|
1552
|
+
for (const relPath of scaffoldAssetFiles) {
|
|
1553
|
+
const filePath = path.join(targetDir, relPath);
|
|
1554
|
+
if (await fs.pathExists(filePath)) {
|
|
1555
|
+
await substitutePlaceholders(filePath, placeholderValues);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const skillsPlaceholderDir = harnessSkillDir
|
|
1560
|
+
? path.join(targetDir, harnessSkillDir)
|
|
1561
|
+
: path.join(targetDir, "skills");
|
|
1562
|
+
if (await fs.pathExists(skillsPlaceholderDir)) {
|
|
1563
|
+
await substitutePlaceholdersInMarkdownFiles(
|
|
1564
|
+
skillsPlaceholderDir,
|
|
1565
|
+
placeholderValues,
|
|
1566
|
+
);
|
|
1567
|
+
console.log(
|
|
1568
|
+
chalk.green(` โ Embedded skill placeholders substituted`),
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// Step 11: Initialize git repository
|
|
1573
|
+
if (process.env.QUARK_SKIP_GIT_INIT === "true") {
|
|
1574
|
+
console.log(chalk.dim("\n ยท Skipping git initialization"));
|
|
1575
|
+
} else if (enclosingRepo) {
|
|
1576
|
+
console.log(
|
|
1577
|
+
chalk.dim(
|
|
1578
|
+
"\n ยท Skipping git initialization (inside an existing repository)",
|
|
1579
|
+
),
|
|
1580
|
+
);
|
|
1581
|
+
} else {
|
|
1582
|
+
console.log(chalk.cyan("\n ๐ Setting up git..."));
|
|
1583
|
+
const gitResult = await initializeGit(targetDir);
|
|
1584
|
+
if (gitResult.initialized) {
|
|
1585
|
+
console.log(chalk.green(` โ Git initialized with initial commit`));
|
|
1586
|
+
} else if (gitResult.existingRoot) {
|
|
1587
|
+
console.log(
|
|
1588
|
+
chalk.green(
|
|
1589
|
+
` โ Using existing git repository: ${gitResult.existingRoot}`,
|
|
1590
|
+
),
|
|
1591
|
+
);
|
|
1592
|
+
|
|
1593
|
+
// GitHub Actions only reads workflow files from the repository
|
|
1594
|
+
// root, so warn when the scaffold's own workflows will be ignored.
|
|
1595
|
+
if (gitResult.existingRoot !== canonicalPath(targetDir)) {
|
|
1596
|
+
const relativeProjectPath = path.relative(
|
|
1597
|
+
gitResult.existingRoot,
|
|
1598
|
+
targetDir,
|
|
1599
|
+
);
|
|
1600
|
+
console.log(
|
|
1601
|
+
chalk.yellow(
|
|
1602
|
+
`\n โ ๏ธ Nested project: GitHub only runs workflows stored at the repository root.`,
|
|
1603
|
+
),
|
|
1604
|
+
);
|
|
1605
|
+
console.log(
|
|
1606
|
+
chalk.white(
|
|
1607
|
+
` ${relativeProjectPath}/.github/workflows/*.yml will be ignored by GitHub Actions.`,
|
|
1608
|
+
),
|
|
1609
|
+
);
|
|
1610
|
+
console.log(
|
|
1611
|
+
chalk.white(
|
|
1612
|
+
` To enable CI/deploys, copy them into ${gitResult.existingRoot}/.github/workflows/`,
|
|
1613
|
+
),
|
|
1614
|
+
);
|
|
1615
|
+
console.log(
|
|
1616
|
+
chalk.white(
|
|
1617
|
+
` and set working-directory: ${relativeProjectPath} on each job's steps.\n`,
|
|
1618
|
+
),
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Step 12: Run pnpm install
|
|
1625
|
+
if (!options.skipInstall) {
|
|
1626
|
+
console.log(chalk.cyan("\n ๐ฆ Installing dependencies..."));
|
|
1627
|
+
try {
|
|
1628
|
+
await execa("pnpm", ["install"], {
|
|
1629
|
+
cwd: targetDir,
|
|
1630
|
+
stdio: "inherit",
|
|
1631
|
+
});
|
|
1632
|
+
console.log(chalk.green(`\n โ Dependencies installed`));
|
|
1633
|
+
} catch (installError) {
|
|
1634
|
+
console.warn(
|
|
1635
|
+
chalk.yellow(
|
|
1636
|
+
`\n โ ๏ธ pnpm install failed: ${installError.message}`,
|
|
1637
|
+
),
|
|
1638
|
+
);
|
|
1639
|
+
console.warn(
|
|
1640
|
+
chalk.yellow(
|
|
1641
|
+
` Run 'pnpm install' manually after resolving the issue.`,
|
|
1642
|
+
),
|
|
1643
|
+
);
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
// Step 13: Generate Prisma client
|
|
1647
|
+
console.log(chalk.cyan("\n ๐๏ธ Generating Prisma client..."));
|
|
1648
|
+
try {
|
|
1649
|
+
await execa("pnpm", ["--filter", "db", "db:generate"], {
|
|
1650
|
+
cwd: targetDir,
|
|
1651
|
+
stdio: "inherit",
|
|
1652
|
+
});
|
|
1653
|
+
console.log(chalk.green(` โ Prisma client generated`));
|
|
1654
|
+
} catch (generateError) {
|
|
1655
|
+
console.warn(
|
|
1656
|
+
chalk.yellow(
|
|
1657
|
+
`\n โ ๏ธ Prisma generate failed: ${generateError.message}`,
|
|
1658
|
+
),
|
|
1659
|
+
);
|
|
1660
|
+
console.warn(
|
|
1661
|
+
chalk.yellow(` Run 'pnpm --filter db db:generate' manually.`),
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// Success message
|
|
1667
|
+
console.log(
|
|
1668
|
+
chalk.green.bold(
|
|
1669
|
+
`\nโ
Project "${projectName}" created successfully!\n`,
|
|
1670
|
+
),
|
|
1671
|
+
);
|
|
1672
|
+
|
|
1673
|
+
console.log(chalk.white(`๐ Project location: ${targetDir}\n`));
|
|
1674
|
+
console.log(chalk.cyan("Start here:"));
|
|
1675
|
+
console.log(chalk.white(` 1. cd ${projectName}`));
|
|
1676
|
+
console.log(chalk.white(` 2. docker compose up -d`));
|
|
1677
|
+
console.log(chalk.white(` 3. pnpm db:migrate`));
|
|
1678
|
+
console.log(chalk.white(` 4. pnpm db:seed`));
|
|
1679
|
+
console.log(chalk.white(` 5. pnpm dev\n`));
|
|
1680
|
+
console.log(
|
|
1681
|
+
chalk.dim(
|
|
1682
|
+
` Toggle signup any time in development by editing AUTH_ALLOW_SIGNUP in .env\n`,
|
|
1683
|
+
),
|
|
1684
|
+
);
|
|
1685
|
+
console.log(chalk.cyan("Build with AI:"));
|
|
1686
|
+
console.log(
|
|
1687
|
+
chalk.white(` Open CLAUDE.md in your AI tool, then tell it:`),
|
|
1688
|
+
);
|
|
1689
|
+
console.log(
|
|
1690
|
+
chalk.white(
|
|
1691
|
+
` "I'm building ${projectName} - [what it does]. Review CLAUDE.md and let's start."\n`,
|
|
1692
|
+
),
|
|
1693
|
+
);
|
|
1694
|
+
|
|
1695
|
+
console.log(
|
|
1696
|
+
chalk.dim(
|
|
1697
|
+
` ๐ github.com/usequark/quark โข Updates: npx @techstream/quark-create-app update\n`,
|
|
1698
|
+
),
|
|
1699
|
+
);
|
|
1700
|
+
} catch (error) {
|
|
1701
|
+
console.error(chalk.red(`\nโ Error creating project: ${error.message}`));
|
|
1702
|
+
console.error(chalk.dim(error.stack));
|
|
1703
|
+
|
|
1704
|
+
// Clean up on error
|
|
1705
|
+
if (await fs.pathExists(targetDir)) {
|
|
1706
|
+
await fs.remove(targetDir);
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
process.exit(1);
|
|
1710
|
+
}
|
|
1711
|
+
});
|
|
1712
|
+
|
|
1713
|
+
// ---------------------------------------------------------------------------
|
|
1714
|
+
// quark add <feature> - Add optional packages to an existing Quark project
|
|
1715
|
+
// ---------------------------------------------------------------------------
|
|
1716
|
+
|
|
1717
|
+
/**
|
|
1718
|
+
* Feature metadata: dependencies, packages, paired apps/routes, and starter.
|
|
1719
|
+
*
|
|
1720
|
+
* Two kinds of feature:
|
|
1721
|
+
* - `package` (default): scaffolds a workspace package (and optional paired
|
|
1722
|
+
* apps/routes). e.g. ui, jobs.
|
|
1723
|
+
* - `starter`: drops in a minified domain starter (generic Prisma model + CRUD
|
|
1724
|
+
* endpoint + skill) from `templates/starters/<starter>/`. No workspace
|
|
1725
|
+
* package, no paired routes.
|
|
1726
|
+
*/
|
|
1727
|
+
const FEATURE_META = {
|
|
1728
|
+
ui: { requires: [], packages: ["ui"], pairs: [] },
|
|
1729
|
+
jobs: { requires: [], packages: ["jobs"], pairs: ["worker"] },
|
|
1730
|
+
pwa: { requires: [], packages: [], pairs: [] },
|
|
1731
|
+
mobile: { requires: [], packages: [], pairs: [] },
|
|
1732
|
+
// Admin dashboard and domain verticals (bookings, crm, cms, ai) are not
|
|
1733
|
+
// scaffolded features. All embedded skills ship with every build and the
|
|
1734
|
+
// AI builds them on demand - see the skill index in <harness>/skills/.
|
|
1735
|
+
};
|
|
1736
|
+
|
|
1737
|
+
function resolveFeatureSelection(features) {
|
|
1738
|
+
const resolved = [];
|
|
1739
|
+
const resolvedSet = new Set();
|
|
1740
|
+
const visiting = new Set();
|
|
1741
|
+
|
|
1742
|
+
function visit(feature) {
|
|
1743
|
+
if (resolvedSet.has(feature)) return;
|
|
1744
|
+
if (visiting.has(feature)) {
|
|
1745
|
+
throw new Error(`Circular feature dependency detected for: ${feature}`);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
const meta = FEATURE_META[feature];
|
|
1749
|
+
if (!meta) return;
|
|
1750
|
+
|
|
1751
|
+
visiting.add(feature);
|
|
1752
|
+
for (const dependency of meta.requires) {
|
|
1753
|
+
visit(dependency);
|
|
1754
|
+
}
|
|
1755
|
+
visiting.delete(feature);
|
|
1756
|
+
|
|
1757
|
+
resolved.push(feature);
|
|
1758
|
+
resolvedSet.add(feature);
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
for (const feature of features) {
|
|
1762
|
+
visit(feature);
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
return resolved;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* Detect the project scope from existing package.json files.
|
|
1770
|
+
* Reads root package.json and extracts the scope from the name field.
|
|
1771
|
+
* @param {string} projectDir
|
|
1772
|
+
* @returns {Promise<string|null>}
|
|
1773
|
+
*/
|
|
1774
|
+
async function detectProjectScope(projectDir) {
|
|
1775
|
+
const rootPkgPath = path.join(projectDir, "package.json");
|
|
1776
|
+
if (!(await fs.pathExists(rootPkgPath))) return null;
|
|
1777
|
+
const rootPkg = await fs.readJSON(rootPkgPath);
|
|
1778
|
+
const match = rootPkg.name?.match(/^@([^/]+)\//);
|
|
1779
|
+
return match ? match[1] : null;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* Detect which optional features are already installed in a project.
|
|
1784
|
+
* @param {string} projectDir
|
|
1785
|
+
* @returns {Promise<string[]>}
|
|
1786
|
+
*/
|
|
1787
|
+
async function detectInstalledFeatures(projectDir) {
|
|
1788
|
+
const installed = [];
|
|
1789
|
+
// Skill-based verticals are tracked in .quark-link.json (no code is scaffolded).
|
|
1790
|
+
let quarkLinkPackages = [];
|
|
1791
|
+
try {
|
|
1792
|
+
const quarkLink = await fs.readJSON(
|
|
1793
|
+
path.join(projectDir, ".quark-link.json"),
|
|
1794
|
+
);
|
|
1795
|
+
quarkLinkPackages = quarkLink.packages ?? [];
|
|
1796
|
+
} catch {
|
|
1797
|
+
// No .quark-link.json โ fall through to directory detection.
|
|
1798
|
+
}
|
|
1799
|
+
for (const feature of Object.keys(FEATURE_META)) {
|
|
1800
|
+
const meta = FEATURE_META[feature];
|
|
1801
|
+
if (meta.skill) {
|
|
1802
|
+
if (quarkLinkPackages.includes(feature)) {
|
|
1803
|
+
installed.push(feature);
|
|
1804
|
+
}
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1807
|
+
// PWA has no workspace package โ detect by PWARegister component in web app
|
|
1808
|
+
if (feature === "pwa") {
|
|
1809
|
+
if (
|
|
1810
|
+
await fs.pathExists(
|
|
1811
|
+
path.join(
|
|
1812
|
+
projectDir,
|
|
1813
|
+
"apps",
|
|
1814
|
+
"web",
|
|
1815
|
+
"src",
|
|
1816
|
+
"app",
|
|
1817
|
+
"_components",
|
|
1818
|
+
"PWARegister.js",
|
|
1819
|
+
),
|
|
1820
|
+
)
|
|
1821
|
+
) {
|
|
1822
|
+
installed.push(feature);
|
|
1823
|
+
}
|
|
1824
|
+
continue;
|
|
1825
|
+
}
|
|
1826
|
+
const featureDir = path.join(projectDir, "packages", feature);
|
|
1827
|
+
if (await fs.pathExists(featureDir)) {
|
|
1828
|
+
installed.push(feature);
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return installed;
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
async function collectRelativeFilePaths(
|
|
1835
|
+
rootDir,
|
|
1836
|
+
currentDir = rootDir,
|
|
1837
|
+
results = [],
|
|
1838
|
+
) {
|
|
1839
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
1840
|
+
|
|
1841
|
+
for (const entry of entries) {
|
|
1842
|
+
if (entry.name === ".git" || entry.name === "node_modules") {
|
|
1843
|
+
continue;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
1847
|
+
const relativePath = path.relative(rootDir, fullPath);
|
|
1848
|
+
|
|
1849
|
+
if (entry.isDirectory()) {
|
|
1850
|
+
await collectRelativeFilePaths(rootDir, fullPath, results);
|
|
1851
|
+
continue;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
if (entry.isFile() && !SCAFFOLD_CHECK_IGNORED_FILES.has(relativePath)) {
|
|
1855
|
+
results.push(relativePath);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
return results.sort();
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
async function compareScaffoldManagedFiles(expectedDir, projectDir) {
|
|
1863
|
+
const expectedFiles = await collectRelativeFilePaths(expectedDir);
|
|
1864
|
+
const changed = [];
|
|
1865
|
+
const missing = [];
|
|
1866
|
+
|
|
1867
|
+
for (const relativePath of expectedFiles) {
|
|
1868
|
+
const expectedPath = path.join(expectedDir, relativePath);
|
|
1869
|
+
const projectPath = path.join(projectDir, relativePath);
|
|
1870
|
+
|
|
1871
|
+
if (!(await fs.pathExists(projectPath))) {
|
|
1872
|
+
missing.push(relativePath);
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
const projectStat = await fs.stat(projectPath);
|
|
1877
|
+
if (!projectStat.isFile()) {
|
|
1878
|
+
changed.push(relativePath);
|
|
1879
|
+
continue;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
const [expectedContent, projectContent] = await Promise.all([
|
|
1883
|
+
fs.readFile(expectedPath),
|
|
1884
|
+
fs.readFile(projectPath),
|
|
1885
|
+
]);
|
|
1886
|
+
|
|
1887
|
+
if (!expectedContent.equals(projectContent)) {
|
|
1888
|
+
changed.push(relativePath);
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
return {
|
|
1893
|
+
total: expectedFiles.length,
|
|
1894
|
+
changed,
|
|
1895
|
+
missing,
|
|
1896
|
+
};
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
async function createReferenceScaffold(projectDir, quarkLink) {
|
|
1900
|
+
const projectName = quarkLink.projectName || path.basename(projectDir);
|
|
1901
|
+
const linkedFeatures = resolveFeatureSelection(
|
|
1902
|
+
Array.isArray(quarkLink.packages) ? quarkLink.packages : [],
|
|
1903
|
+
);
|
|
1904
|
+
const detectedFeatures = resolveFeatureSelection(
|
|
1905
|
+
await detectInstalledFeatures(projectDir),
|
|
1906
|
+
);
|
|
1907
|
+
const comparisonFeatures = resolveFeatureSelection([
|
|
1908
|
+
...linkedFeatures,
|
|
1909
|
+
...detectedFeatures,
|
|
1910
|
+
]);
|
|
1911
|
+
const allowSignup =
|
|
1912
|
+
typeof quarkLink.authAllowSignup === "boolean"
|
|
1913
|
+
? quarkLink.authAllowSignup
|
|
1914
|
+
: true;
|
|
1915
|
+
const tempRoot = await fs.mkdtemp(
|
|
1916
|
+
path.join(os.tmpdir(), "quark-scaffold-check-"),
|
|
1917
|
+
);
|
|
1918
|
+
const args = [
|
|
1919
|
+
path.join(__dirname, "index.js"),
|
|
1920
|
+
projectName,
|
|
1921
|
+
"--no-prompts",
|
|
1922
|
+
"--skip-install",
|
|
1923
|
+
"--skip-docker",
|
|
1924
|
+
"--packages",
|
|
1925
|
+
comparisonFeatures.join(","),
|
|
1926
|
+
"--signup",
|
|
1927
|
+
allowSignup ? "enabled" : "disabled",
|
|
1928
|
+
];
|
|
1929
|
+
|
|
1930
|
+
try {
|
|
1931
|
+
await execa(process.execPath, args, {
|
|
1932
|
+
cwd: tempRoot,
|
|
1933
|
+
env: {
|
|
1934
|
+
...process.env,
|
|
1935
|
+
QUARK_SCAFFOLD_DATE:
|
|
1936
|
+
quarkLink.scaffoldedDate || new Date().toISOString(),
|
|
1937
|
+
QUARK_SKIP_GIT_INIT: "true",
|
|
1938
|
+
},
|
|
1939
|
+
stdio: "pipe",
|
|
1940
|
+
});
|
|
1941
|
+
} catch (error) {
|
|
1942
|
+
const details = error.stderr || error.stdout || error.shortMessage;
|
|
1943
|
+
await fs.remove(tempRoot);
|
|
1944
|
+
throw new Error(
|
|
1945
|
+
details
|
|
1946
|
+
? `Could not generate the reference scaffold.\n${details}`
|
|
1947
|
+
: "Could not generate the reference scaffold.",
|
|
1948
|
+
);
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
return {
|
|
1952
|
+
tempRoot,
|
|
1953
|
+
expectedDir: path.join(tempRoot, projectName),
|
|
1954
|
+
linkedFeatures,
|
|
1955
|
+
detectedFeatures,
|
|
1956
|
+
comparisonFeatures,
|
|
1957
|
+
};
|
|
1958
|
+
}
|
|
1959
|
+
|
|
1960
|
+
async function reportScaffoldDrift(projectDir, quarkLink) {
|
|
1961
|
+
console.log(chalk.yellow("Checking scaffold-managed files...\n"));
|
|
1962
|
+
|
|
1963
|
+
const {
|
|
1964
|
+
tempRoot,
|
|
1965
|
+
expectedDir,
|
|
1966
|
+
linkedFeatures,
|
|
1967
|
+
detectedFeatures,
|
|
1968
|
+
comparisonFeatures,
|
|
1969
|
+
} = await createReferenceScaffold(projectDir, quarkLink);
|
|
1970
|
+
|
|
1971
|
+
try {
|
|
1972
|
+
const { total, changed, missing } = await compareScaffoldManagedFiles(
|
|
1973
|
+
expectedDir,
|
|
1974
|
+
projectDir,
|
|
1975
|
+
);
|
|
1976
|
+
const linkedOnly = linkedFeatures.filter(
|
|
1977
|
+
(feature) => !detectedFeatures.includes(feature),
|
|
1978
|
+
);
|
|
1979
|
+
const detectedOnly = detectedFeatures.filter(
|
|
1980
|
+
(feature) => !linkedFeatures.includes(feature),
|
|
1981
|
+
);
|
|
1982
|
+
|
|
1983
|
+
console.log(
|
|
1984
|
+
chalk.cyan(`Recorded features: ${formatFeatureSummary(linkedFeatures)}`),
|
|
1985
|
+
);
|
|
1986
|
+
console.log(
|
|
1987
|
+
chalk.cyan(
|
|
1988
|
+
`Detected features: ${formatFeatureSummary(detectedFeatures)}`,
|
|
1989
|
+
),
|
|
1990
|
+
);
|
|
1991
|
+
console.log(
|
|
1992
|
+
chalk.cyan(
|
|
1993
|
+
`Compared against: ${formatFeatureSummary(comparisonFeatures)}`,
|
|
1994
|
+
),
|
|
1995
|
+
);
|
|
1996
|
+
console.log(chalk.cyan(`Managed files: ${total}`));
|
|
1997
|
+
|
|
1998
|
+
if (linkedOnly.length > 0 || detectedOnly.length > 0) {
|
|
1999
|
+
console.log(
|
|
2000
|
+
chalk.yellow(
|
|
2001
|
+
"\n โน .quark-link.json and installed feature directories differ; this report used the union.",
|
|
2002
|
+
),
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
if (changed.length === 0 && missing.length === 0) {
|
|
2007
|
+
console.log(
|
|
2008
|
+
chalk.green(
|
|
2009
|
+
"\n โ No scaffold drift detected. This report is read-only.\n",
|
|
2010
|
+
),
|
|
2011
|
+
);
|
|
2012
|
+
return { total, changed, missing };
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
console.log(
|
|
2016
|
+
chalk.yellow(
|
|
2017
|
+
`\nโ ๏ธ Scaffold drift detected in ${changed.length + missing.length} file(s).\n`,
|
|
2018
|
+
),
|
|
2019
|
+
);
|
|
2020
|
+
|
|
2021
|
+
if (changed.length > 0) {
|
|
2022
|
+
console.log(chalk.yellow(`Changed files (${changed.length}):`));
|
|
2023
|
+
for (const relativePath of changed) {
|
|
2024
|
+
console.log(chalk.white(` - ${relativePath}`));
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
if (missing.length > 0) {
|
|
2029
|
+
if (changed.length > 0) {
|
|
2030
|
+
console.log("");
|
|
2031
|
+
}
|
|
2032
|
+
console.log(chalk.yellow(`Missing scaffold files (${missing.length}):`));
|
|
2033
|
+
for (const relativePath of missing) {
|
|
2034
|
+
console.log(chalk.white(` - ${relativePath}`));
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
console.log(
|
|
2039
|
+
chalk.dim(
|
|
2040
|
+
"\n This command only reports scaffold drift. It does not overwrite project files.\n",
|
|
2041
|
+
),
|
|
2042
|
+
);
|
|
2043
|
+
|
|
2044
|
+
return { total, changed, missing };
|
|
2045
|
+
} finally {
|
|
2046
|
+
await fs.remove(tempRoot);
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
/**
|
|
2051
|
+
* Add a transpilePackages entry to next.config.js if not already present.
|
|
2052
|
+
* @param {string} webDir - Path to apps/web
|
|
2053
|
+
* @param {string} scopedPkg - e.g. "@myapp/admin"
|
|
2054
|
+
*/
|
|
2055
|
+
async function addTranspilePackage(webDir, scopedPkg) {
|
|
2056
|
+
const configPath = path.join(webDir, "next.config.js");
|
|
2057
|
+
if (!(await fs.pathExists(configPath))) return;
|
|
2058
|
+
|
|
2059
|
+
let content = await fs.readFile(configPath, "utf-8");
|
|
2060
|
+
if (content.includes(`"${scopedPkg}"`)) return; // already present
|
|
2061
|
+
|
|
2062
|
+
// Insert after the last entry in transpilePackages array
|
|
2063
|
+
const transpileMatch = content.match(/transpilePackages:\s*\[([^\]]*)\]/s);
|
|
2064
|
+
if (!transpileMatch) return;
|
|
2065
|
+
|
|
2066
|
+
const existingBlock = transpileMatch[1];
|
|
2067
|
+
const lastEntry = existingBlock.trimEnd();
|
|
2068
|
+
// Add a trailing comma to the last entry if needed, then add new entry
|
|
2069
|
+
const updatedBlock = lastEntry.endsWith(",")
|
|
2070
|
+
? `${lastEntry}\n\t\t"${scopedPkg}",`
|
|
2071
|
+
: `${lastEntry},\n\t\t"${scopedPkg}",`;
|
|
2072
|
+
|
|
2073
|
+
content = content.replace(transpileMatch[1], `${updatedBlock}\n\t`);
|
|
2074
|
+
await fs.writeFile(configPath, content);
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
/**
|
|
2078
|
+
* Add a workspace dependency to a package.json file.
|
|
2079
|
+
* @param {string} pkgJsonPath - Path to the package.json
|
|
2080
|
+
* @param {string} depName - e.g. "@myapp/admin"
|
|
2081
|
+
*/
|
|
2082
|
+
async function addWorkspaceDep(pkgJsonPath, depName) {
|
|
2083
|
+
if (!(await fs.pathExists(pkgJsonPath))) return;
|
|
2084
|
+
const pkg = await fs.readJSON(pkgJsonPath);
|
|
2085
|
+
pkg.dependencies = pkg.dependencies || {};
|
|
2086
|
+
if (pkg.dependencies[depName]) return; // already present
|
|
2087
|
+
pkg.dependencies[depName] = "workspace:*";
|
|
2088
|
+
await fs.writeFile(pkgJsonPath, `${JSON.stringify(pkg, null, " ")}\n`);
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
async function rewriteWorkspaceDeps(pkgJsonPath, scope, workspacePackages) {
|
|
2092
|
+
if (!(await fs.pathExists(pkgJsonPath))) return;
|
|
2093
|
+
const pkg = await fs.readJSON(pkgJsonPath);
|
|
2094
|
+
replaceDepsScope(pkg.dependencies, scope, workspacePackages);
|
|
2095
|
+
replaceDepsScope(pkg.devDependencies, scope, workspacePackages);
|
|
2096
|
+
await fs.writeFile(pkgJsonPath, `${JSON.stringify(pkg, null, " ")}\n`);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
program
|
|
2100
|
+
.command("add")
|
|
2101
|
+
.argument("<feature>", "Package to add (ui, jobs, pwa)")
|
|
2102
|
+
.description("Add an optional package to an existing Quark project")
|
|
2103
|
+
.option("--force", "Skip safety checks (uncommitted changes)")
|
|
2104
|
+
.option("--skip-install", "Skip pnpm install after adding")
|
|
2105
|
+
.action(async (feature, options) => {
|
|
2106
|
+
console.log(chalk.blue.bold(`\n๐ฆ Quark Add: ${feature}\n`));
|
|
2107
|
+
|
|
2108
|
+
// The global `--skip-install` (on the create program) shadows the add
|
|
2109
|
+
// subcommand's option in commander, so also check process.argv directly.
|
|
2110
|
+
const skipInstall =
|
|
2111
|
+
options.skipInstall || process.argv.includes("--skip-install");
|
|
2112
|
+
|
|
2113
|
+
const projectDir = process.cwd();
|
|
2114
|
+
|
|
2115
|
+
// --- Validate: is this a Quark project? ---
|
|
2116
|
+
const quarkLinkPath = path.join(projectDir, ".quark-link.json");
|
|
2117
|
+
if (!(await fs.pathExists(quarkLinkPath))) {
|
|
2118
|
+
console.error(
|
|
2119
|
+
chalk.red(
|
|
2120
|
+
"โ .quark-link.json not found. Are you in a Quark project root?",
|
|
2121
|
+
),
|
|
2122
|
+
);
|
|
2123
|
+
process.exit(1);
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
// --- Validate: is the feature valid? ---
|
|
2127
|
+
const validFeatures = Object.keys(FEATURE_META);
|
|
2128
|
+
if (!validFeatures.includes(feature)) {
|
|
2129
|
+
console.error(
|
|
2130
|
+
chalk.red(
|
|
2131
|
+
`โ Unknown feature: "${feature}". Valid options: ${validFeatures.join(", ")}`,
|
|
2132
|
+
),
|
|
2133
|
+
);
|
|
2134
|
+
process.exit(1);
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
// --- Detect project state ---
|
|
2138
|
+
const scope = await detectProjectScope(projectDir);
|
|
2139
|
+
if (!scope) {
|
|
2140
|
+
console.error(
|
|
2141
|
+
chalk.red(
|
|
2142
|
+
"โ Could not detect project scope from package.json. Expected @scope/root format.",
|
|
2143
|
+
),
|
|
2144
|
+
);
|
|
2145
|
+
process.exit(1);
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
const quarkLink = await fs.readJSON(quarkLinkPath);
|
|
2149
|
+
const installedFeatures = await detectInstalledFeatures(projectDir);
|
|
2150
|
+
|
|
2151
|
+
// --- Check if already installed ---
|
|
2152
|
+
if (installedFeatures.includes(feature)) {
|
|
2153
|
+
console.log(
|
|
2154
|
+
chalk.yellow(`โ ๏ธ "${feature}" is already installed in this project.`),
|
|
2155
|
+
);
|
|
2156
|
+
process.exit(0);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// --- Safety: check for uncommitted changes (project-scoped) ---
|
|
2160
|
+
if (!options.force && (await hasUncommittedChanges(projectDir))) {
|
|
2161
|
+
console.log(
|
|
2162
|
+
chalk.yellow(
|
|
2163
|
+
"โ ๏ธ You have uncommitted changes. Commit or stash them first.",
|
|
2164
|
+
),
|
|
2165
|
+
);
|
|
2166
|
+
console.log(
|
|
2167
|
+
chalk.white("Use --force to skip this check (not recommended).\n"),
|
|
2168
|
+
);
|
|
2169
|
+
process.exit(1);
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
// --- Resolve dependency chain ---
|
|
2173
|
+
const resolvedFeatures = resolveFeatureSelection([
|
|
2174
|
+
...installedFeatures,
|
|
2175
|
+
feature,
|
|
2176
|
+
]);
|
|
2177
|
+
const toAdd = resolvedFeatures.filter(
|
|
2178
|
+
(installedFeature) => !installedFeatures.includes(installedFeature),
|
|
2179
|
+
);
|
|
2180
|
+
const missingRequirements = toAdd.filter(
|
|
2181
|
+
(installedFeature) => installedFeature !== feature,
|
|
2182
|
+
);
|
|
2183
|
+
if (missingRequirements.length > 0) {
|
|
2184
|
+
console.log(
|
|
2185
|
+
chalk.yellow(
|
|
2186
|
+
` โน "${feature}" requires ${missingRequirements.join(", ")} - adding automatically.`,
|
|
2187
|
+
),
|
|
2188
|
+
);
|
|
2189
|
+
}
|
|
2190
|
+
const finalFeatures = resolvedFeatures;
|
|
2191
|
+
const finalWorkspacePackages =
|
|
2192
|
+
getWorkspacePackagesForFeatures(finalFeatures);
|
|
2193
|
+
|
|
2194
|
+
try {
|
|
2195
|
+
for (const feat of toAdd) {
|
|
2196
|
+
const featMeta = FEATURE_META[feat];
|
|
2197
|
+
const packageNames = featMeta.packages ?? [feat];
|
|
2198
|
+
console.log(chalk.cyan(`\n ๐ Adding ${feat}...`));
|
|
2199
|
+
|
|
2200
|
+
// 0. Skill-based vertical โ the embedded skill is already scaffolded;
|
|
2201
|
+
// no starter code is added.
|
|
2202
|
+
if (featMeta.skill) {
|
|
2203
|
+
console.log(
|
|
2204
|
+
chalk.green(
|
|
2205
|
+
` โ ${feat} (skill โ build it with the embedded skill)`,
|
|
2206
|
+
),
|
|
2207
|
+
);
|
|
2208
|
+
continue;
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
// PWA modifies the web app, not a workspace package
|
|
2212
|
+
if (feat === "pwa") {
|
|
2213
|
+
const pwaTemplateDir = path.join(templatesDir, "pwa");
|
|
2214
|
+
const webDir = path.join(projectDir, "apps/web");
|
|
2215
|
+
|
|
2216
|
+
if (
|
|
2217
|
+
await fs.pathExists(
|
|
2218
|
+
path.join(webDir, "src/app/_components/PWARegister.js"),
|
|
2219
|
+
)
|
|
2220
|
+
) {
|
|
2221
|
+
console.log(chalk.dim(" ยท PWA already configured - skipping"));
|
|
2222
|
+
} else {
|
|
2223
|
+
// Replace the base manifest.js - see the scaffold-time copy for
|
|
2224
|
+
// why a sibling manifest.json breaks the Next.js build.
|
|
2225
|
+
await fs.copy(
|
|
2226
|
+
path.join(pwaTemplateDir, "app/manifest.js"),
|
|
2227
|
+
path.join(webDir, "src/app/manifest.js"),
|
|
2228
|
+
);
|
|
2229
|
+
|
|
2230
|
+
// Point the PWA manifest at this project's scope.
|
|
2231
|
+
await substitutePlaceholders(
|
|
2232
|
+
path.join(webDir, "src/app/manifest.js"),
|
|
2233
|
+
{ __QUARK_SCOPE__: scope },
|
|
2234
|
+
);
|
|
2235
|
+
|
|
2236
|
+
// Copy sw.js to public/
|
|
2237
|
+
await fs.copy(
|
|
2238
|
+
path.join(pwaTemplateDir, "public/sw.js"),
|
|
2239
|
+
path.join(webDir, "public/sw.js"),
|
|
2240
|
+
);
|
|
2241
|
+
|
|
2242
|
+
// Copy PWARegister client component
|
|
2243
|
+
await fs.copy(
|
|
2244
|
+
path.join(pwaTemplateDir, "app/_components/PWARegister.js"),
|
|
2245
|
+
path.join(webDir, "src/app/_components/PWARegister.js"),
|
|
2246
|
+
);
|
|
2247
|
+
|
|
2248
|
+
// Inject PWARegister into layout.js
|
|
2249
|
+
const layoutPath = path.join(webDir, "src/app/layout.js");
|
|
2250
|
+
let layout = await fs.readFile(layoutPath, "utf-8");
|
|
2251
|
+
|
|
2252
|
+
layout = layout.replace(
|
|
2253
|
+
/import UmamiWebVitals from/,
|
|
2254
|
+
'import PWARegister from "./_components/PWARegister.js";\nimport UmamiWebVitals from',
|
|
2255
|
+
);
|
|
2256
|
+
|
|
2257
|
+
layout = layout.replace(
|
|
2258
|
+
/export function generateMetadata/,
|
|
2259
|
+
'export const viewport = {\n\tthemeColor: "#000000",\n};\n\nexport function generateMetadata',
|
|
2260
|
+
);
|
|
2261
|
+
|
|
2262
|
+
layout = layout.replace(/<body>/, "<body>\n\t\t\t<PWARegister />");
|
|
2263
|
+
|
|
2264
|
+
await fs.writeFile(layoutPath, layout);
|
|
2265
|
+
|
|
2266
|
+
console.log(
|
|
2267
|
+
chalk.green(
|
|
2268
|
+
" โ PWA support enabled (manifest + service worker)",
|
|
2269
|
+
),
|
|
2270
|
+
);
|
|
2271
|
+
}
|
|
2272
|
+
continue;
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
// Mobile app scaffolds apps/mobile from the template
|
|
2276
|
+
if (feat === "mobile") {
|
|
2277
|
+
const mobileTemplateDir = path.join(templatesDir, "mobile");
|
|
2278
|
+
const mobileDir = path.join(projectDir, "apps/mobile");
|
|
2279
|
+
|
|
2280
|
+
if (await fs.pathExists(mobileDir)) {
|
|
2281
|
+
console.log(
|
|
2282
|
+
chalk.dim(" ยท apps/mobile already exists - skipping copy"),
|
|
2283
|
+
);
|
|
2284
|
+
} else {
|
|
2285
|
+
await fs.ensureDir(mobileDir);
|
|
2286
|
+
await fs.copy(mobileTemplateDir, mobileDir);
|
|
2287
|
+
|
|
2288
|
+
// Replace placeholder variables in template files
|
|
2289
|
+
const mobileAppName = formatProjectDisplayName(
|
|
2290
|
+
quarkLink.projectName,
|
|
2291
|
+
);
|
|
2292
|
+
const mobileFiles = ["app.json", "package.json", "README.md"];
|
|
2293
|
+
|
|
2294
|
+
for (const relPath of mobileFiles) {
|
|
2295
|
+
const filePath = path.join(mobileDir, relPath);
|
|
2296
|
+
if (await fs.pathExists(filePath)) {
|
|
2297
|
+
let content = await fs.readFile(filePath, "utf-8");
|
|
2298
|
+
content = content.replace(/__QUARK_APP_NAME__/g, mobileAppName);
|
|
2299
|
+
content = content.replace(
|
|
2300
|
+
/__QUARK_PROJECT_NAME__/g,
|
|
2301
|
+
quarkLink.projectName,
|
|
2302
|
+
);
|
|
2303
|
+
// Replace EAS project ID with a placeholder that users
|
|
2304
|
+
// fill in after running `eas init`
|
|
2305
|
+
content = content.replace(
|
|
2306
|
+
/__QUARK_EAS_PROJECT_ID__/g,
|
|
2307
|
+
"YOUR_EAS_PROJECT_ID",
|
|
2308
|
+
);
|
|
2309
|
+
await fs.writeFile(filePath, content);
|
|
2310
|
+
}
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
await updatePackageJsonName(
|
|
2314
|
+
path.join(mobileDir, "package.json"),
|
|
2315
|
+
scope,
|
|
2316
|
+
);
|
|
2317
|
+
|
|
2318
|
+
// Add EXPO_PUBLIC_API_URL to .env.example
|
|
2319
|
+
const envExamplePath = path.join(projectDir, ".env.example");
|
|
2320
|
+
if (await fs.pathExists(envExamplePath)) {
|
|
2321
|
+
let envContent = await fs.readFile(envExamplePath, "utf-8");
|
|
2322
|
+
if (!envContent.includes("EXPO_PUBLIC_API_URL")) {
|
|
2323
|
+
envContent +=
|
|
2324
|
+
"\n# --- Mobile App ---\nEXPO_PUBLIC_API_URL=http://localhost:3000\n";
|
|
2325
|
+
await fs.writeFile(envExamplePath, envContent);
|
|
2326
|
+
}
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
// Add mobile scripts to root package.json
|
|
2330
|
+
const rootPkgPath = path.join(projectDir, "package.json");
|
|
2331
|
+
if (await fs.pathExists(rootPkgPath)) {
|
|
2332
|
+
const rootPkg = await fs.readJSON(rootPkgPath);
|
|
2333
|
+
rootPkg.scripts = rootPkg.scripts || {};
|
|
2334
|
+
if (!rootPkg.scripts["dev:mobile"]) {
|
|
2335
|
+
rootPkg.scripts["dev:mobile"] =
|
|
2336
|
+
`pnpm --filter @${scope}/mobile start`;
|
|
2337
|
+
}
|
|
2338
|
+
if (!rootPkg.scripts["build:mobile"]) {
|
|
2339
|
+
rootPkg.scripts["build:mobile"] =
|
|
2340
|
+
`pnpm --filter @${scope}/mobile export`;
|
|
2341
|
+
}
|
|
2342
|
+
await fs.writeFile(
|
|
2343
|
+
rootPkgPath,
|
|
2344
|
+
`${JSON.stringify(rootPkg, null, "\t")}\n`,
|
|
2345
|
+
);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
// Add DEVICE model to Prisma schema
|
|
2349
|
+
const schemaPath = path.join(
|
|
2350
|
+
projectDir,
|
|
2351
|
+
"packages/db/prisma/schema.prisma",
|
|
2352
|
+
);
|
|
2353
|
+
if (await fs.pathExists(schemaPath)) {
|
|
2354
|
+
let schema = await fs.readFile(schemaPath, "utf-8");
|
|
2355
|
+
if (!schema.includes("model Device")) {
|
|
2356
|
+
// Insert Device model after AuditLog
|
|
2357
|
+
const deviceModel = `
|
|
2358
|
+
|
|
2359
|
+
// โโโ Device Model (Mobile Push Notifications) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
2360
|
+
|
|
2361
|
+
model Device {
|
|
2362
|
+
id String @id @default(cuid())
|
|
2363
|
+
userId String
|
|
2364
|
+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
2365
|
+
platform String // "ios" | "android"
|
|
2366
|
+
pushToken String
|
|
2367
|
+
deviceId String
|
|
2368
|
+
createdAt DateTime @default(now())
|
|
2369
|
+
updatedAt DateTime @updatedAt
|
|
2370
|
+
|
|
2371
|
+
@@unique([userId, deviceId])
|
|
2372
|
+
@@index([userId])
|
|
2373
|
+
@@index([pushToken])
|
|
2374
|
+
@@index([createdAt])
|
|
2375
|
+
}
|
|
2376
|
+
`;
|
|
2377
|
+
schema = schema.replace(
|
|
2378
|
+
/(\/\/ Audit Log Model[\s\S]*?^}\s)/m,
|
|
2379
|
+
`$1${deviceModel}`,
|
|
2380
|
+
);
|
|
2381
|
+
// Add devices relation to User model
|
|
2382
|
+
schema = schema.replace(
|
|
2383
|
+
/(files\s+File\[\])/,
|
|
2384
|
+
"$1\n devices Device[]",
|
|
2385
|
+
);
|
|
2386
|
+
await fs.writeFile(schemaPath, schema);
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
console.log(
|
|
2391
|
+
chalk.green(" โ Mobile app scaffolded (apps/mobile)"),
|
|
2392
|
+
);
|
|
2393
|
+
console.log(
|
|
2394
|
+
chalk.green(" โ DEVICE model added to Prisma schema"),
|
|
2395
|
+
);
|
|
2396
|
+
console.log(
|
|
2397
|
+
chalk.green(" โ EXPO_PUBLIC_API_URL added to .env.example"),
|
|
2398
|
+
);
|
|
2399
|
+
console.log(
|
|
2400
|
+
chalk.dim(
|
|
2401
|
+
" ยท API routes (token, refresh, device/register) already in base template",
|
|
2402
|
+
),
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
continue;
|
|
2406
|
+
}
|
|
2407
|
+
|
|
2408
|
+
// 1. Copy package template
|
|
2409
|
+
for (const packageName of packageNames) {
|
|
2410
|
+
const packageDir = path.join(projectDir, "packages", packageName);
|
|
2411
|
+
if (await fs.pathExists(packageDir)) {
|
|
2412
|
+
console.log(
|
|
2413
|
+
chalk.dim(
|
|
2414
|
+
` ยท packages/${packageName} already exists - skipping copy`,
|
|
2415
|
+
),
|
|
2416
|
+
);
|
|
2417
|
+
} else {
|
|
2418
|
+
await copyTemplate(packageName, packageDir);
|
|
2419
|
+
await updatePackageJsonName(
|
|
2420
|
+
path.join(packageDir, "package.json"),
|
|
2421
|
+
scope,
|
|
2422
|
+
);
|
|
2423
|
+
await rewriteWorkspaceDeps(
|
|
2424
|
+
path.join(packageDir, "package.json"),
|
|
2425
|
+
scope,
|
|
2426
|
+
finalWorkspacePackages,
|
|
2427
|
+
);
|
|
2428
|
+
await replaceImportsInSourceFiles(packageDir, scope);
|
|
2429
|
+
console.log(chalk.green(` โ packages/${packageName}`));
|
|
2430
|
+
}
|
|
2431
|
+
}
|
|
2432
|
+
|
|
2433
|
+
// 2. Handle paired apps/routes
|
|
2434
|
+
for (const pair of featMeta.pairs) {
|
|
2435
|
+
if (pair === "worker") {
|
|
2436
|
+
const workerDir = path.join(projectDir, "apps", "worker");
|
|
2437
|
+
if (await fs.pathExists(workerDir)) {
|
|
2438
|
+
console.log(
|
|
2439
|
+
chalk.dim(` ยท apps/worker already exists - skipping copy`),
|
|
2440
|
+
);
|
|
2441
|
+
} else {
|
|
2442
|
+
await copyTemplate("worker", workerDir);
|
|
2443
|
+
await updatePackageJsonName(
|
|
2444
|
+
path.join(workerDir, "package.json"),
|
|
2445
|
+
scope,
|
|
2446
|
+
);
|
|
2447
|
+
await rewriteWorkspaceDeps(
|
|
2448
|
+
path.join(workerDir, "package.json"),
|
|
2449
|
+
scope,
|
|
2450
|
+
finalWorkspacePackages,
|
|
2451
|
+
);
|
|
2452
|
+
await replaceImportsInSourceFiles(workerDir, scope);
|
|
2453
|
+
console.log(chalk.green(` โ apps/worker (paired with jobs)`));
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
|
|
2458
|
+
// 3. Add transpilePackages entries and workspace deps to apps/web/package.json
|
|
2459
|
+
for (const packageName of packageNames) {
|
|
2460
|
+
const scopedPkg = `@${scope}/${packageName}`;
|
|
2461
|
+
await addTranspilePackage(
|
|
2462
|
+
path.join(projectDir, "apps", "web"),
|
|
2463
|
+
scopedPkg,
|
|
2464
|
+
);
|
|
2465
|
+
await addWorkspaceDep(
|
|
2466
|
+
path.join(projectDir, "apps", "web", "package.json"),
|
|
2467
|
+
scopedPkg,
|
|
2468
|
+
);
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
// 5. If adding jobs, also add dep to worker
|
|
2472
|
+
if (feat === "jobs") {
|
|
2473
|
+
await addWorkspaceDep(
|
|
2474
|
+
path.join(projectDir, "apps", "worker", "package.json"),
|
|
2475
|
+
`@${scope}/jobs`,
|
|
2476
|
+
);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
// --- Update .quark-link.json ---
|
|
2481
|
+
const allFeatures = [...new Set([...quarkLink.packages, ...toAdd])];
|
|
2482
|
+
quarkLink.packages = allFeatures;
|
|
2483
|
+
quarkLink.hasWorker = allFeatures.includes("jobs");
|
|
2484
|
+
quarkLink.lastAddedFeature = feature;
|
|
2485
|
+
quarkLink.lastModifiedDate = new Date().toISOString();
|
|
2486
|
+
// Track per-feature versions for migration detection
|
|
2487
|
+
if (!quarkLink.featureVersions) quarkLink.featureVersions = {};
|
|
2488
|
+
for (const addedFeature of toAdd) {
|
|
2489
|
+
const featurePkgPath = path.join(
|
|
2490
|
+
projectDir,
|
|
2491
|
+
addedFeature === "mobile" ? "apps" : "packages",
|
|
2492
|
+
addedFeature,
|
|
2493
|
+
"package.json",
|
|
2494
|
+
);
|
|
2495
|
+
if (await fs.pathExists(featurePkgPath)) {
|
|
2496
|
+
const featurePkg = JSON.parse(
|
|
2497
|
+
await fs.readFile(featurePkgPath, "utf-8"),
|
|
2498
|
+
);
|
|
2499
|
+
quarkLink.featureVersions[addedFeature] = featurePkg.version;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
await fs.writeFile(quarkLinkPath, JSON.stringify(quarkLink, null, " "));
|
|
2503
|
+
console.log(chalk.green(`\n โ .quark-link.json updated`));
|
|
2504
|
+
|
|
2505
|
+
// --- Run pnpm install ---
|
|
2506
|
+
if (!skipInstall) {
|
|
2507
|
+
console.log(chalk.cyan("\n ๐ฆ Installing dependencies..."));
|
|
2508
|
+
try {
|
|
2509
|
+
await execa("pnpm", ["install"], {
|
|
2510
|
+
cwd: projectDir,
|
|
2511
|
+
stdio: "inherit",
|
|
2512
|
+
});
|
|
2513
|
+
console.log(chalk.green(`\n โ Dependencies installed`));
|
|
2514
|
+
} catch {
|
|
2515
|
+
console.warn(
|
|
2516
|
+
chalk.yellow(
|
|
2517
|
+
`\n โ ๏ธ pnpm install failed. Run it manually to resolve.`,
|
|
2518
|
+
),
|
|
2519
|
+
);
|
|
2520
|
+
}
|
|
2521
|
+
} else {
|
|
2522
|
+
console.log(chalk.dim("\n ยท Skipping pnpm install (--skip-install)"));
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
// --- Success ---
|
|
2526
|
+
console.log(chalk.green.bold(`\nโ
Added "${feature}" successfully!\n`));
|
|
2527
|
+
|
|
2528
|
+
// Feature-specific next steps
|
|
2529
|
+
if (feature === "jobs") {
|
|
2530
|
+
console.log(chalk.cyan("Next steps:"));
|
|
2531
|
+
console.log(chalk.white(" 1. Define jobs in packages/jobs/src/"));
|
|
2532
|
+
console.log(
|
|
2533
|
+
chalk.white(" 2. Add handlers in apps/worker/src/handlers/"),
|
|
2534
|
+
);
|
|
2535
|
+
console.log(chalk.white(" 3. pnpm dev (starts web + worker)\n"));
|
|
2536
|
+
} else if (feature === "ui") {
|
|
2537
|
+
console.log(chalk.cyan("Next steps:"));
|
|
2538
|
+
console.log(
|
|
2539
|
+
chalk.white(
|
|
2540
|
+
` 1. Import components: import { Button } from "@${scope}/ui"`,
|
|
2541
|
+
),
|
|
2542
|
+
);
|
|
2543
|
+
console.log(chalk.white(" 2. pnpm dev\n"));
|
|
2544
|
+
} else if (feature === "pwa") {
|
|
2545
|
+
console.log(chalk.cyan("Next steps:"));
|
|
2546
|
+
console.log(
|
|
2547
|
+
chalk.white(
|
|
2548
|
+
" 1. Edit src/app/manifest.json to set your app name and theme color",
|
|
2549
|
+
),
|
|
2550
|
+
);
|
|
2551
|
+
console.log(
|
|
2552
|
+
chalk.white(" 2. Replace public/quark.svg with your app icon"),
|
|
2553
|
+
);
|
|
2554
|
+
console.log(
|
|
2555
|
+
chalk.white(
|
|
2556
|
+
" 3. Customize public/sw.js caching strategies if needed",
|
|
2557
|
+
),
|
|
2558
|
+
);
|
|
2559
|
+
console.log(
|
|
2560
|
+
chalk.white(" 4. pnpm build (test SW registration in production)\n"),
|
|
2561
|
+
);
|
|
2562
|
+
} else if (feature === "mobile") {
|
|
2563
|
+
console.log(chalk.cyan("Next steps:"));
|
|
2564
|
+
console.log(chalk.white(" 1. pnpm install"));
|
|
2565
|
+
console.log(
|
|
2566
|
+
chalk.white(" 2. Set EXPO_PUBLIC_API_URL in apps/mobile/.env"),
|
|
2567
|
+
);
|
|
2568
|
+
console.log(chalk.white(" 3. pnpm dev (starts web + worker)"));
|
|
2569
|
+
console.log(
|
|
2570
|
+
chalk.white(
|
|
2571
|
+
" 4. pnpm dev:mobile (starts Expo in a second terminal)",
|
|
2572
|
+
),
|
|
2573
|
+
);
|
|
2574
|
+
console.log(
|
|
2575
|
+
chalk.white(
|
|
2576
|
+
" 5. Configure APNs/FCM env vars for push notifications\n",
|
|
2577
|
+
),
|
|
2578
|
+
);
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
console.log(
|
|
2582
|
+
chalk.dim(" Run 'pnpm doctor' to check your project configuration.\n"),
|
|
2583
|
+
);
|
|
2584
|
+
} catch (error) {
|
|
2585
|
+
console.error(
|
|
2586
|
+
chalk.red(`\nโ Failed to add "${feature}": ${error.message}`),
|
|
2587
|
+
);
|
|
2588
|
+
console.error(chalk.dim(error.stack));
|
|
2589
|
+
process.exit(1);
|
|
2590
|
+
}
|
|
2591
|
+
});
|
|
2592
|
+
|
|
2593
|
+
// ---------------------------------------------------------------------------
|
|
2594
|
+
// quark skill <feature> - Print an embedded skill for a feature
|
|
2595
|
+
// ---------------------------------------------------------------------------
|
|
2596
|
+
|
|
2597
|
+
program
|
|
2598
|
+
.command("skill")
|
|
2599
|
+
.argument(
|
|
2600
|
+
"<feature>",
|
|
2601
|
+
"Feature to print a skill for (model, endpoint, dashboard)",
|
|
2602
|
+
)
|
|
2603
|
+
.description("Print an embedded skill for a feature")
|
|
2604
|
+
.action(async (feature) => {
|
|
2605
|
+
const skillName =
|
|
2606
|
+
feature === "model"
|
|
2607
|
+
? "add-model"
|
|
2608
|
+
: feature === "endpoint"
|
|
2609
|
+
? "add-endpoint"
|
|
2610
|
+
: feature === "dashboard"
|
|
2611
|
+
? "add-dashboard"
|
|
2612
|
+
: feature;
|
|
2613
|
+
const skillPath = path.join(
|
|
2614
|
+
templatesDir,
|
|
2615
|
+
"base-project",
|
|
2616
|
+
"skills",
|
|
2617
|
+
skillName,
|
|
2618
|
+
"SKILL.md",
|
|
2619
|
+
);
|
|
2620
|
+
|
|
2621
|
+
if (!(await fs.pathExists(skillPath))) {
|
|
2622
|
+
console.error(
|
|
2623
|
+
chalk.red(
|
|
2624
|
+
`โ No skill found for "${feature}". Available: model, endpoint, dashboard`,
|
|
2625
|
+
),
|
|
2626
|
+
);
|
|
2627
|
+
process.exit(1);
|
|
2628
|
+
}
|
|
2629
|
+
|
|
2630
|
+
const skill = await fs.readFile(skillPath, "utf-8");
|
|
2631
|
+
console.log(skill);
|
|
2632
|
+
});
|
|
2633
|
+
|
|
2634
|
+
/**
|
|
2635
|
+
* Read the installed version of a package from node_modules.
|
|
2636
|
+
* Returns null if the package is not installed.
|
|
2637
|
+
* @param {string} cwd - Project root
|
|
2638
|
+
* @param {string} packageName - e.g. "@techstream/quark-core"
|
|
2639
|
+
* @returns {Promise<string|null>}
|
|
2640
|
+
*/
|
|
2641
|
+
async function getInstalledVersion(cwd, packageName) {
|
|
2642
|
+
try {
|
|
2643
|
+
const pkgPath = path.join(
|
|
2644
|
+
cwd,
|
|
2645
|
+
"node_modules",
|
|
2646
|
+
...packageName.split("/"),
|
|
2647
|
+
"package.json",
|
|
2648
|
+
);
|
|
2649
|
+
const { version } = await fs.readJSON(pkgPath);
|
|
2650
|
+
return version ?? null;
|
|
2651
|
+
} catch {
|
|
2652
|
+
return null;
|
|
2653
|
+
}
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
/**
|
|
2657
|
+
* Fetch the latest published version of a package from the npm registry.
|
|
2658
|
+
* Returns null on network failure so the caller can degrade gracefully.
|
|
2659
|
+
* @param {string} packageName
|
|
2660
|
+
* @returns {Promise<string|null>}
|
|
2661
|
+
*/
|
|
2662
|
+
async function getLatestNpmVersion(packageName) {
|
|
2663
|
+
try {
|
|
2664
|
+
const { stdout } = await execa("npm", [
|
|
2665
|
+
"view",
|
|
2666
|
+
packageName,
|
|
2667
|
+
"version",
|
|
2668
|
+
"--json",
|
|
2669
|
+
]);
|
|
2670
|
+
return JSON.parse(stdout);
|
|
2671
|
+
} catch {
|
|
2672
|
+
return null;
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
/**
|
|
2677
|
+
* quark-update command
|
|
2678
|
+
* Updates Quark core infrastructure in a scaffolded project
|
|
2679
|
+
*/
|
|
2680
|
+
program
|
|
2681
|
+
.command("update")
|
|
2682
|
+
.description("Update Quark packages in the current project")
|
|
2683
|
+
.option("--check", "Check for updates without applying")
|
|
2684
|
+
.option(
|
|
2685
|
+
"--scaffold-check",
|
|
2686
|
+
"Report drift in scaffold-managed files without applying changes",
|
|
2687
|
+
)
|
|
2688
|
+
.option(
|
|
2689
|
+
"--fail-on-drift",
|
|
2690
|
+
"Exit with status 1 when scaffold drift is detected (use with --scaffold-check)",
|
|
2691
|
+
)
|
|
2692
|
+
.option("--force", "Skip safety checks")
|
|
2693
|
+
.action(async (options) => {
|
|
2694
|
+
console.log(chalk.blue.bold(`\n๐ Quark Package Update\n`));
|
|
2695
|
+
|
|
2696
|
+
if (options.failOnDrift && !options.scaffoldCheck) {
|
|
2697
|
+
console.error(
|
|
2698
|
+
chalk.red("โ --fail-on-drift requires --scaffold-check.\n"),
|
|
2699
|
+
);
|
|
2700
|
+
process.exit(1);
|
|
2701
|
+
}
|
|
2702
|
+
|
|
2703
|
+
// Check if .quark-link.json exists
|
|
2704
|
+
const quarkLinkPath = path.join(process.cwd(), ".quark-link.json");
|
|
2705
|
+
if (!(await fs.pathExists(quarkLinkPath))) {
|
|
2706
|
+
console.error(
|
|
2707
|
+
chalk.red("โ .quark-link.json not found. Are you in a Quark project?"),
|
|
2708
|
+
);
|
|
2709
|
+
process.exit(1);
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
const quarkLink = await fs.readJSON(quarkLinkPath);
|
|
2713
|
+
|
|
2714
|
+
// The packages this command manages
|
|
2715
|
+
const MANAGED_PACKAGES = [
|
|
2716
|
+
"@techstream/quark-core",
|
|
2717
|
+
"@techstream/quark-create-app",
|
|
2718
|
+
];
|
|
2719
|
+
|
|
2720
|
+
// Snapshot installed versions before any update
|
|
2721
|
+
const before = {};
|
|
2722
|
+
for (const name of MANAGED_PACKAGES) {
|
|
2723
|
+
before[name] = await getInstalledVersion(process.cwd(), name);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
console.log(chalk.cyan(`Scaffolded: ${quarkLink.scaffoldedDate}`));
|
|
2727
|
+
console.log(
|
|
2728
|
+
chalk.cyan(
|
|
2729
|
+
`Installed core: ${before["@techstream/quark-core"] ?? quarkLink.quarkVersion ?? "unknown"}\n`,
|
|
2730
|
+
),
|
|
2731
|
+
);
|
|
2732
|
+
|
|
2733
|
+
if (options.check) {
|
|
2734
|
+
// Query npm for latest versions and report the delta
|
|
2735
|
+
console.log(chalk.yellow("Checking npm registry for updates...\n"));
|
|
2736
|
+
let anyUpdates = false;
|
|
2737
|
+
for (const name of MANAGED_PACKAGES) {
|
|
2738
|
+
const installed = before[name];
|
|
2739
|
+
const latest = await getLatestNpmVersion(name);
|
|
2740
|
+
if (!latest) {
|
|
2741
|
+
console.log(chalk.dim(` ${name}: registry unreachable`));
|
|
2742
|
+
continue;
|
|
2743
|
+
}
|
|
2744
|
+
if (!installed || installed === latest) {
|
|
2745
|
+
console.log(chalk.green(` โ ${name} ${latest} - up to date`));
|
|
2746
|
+
} else {
|
|
2747
|
+
console.log(
|
|
2748
|
+
chalk.yellow(` โ ${name}: ${installed} โ ${chalk.bold(latest)}`),
|
|
2749
|
+
);
|
|
2750
|
+
anyUpdates = true;
|
|
2751
|
+
}
|
|
2752
|
+
}
|
|
2753
|
+
if (anyUpdates) {
|
|
2754
|
+
console.log(chalk.white("\n Run without --check to apply updates.\n"));
|
|
2755
|
+
} else {
|
|
2756
|
+
console.log(chalk.dim("\n Nothing to update.\n"));
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
if (options.scaffoldCheck) {
|
|
2761
|
+
const scaffoldReport = await reportScaffoldDrift(
|
|
2762
|
+
process.cwd(),
|
|
2763
|
+
quarkLink,
|
|
2764
|
+
);
|
|
2765
|
+
const hasDrift =
|
|
2766
|
+
scaffoldReport.changed.length > 0 || scaffoldReport.missing.length > 0;
|
|
2767
|
+
if (options.failOnDrift && hasDrift) {
|
|
2768
|
+
console.error(
|
|
2769
|
+
chalk.red("โ Drift detected and --fail-on-drift was set.\n"),
|
|
2770
|
+
);
|
|
2771
|
+
process.exitCode = 1;
|
|
2772
|
+
}
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
if (options.check || options.scaffoldCheck) {
|
|
2776
|
+
return;
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
try {
|
|
2780
|
+
// Guard: check for both unstaged and staged changes (project-scoped)
|
|
2781
|
+
if (!options.force && (await hasUncommittedChanges(process.cwd()))) {
|
|
2782
|
+
console.log(
|
|
2783
|
+
chalk.yellow(
|
|
2784
|
+
"โ ๏ธ You have uncommitted changes. Commit or stash them first.",
|
|
2785
|
+
),
|
|
2786
|
+
);
|
|
2787
|
+
console.log(
|
|
2788
|
+
chalk.white("Use --force to skip this check (not recommended).\n"),
|
|
2789
|
+
);
|
|
2790
|
+
process.exit(1);
|
|
2791
|
+
}
|
|
2792
|
+
|
|
2793
|
+
// Run pnpm update for all managed packages
|
|
2794
|
+
console.log(chalk.cyan("๐ฆ Updating Quark packages...\n"));
|
|
2795
|
+
await execa("pnpm", ["update", ...MANAGED_PACKAGES], {
|
|
2796
|
+
cwd: process.cwd(),
|
|
2797
|
+
stdio: "inherit",
|
|
2798
|
+
});
|
|
2799
|
+
|
|
2800
|
+
// Snapshot installed versions after update
|
|
2801
|
+
const after = {};
|
|
2802
|
+
for (const name of MANAGED_PACKAGES) {
|
|
2803
|
+
after[name] = await getInstalledVersion(process.cwd(), name);
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
// Report the delta
|
|
2807
|
+
console.log(chalk.cyan("\n๐ Update summary:\n"));
|
|
2808
|
+
for (const name of MANAGED_PACKAGES) {
|
|
2809
|
+
const was = before[name];
|
|
2810
|
+
const now = after[name];
|
|
2811
|
+
if (!now) continue;
|
|
2812
|
+
if (was && was !== now) {
|
|
2813
|
+
console.log(chalk.green(` โ ${name}: ${was} โ ${chalk.bold(now)}`));
|
|
2814
|
+
} else {
|
|
2815
|
+
console.log(chalk.dim(` ยท ${name}: ${now} (already current)`));
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
// Persist the new core version - keep previous on failure, never write garbage
|
|
2820
|
+
const newCoreVersion =
|
|
2821
|
+
after["@techstream/quark-core"] ?? quarkLink.quarkVersion;
|
|
2822
|
+
quarkLink.quarkVersion = newCoreVersion;
|
|
2823
|
+
quarkLink.updatedDate = new Date().toISOString();
|
|
2824
|
+
await fs.writeFile(quarkLinkPath, JSON.stringify(quarkLink, null, " "));
|
|
2825
|
+
|
|
2826
|
+
// Run lint to surface any API breakage from the update
|
|
2827
|
+
console.log(chalk.cyan("\n๐ Running lint to check for breakage...\n"));
|
|
2828
|
+
try {
|
|
2829
|
+
await execa("pnpm", ["lint"], {
|
|
2830
|
+
cwd: process.cwd(),
|
|
2831
|
+
stdio: "inherit",
|
|
2832
|
+
});
|
|
2833
|
+
console.log(chalk.green("\n โ Lint passed\n"));
|
|
2834
|
+
} catch {
|
|
2835
|
+
console.log(
|
|
2836
|
+
chalk.yellow(
|
|
2837
|
+
"\n โ ๏ธ Lint reported issues - review before committing.\n",
|
|
2838
|
+
),
|
|
2839
|
+
);
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
console.log(chalk.green("โ
Quark updated successfully!\n"));
|
|
2843
|
+
console.log(chalk.cyan("Next steps:"));
|
|
2844
|
+
console.log(chalk.white(` 1. pnpm test`));
|
|
2845
|
+
console.log(
|
|
2846
|
+
chalk.white(` 2. git add . && git commit -m "chore: update Quark"\n`),
|
|
2847
|
+
);
|
|
2848
|
+
} catch (error) {
|
|
2849
|
+
console.error(chalk.red(`\nโ Update failed: ${error.message}\n`));
|
|
2850
|
+
process.exit(1);
|
|
2851
|
+
}
|
|
2852
|
+
});
|
|
2853
|
+
|
|
2854
|
+
// ---------------------------------------------------------------------------
|
|
2855
|
+
// quark deploy - Deploy and inspect
|
|
2856
|
+
// ---------------------------------------------------------------------------
|
|
2857
|
+
|
|
2858
|
+
const deployCmd = program
|
|
2859
|
+
.command("deploy")
|
|
2860
|
+
.description("Deploy a Quark project to Railway");
|
|
2861
|
+
|
|
2862
|
+
deployCmd
|
|
2863
|
+
.command("railway")
|
|
2864
|
+
.description("Deploy to Railway (web + worker + Postgres + Redis)")
|
|
2865
|
+
.option(
|
|
2866
|
+
"--project-name <name>",
|
|
2867
|
+
"Railway project name (creates a new project)",
|
|
2868
|
+
)
|
|
2869
|
+
.option("--project-id <id>", "Existing Railway project ID to link to")
|
|
2870
|
+
.option("--environment <env>", "Railway environment (default: production)")
|
|
2871
|
+
.option("--no-provision", "Skip provisioning PostgreSQL and Redis plugins")
|
|
2872
|
+
.option("--dry-run", "Validate configuration without deploying")
|
|
2873
|
+
.action(async (options) => {
|
|
2874
|
+
const { deployToRailway } = await import("./deploy/deploy.js");
|
|
2875
|
+
|
|
2876
|
+
if (options.dryRun) {
|
|
2877
|
+
const { inspectProject } = await import("./deploy/inspect.js");
|
|
2878
|
+
console.log(
|
|
2879
|
+
chalk.yellow("\nโ Dry run - validating deployment configuration\n"),
|
|
2880
|
+
);
|
|
2881
|
+
await inspectProject({ cwd: process.cwd() });
|
|
2882
|
+
console.log(
|
|
2883
|
+
chalk.green(
|
|
2884
|
+
"โ Configuration valid. Run without --dry-run to deploy.\n",
|
|
2885
|
+
),
|
|
2886
|
+
);
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
|
|
2890
|
+
try {
|
|
2891
|
+
await deployToRailway({
|
|
2892
|
+
cwd: process.cwd(),
|
|
2893
|
+
projectName: options.projectName,
|
|
2894
|
+
projectId: options.projectId,
|
|
2895
|
+
environment: options.environment,
|
|
2896
|
+
provision: options.provision,
|
|
2897
|
+
});
|
|
2898
|
+
} catch (error) {
|
|
2899
|
+
console.error(chalk.red(`\nโ Deploy failed: ${error.message}\n`));
|
|
2900
|
+
process.exit(1);
|
|
2901
|
+
}
|
|
2902
|
+
});
|
|
2903
|
+
|
|
2904
|
+
deployCmd
|
|
2905
|
+
.command("inspect")
|
|
2906
|
+
.description("Inspect project deployment readiness")
|
|
2907
|
+
.action(async () => {
|
|
2908
|
+
const { inspectProject } = await import("./deploy/inspect.js");
|
|
2909
|
+
await inspectProject({ cwd: process.cwd() });
|
|
2910
|
+
});
|
|
2911
|
+
|
|
2912
|
+
deployCmd
|
|
2913
|
+
.command("status")
|
|
2914
|
+
.description("Check deployed service status")
|
|
2915
|
+
.action(async () => {
|
|
2916
|
+
const { inspectProject } = await import("./deploy/inspect.js");
|
|
2917
|
+
await inspectProject({ cwd: process.cwd() });
|
|
2918
|
+
});
|
|
2919
|
+
|
|
2920
|
+
program.parse();
|