vibecarbon 0.6.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/carbon/biome.json +3 -2
- package/carbon/package.json +21 -21
- package/carbon/scripts/db-migrate.js +49 -0
- package/carbon/scripts/dev-init.js +4 -0
- package/carbon/scripts/dev.js +39 -4
- package/carbon/scripts/lib/vite-log-filter.js +29 -0
- package/carbon/src/client/locales/en.json +1 -1
- package/carbon/src/server/lib/env.ts +1 -1
- package/carbon/supabase/migrations/00001_init.sql +14 -2
- package/carbon/supabase/migrations/00003_pg_cron.sql +4 -0
- package/carbon/supabase/migrations/00004_contact_newsletter.sql +2 -1
- package/carbon/vite.config.ts +14 -1
- package/carbon/volumes/db/super-admin.dev.sql +5 -1
- package/carbon/volumes/db/super-admin.sql +4 -1
- package/package.json +13 -13
- package/src/create.js +58 -0
- package/src/lib/orphan.js +221 -0
- package/src/up.js +22 -7
package/carbon/biome.json
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
2
|
+
"root": false,
|
|
3
|
+
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
|
|
3
4
|
"linter": {
|
|
4
5
|
"enabled": true,
|
|
5
6
|
"rules": {
|
|
6
|
-
"
|
|
7
|
+
"preset": "recommended",
|
|
7
8
|
"complexity": {
|
|
8
9
|
"noUselessFragments": "warn"
|
|
9
10
|
},
|
package/carbon/package.json
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"docker:reset:all": "node scripts/docker-down.js -v --rmi --all",
|
|
40
40
|
"docker:prod:up": "node scripts/docker-up.js --prod",
|
|
41
41
|
"docker:prod:down": "node scripts/docker-down.js --prod",
|
|
42
|
-
"db:migrate": "
|
|
42
|
+
"db:migrate": "node scripts/db-migrate.js",
|
|
43
43
|
"db:reset": "node scripts/docker-down.js -v && node scripts/docker-up.js",
|
|
44
44
|
"k8s:apply": "node scripts/k8s-apply.js",
|
|
45
45
|
"k8s:delete": "node scripts/k8s-delete.js",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"stripe:listen": "stripe listen --forward-to localhost:3000/api/webhooks/stripe"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@base-ui/react": "^1.
|
|
53
|
+
"@base-ui/react": "^1.6.0",
|
|
54
54
|
"@fontsource-variable/noto-sans": "^5.2.10",
|
|
55
55
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
|
56
56
|
"@hono/node-server": "^2.0.6",
|
|
@@ -58,39 +58,39 @@
|
|
|
58
58
|
"@hookform/resolvers": "^5.4.0",
|
|
59
59
|
"@mdx-js/react": "^3.1.1",
|
|
60
60
|
"@mdx-js/rollup": "^3.1.1",
|
|
61
|
-
"@scalar/api-reference-react": "^0.9.
|
|
62
|
-
"@supabase/supabase-js": "^2.
|
|
63
|
-
"@tanstack/react-query": "^5.
|
|
61
|
+
"@scalar/api-reference-react": "^0.9.50",
|
|
62
|
+
"@supabase/supabase-js": "^2.110.0",
|
|
63
|
+
"@tanstack/react-query": "^5.101.2",
|
|
64
64
|
"class-variance-authority": "^0.7.1",
|
|
65
65
|
"clsx": "^2.1.1",
|
|
66
66
|
"cmdk": "^1.1.1",
|
|
67
67
|
"date-fns": "^4.4.0",
|
|
68
68
|
"embla-carousel-react": "^8.6.0",
|
|
69
|
-
"framer-motion": "^12.
|
|
69
|
+
"framer-motion": "^12.42.2",
|
|
70
70
|
"hono": "^4.12.27",
|
|
71
|
-
"i18next": "^26.3.
|
|
71
|
+
"i18next": "^26.3.4",
|
|
72
72
|
"i18next-browser-languagedetector": "^8.2.1",
|
|
73
73
|
"input-otp": "^1.4.2",
|
|
74
74
|
"ioredis": "^5.11.1",
|
|
75
75
|
"lenis": "^1.3.25",
|
|
76
|
-
"lucide-react": "^1.
|
|
76
|
+
"lucide-react": "^1.22.0",
|
|
77
77
|
"next-themes": "^0.4.6",
|
|
78
|
-
"nodemailer": "^
|
|
78
|
+
"nodemailer": "^9.0.3",
|
|
79
79
|
"pino": "^10.3.1",
|
|
80
80
|
"react": "^19.2.7",
|
|
81
81
|
"react-day-picker": "^10.0.1",
|
|
82
82
|
"react-dom": "^19.2.7",
|
|
83
|
-
"react-hook-form": "^7.
|
|
83
|
+
"react-hook-form": "^7.80.0",
|
|
84
84
|
"react-i18next": "^17.0.8",
|
|
85
|
-
"react-resizable-panels": "^4.
|
|
86
|
-
"react-router-dom": "^7.
|
|
87
|
-
"recharts": "^3.
|
|
85
|
+
"react-resizable-panels": "^4.12.0",
|
|
86
|
+
"react-router-dom": "^7.18.1",
|
|
87
|
+
"recharts": "^3.9.1",
|
|
88
88
|
"rehype-autolink-headings": "^7.1.0",
|
|
89
89
|
"rehype-slug": "^6.0.0",
|
|
90
90
|
"remark-frontmatter": "^5.0.0",
|
|
91
91
|
"remark-mdx-frontmatter": "^5.2.0",
|
|
92
92
|
"sonner": "^2.0.7",
|
|
93
|
-
"stripe": "^22.
|
|
93
|
+
"stripe": "^22.3.0",
|
|
94
94
|
"tailwind-merge": "^3.6.0",
|
|
95
95
|
"vaul": "^1.1.2",
|
|
96
96
|
"zod": "^4.4.3"
|
|
@@ -122,16 +122,16 @@
|
|
|
122
122
|
}
|
|
123
123
|
},
|
|
124
124
|
"devDependencies": {
|
|
125
|
-
"@biomejs/biome": "^2.
|
|
125
|
+
"@biomejs/biome": "^2.5.1",
|
|
126
126
|
"@hono/swagger-ui": "^0.6.1",
|
|
127
|
-
"@scalar/hono-api-reference": "^0.
|
|
127
|
+
"@scalar/hono-api-reference": "^0.11.7",
|
|
128
128
|
"@tailwindcss/typography": "^0.5.20",
|
|
129
129
|
"@tailwindcss/vite": "^4.3.2",
|
|
130
130
|
"@testing-library/jest-dom": "^6.9.1",
|
|
131
131
|
"@testing-library/react": "^16.3.2",
|
|
132
132
|
"@testing-library/user-event": "^14.6.1",
|
|
133
|
-
"@types/node": "^
|
|
134
|
-
"@types/nodemailer": "^8.0.
|
|
133
|
+
"@types/node": "^26.0.1",
|
|
134
|
+
"@types/nodemailer": "^8.0.1",
|
|
135
135
|
"@types/pg": "^8.20.0",
|
|
136
136
|
"@types/react": "^19.2.17",
|
|
137
137
|
"@types/react-dom": "^19.2.3",
|
|
@@ -139,15 +139,15 @@
|
|
|
139
139
|
"@vitest/coverage-v8": "^4.1.9",
|
|
140
140
|
"esbuild": "^0.28.1",
|
|
141
141
|
"jsdom": "^29.1.1",
|
|
142
|
-
"pg": "^8.
|
|
142
|
+
"pg": "^8.22.0",
|
|
143
143
|
"pino-pretty": "^13.1.3",
|
|
144
144
|
"remark-gfm": "^4.0.1",
|
|
145
|
-
"shadcn": "^4.
|
|
145
|
+
"shadcn": "^4.12.0",
|
|
146
146
|
"tailwindcss": "^4.3.2",
|
|
147
147
|
"tsx": "^4.22.4",
|
|
148
148
|
"tw-animate-css": "^1.4.0",
|
|
149
149
|
"typescript": "^6.0.3",
|
|
150
|
-
"vite": "^8.
|
|
150
|
+
"vite": "^8.1.2",
|
|
151
151
|
"vitest": "^4.1.9"
|
|
152
152
|
}
|
|
153
153
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Quiet database migration runner for local dev (`pnpm db:migrate`).
|
|
4
|
+
*
|
|
5
|
+
* Applies every /migrations/*.sql (lexical order) then /tmp/super-admin.sql
|
|
6
|
+
* inside the running `db` compose service. psql runs with notices suppressed
|
|
7
|
+
* (client_min_messages=warning), command tags silenced (-q), and stdout
|
|
8
|
+
* discarded (>/dev/null), so a normal idempotent re-run prints ~one line per
|
|
9
|
+
* file instead of ~200. Real WARNING/ERROR text is preserved on stderr and the
|
|
10
|
+
* first error aborts the run (ON_ERROR_STOP + set -e).
|
|
11
|
+
*/
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
|
|
14
|
+
/** The bash program run inside the db container. Exported for unit testing. */
|
|
15
|
+
export function buildMigrateScript() {
|
|
16
|
+
// Single quiet psql invocation, parameterised by the shell var "$f" / a path.
|
|
17
|
+
const psql = (file) =>
|
|
18
|
+
`PGOPTIONS="-c client_min_messages=warning" ` +
|
|
19
|
+
`psql -U postgres -d postgres -v ON_ERROR_STOP=1 -q -f ${file} >/dev/null`;
|
|
20
|
+
|
|
21
|
+
return [
|
|
22
|
+
'set -e',
|
|
23
|
+
'for f in /migrations/*.sql; do',
|
|
24
|
+
` ${psql('"$f"')}`,
|
|
25
|
+
' echo " ✓ $(basename "$f")"',
|
|
26
|
+
'done',
|
|
27
|
+
psql('/tmp/super-admin.sql'),
|
|
28
|
+
'echo " ✓ super-admin"',
|
|
29
|
+
].join('\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function main() {
|
|
33
|
+
console.log('Running database migrations…');
|
|
34
|
+
const res = spawnSync(
|
|
35
|
+
'docker',
|
|
36
|
+
['compose', 'exec', '-T', 'db', 'bash', '-c', buildMigrateScript()],
|
|
37
|
+
{ stdio: 'inherit' },
|
|
38
|
+
);
|
|
39
|
+
if (res.error) {
|
|
40
|
+
console.error(res.error.message);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
process.exit(res.status ?? 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Run only when invoked directly, not when imported by the test.
|
|
47
|
+
if (process.argv[1] && process.argv[1].endsWith('db-migrate.js')) {
|
|
48
|
+
main();
|
|
49
|
+
}
|
|
@@ -95,6 +95,10 @@ ADMIN_PASSWORD="${DEV.ADMIN_PASSWORD}"
|
|
|
95
95
|
# Site URL (base port — offset applied dynamically, see above)
|
|
96
96
|
SITE_URL="http://localhost:5173"
|
|
97
97
|
|
|
98
|
+
# Public URL — substituted into index.html's og:/twitter: meta tags by Vite's
|
|
99
|
+
# %VITE_PUBLIC_URL% replacement. Must be defined or Vite warns on every reload.
|
|
100
|
+
VITE_PUBLIC_URL="http://localhost:5173"
|
|
101
|
+
|
|
98
102
|
# OAuth (disabled for dev)
|
|
99
103
|
GOOGLE_ENABLED="false"
|
|
100
104
|
GOOGLE_CLIENT_ID=""
|
package/carbon/scripts/dev.js
CHANGED
|
@@ -109,22 +109,53 @@ if (conflicts.length > 0) {
|
|
|
109
109
|
process.exit(1);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
-
|
|
113
|
-
|
|
112
|
+
// The service-URL banner is printed *after* both dev servers are listening (see
|
|
113
|
+
// announceWhenReady below) rather than up-front. Otherwise Vite's "ready in Xms"
|
|
114
|
+
// and the API's startup logs print afterward and scroll the URLs off-screen.
|
|
115
|
+
function printBanner() {
|
|
116
|
+
console.log(`
|
|
117
|
+
${cyan('✦')} ${bold(projectName)} is ready
|
|
114
118
|
|
|
115
119
|
${cyan('→')} ${bold('Frontend:')} ${cyan(`http://localhost:${ports.vite}`)}
|
|
116
120
|
${cyan('→')} ${bold('API:')} ${cyan(`http://localhost:${ports.api}`)}
|
|
117
|
-
${cyan('→')} ${bold('Studio:')} ${cyan('http://studio.localhost')} (
|
|
118
|
-
${cyan('→')} ${bold('Traefik:')} ${cyan('http://traefik.localhost')} (
|
|
121
|
+
${cyan('→')} ${bold('Studio:')} ${cyan('http://studio.localhost')} (no auth in dev)
|
|
122
|
+
${cyan('→')} ${bold('Traefik:')} ${cyan('http://traefik.localhost')} (no auth in dev)
|
|
119
123
|
`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
127
|
+
|
|
128
|
+
// Poll until both dev servers hold their ports (i.e. are listening), then print
|
|
129
|
+
// the URL banner so it lands as the last thing on screen. We watch the ports
|
|
130
|
+
// instead of piping child stdout because `stdio: 'inherit'` keeps Vite's TTY
|
|
131
|
+
// (colours + interactive `h+enter` shortcuts); piping would forfeit both.
|
|
132
|
+
async function announceWhenReady() {
|
|
133
|
+
const deadline = Date.now() + 30_000; // print anyway after 30s, don't hang
|
|
134
|
+
while (Date.now() < deadline) {
|
|
135
|
+
if (shuttingDown) return;
|
|
136
|
+
const free = await Promise.all([isPortFree(ports.vite), isPortFree(ports.api)]);
|
|
137
|
+
if (free.every((isFree) => !isFree)) break; // both ports in use → listening
|
|
138
|
+
await delay(200);
|
|
139
|
+
}
|
|
140
|
+
if (shuttingDown) return;
|
|
141
|
+
// Let the servers' own "ready"/"Starting server" lines flush first.
|
|
142
|
+
await delay(400);
|
|
143
|
+
if (!shuttingDown) printBanner();
|
|
144
|
+
}
|
|
120
145
|
|
|
121
146
|
const children = [];
|
|
122
147
|
|
|
148
|
+
// pnpm exports its own config as npm_config_* env vars; the nested `npx`
|
|
149
|
+
// (real npm) then warns "Unknown env config …". Silence npm's own warnings
|
|
150
|
+
// for these dev tool processes — tsx/vite write their own logs, not via npm.
|
|
151
|
+
const childEnv = { ...process.env, npm_config_loglevel: 'error' };
|
|
152
|
+
|
|
123
153
|
// Spawn server process with its own process group for clean shutdown
|
|
124
154
|
const server = spawn('npx', ['tsx', 'watch', '--env-file=.env.local', 'src/server/index.ts'], {
|
|
125
155
|
stdio: 'inherit',
|
|
126
156
|
shell: isWindows,
|
|
127
157
|
detached: !isWindows, // Create process group on Unix for clean group kill
|
|
158
|
+
env: childEnv,
|
|
128
159
|
});
|
|
129
160
|
children.push(server);
|
|
130
161
|
|
|
@@ -133,12 +164,16 @@ const client = spawn('npx', ['vite'], {
|
|
|
133
164
|
stdio: 'inherit',
|
|
134
165
|
shell: isWindows,
|
|
135
166
|
detached: !isWindows,
|
|
167
|
+
env: childEnv,
|
|
136
168
|
});
|
|
137
169
|
children.push(client);
|
|
138
170
|
|
|
139
171
|
// Track if we're shutting down
|
|
140
172
|
let shuttingDown = false;
|
|
141
173
|
|
|
174
|
+
// Print the URL banner once both servers are up (fire-and-forget).
|
|
175
|
+
announceWhenReady();
|
|
176
|
+
|
|
142
177
|
function shutdown(exitCode = 0) {
|
|
143
178
|
if (shuttingDown) return;
|
|
144
179
|
shuttingDown = true;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Matches ANSI SGR escape sequences (colour/style codes). In a TTY, Vite wraps
|
|
2
|
+
// the banner labels in colour codes — and crucially places the reset *between*
|
|
3
|
+
// the word and its colon (e.g. `bold("Local") + ":"` → "Local\x1b[22m:"), so the
|
|
4
|
+
// raw line never contains the literal substring "Local:". We strip the escapes
|
|
5
|
+
// before matching. Built from String.fromCharCode(27) rather than a control-char
|
|
6
|
+
// regex literal, which Biome forbids.
|
|
7
|
+
const ANSI_RE = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* True when a Vite log line is the dev-server URL banner that our own dev.js
|
|
11
|
+
* banner already prints (so vite.config.ts's customLogger can drop it). Keeps
|
|
12
|
+
* the "ready in Xms" header, HMR/reload info, warnings, and errors.
|
|
13
|
+
*
|
|
14
|
+
* @param {unknown} msg
|
|
15
|
+
* @returns {boolean}
|
|
16
|
+
*/
|
|
17
|
+
export function isViteUrlBannerLine(msg) {
|
|
18
|
+
if (typeof msg !== 'string') return false;
|
|
19
|
+
// Strip colour codes so matches work the same in a TTY (coloured) and when
|
|
20
|
+
// piped/CI (plain). Vite splits labels and their colons into separate escape
|
|
21
|
+
// wrappers, so substring checks must run against the de-coloured text.
|
|
22
|
+
const plain = msg.replace(ANSI_RE, '');
|
|
23
|
+
const hasUrl = plain.includes('http://') || plain.includes('https://');
|
|
24
|
+
// Vite's startup banner: "➜ Local: <url>" / "➜ Network: <url>".
|
|
25
|
+
if (hasUrl && (plain.includes('Local:') || plain.includes('Network:'))) return true;
|
|
26
|
+
// The interactive shortcuts hint printed right after the URLs.
|
|
27
|
+
if (plain.includes('press h + enter')) return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
@@ -274,7 +274,7 @@
|
|
|
274
274
|
"hero": {
|
|
275
275
|
"badge": "Fully Automated · Self-Hosted",
|
|
276
276
|
"headline1": "Professional Grade",
|
|
277
|
-
"subheading": "Full-stack SaaS
|
|
277
|
+
"subheading": "Full-stack SaaS starter kit with automated everything — built for AI-driven development.",
|
|
278
278
|
"getUpdates": "Get Updates"
|
|
279
279
|
},
|
|
280
280
|
"logoStrip": {
|
|
@@ -102,7 +102,7 @@ if (process.env.NODE_ENV !== 'production' && portOffset !== 0) {
|
|
|
102
102
|
// makes "" mean "not set", matching operator intent (and how the k8s Secret
|
|
103
103
|
// path already skips empty values).
|
|
104
104
|
const cleanedEnv = Object.fromEntries(
|
|
105
|
-
Object.entries(process.env).filter(([, value]) => value !== '')
|
|
105
|
+
Object.entries(process.env).filter(([, value]) => value !== '')
|
|
106
106
|
);
|
|
107
107
|
|
|
108
108
|
// Validate environment variables
|
|
@@ -82,7 +82,8 @@ CREATE TABLE IF NOT EXISTS customers (
|
|
|
82
82
|
|
|
83
83
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_user_id ON customers(user_id) WHERE user_id IS NOT NULL;
|
|
84
84
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_organization_id ON customers(organization_id) WHERE organization_id IS NOT NULL;
|
|
85
|
-
|
|
85
|
+
-- stripe_customer_id is UNIQUE (see column def), which already creates an index;
|
|
86
|
+
-- a separate idx_ would be a duplicate.
|
|
86
87
|
|
|
87
88
|
CREATE TABLE IF NOT EXISTS subscriptions (
|
|
88
89
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
@@ -99,7 +100,8 @@ CREATE TABLE IF NOT EXISTS subscriptions (
|
|
|
99
100
|
);
|
|
100
101
|
|
|
101
102
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer_id ON subscriptions(customer_id);
|
|
102
|
-
|
|
103
|
+
-- stripe_subscription_id is UNIQUE (see column def), which already creates an
|
|
104
|
+
-- index; a separate idx_ would be a duplicate.
|
|
103
105
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
|
|
104
106
|
|
|
105
107
|
-- ============================================================================
|
|
@@ -132,6 +134,11 @@ CREATE INDEX IF NOT EXISTS idx_notifications_org
|
|
|
132
134
|
ON notifications(organization_id)
|
|
133
135
|
WHERE organization_id IS NOT NULL;
|
|
134
136
|
|
|
137
|
+
-- Covering index for the created_by FK (avoids seq scans on auth.users deletes).
|
|
138
|
+
CREATE INDEX IF NOT EXISTS idx_notifications_created_by
|
|
139
|
+
ON notifications(created_by)
|
|
140
|
+
WHERE created_by IS NOT NULL;
|
|
141
|
+
|
|
135
142
|
CREATE TABLE IF NOT EXISTS notification_dismissals (
|
|
136
143
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
137
144
|
notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
|
|
@@ -167,6 +174,11 @@ CREATE TABLE IF NOT EXISTS app_settings (
|
|
|
167
174
|
updated_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
|
|
168
175
|
);
|
|
169
176
|
|
|
177
|
+
-- Covering index for the updated_by FK (avoids seq scans on auth.users deletes).
|
|
178
|
+
CREATE INDEX IF NOT EXISTS idx_app_settings_updated_by
|
|
179
|
+
ON app_settings(updated_by)
|
|
180
|
+
WHERE updated_by IS NOT NULL;
|
|
181
|
+
|
|
170
182
|
-- WARNING: This table is publicly readable by the anonymous role to support
|
|
171
183
|
-- pre-authentication flows (e.g. checking if MFA is enforced).
|
|
172
184
|
-- DO NOT STORE SECRETS, TOKENS, OR SENSITIVE ENVIRONMENT VARIABLES HERE.
|
|
@@ -48,6 +48,10 @@ CREATE OR REPLACE FUNCTION public.log_cron_job(
|
|
|
48
48
|
RETURNS void
|
|
49
49
|
LANGUAGE plpgsql
|
|
50
50
|
SECURITY DEFINER
|
|
51
|
+
-- Pin search_path so this SECURITY DEFINER function can't be hijacked by a
|
|
52
|
+
-- malicious object in an attacker-controlled schema. All refs below are already
|
|
53
|
+
-- schema-qualified (public.cron_job_history), so an empty path is safe.
|
|
54
|
+
SET search_path = ''
|
|
51
55
|
AS $$
|
|
52
56
|
BEGIN
|
|
53
57
|
INSERT INTO public.cron_job_history (job_name, status, finished_at, result, error)
|
|
@@ -52,7 +52,8 @@ CREATE TABLE IF NOT EXISTS public.newsletter_subscribers (
|
|
|
52
52
|
);
|
|
53
53
|
|
|
54
54
|
CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_status ON public.newsletter_subscribers (status);
|
|
55
|
-
|
|
55
|
+
-- email is UNIQUE (see column def), which already creates an index; a separate
|
|
56
|
+
-- idx_ would be a duplicate.
|
|
56
57
|
|
|
57
58
|
ALTER TABLE public.newsletter_subscribers ENABLE ROW LEVEL SECURITY;
|
|
58
59
|
|
package/carbon/vite.config.ts
CHANGED
|
@@ -7,7 +7,8 @@ import rehypeSlug from 'rehype-slug';
|
|
|
7
7
|
import remarkFrontmatter from 'remark-frontmatter';
|
|
8
8
|
import remarkGfm from 'remark-gfm';
|
|
9
9
|
import remarkMdxFrontmatter from 'remark-mdx-frontmatter';
|
|
10
|
-
import { defineConfig, loadEnv } from 'vite';
|
|
10
|
+
import { createLogger, defineConfig, loadEnv } from 'vite';
|
|
11
|
+
import { isViteUrlBannerLine } from './scripts/lib/vite-log-filter.js';
|
|
11
12
|
|
|
12
13
|
// Calculate ports based on offset and overrides
|
|
13
14
|
function getPort(envVarName: string, defaultPort: number, offset: number): number {
|
|
@@ -36,7 +37,19 @@ export default defineConfig(({ mode }) => {
|
|
|
36
37
|
}
|
|
37
38
|
: {};
|
|
38
39
|
|
|
40
|
+
const logger = createLogger();
|
|
41
|
+
const baseInfo = logger.info.bind(logger);
|
|
42
|
+
// Our dev.js banner is the single source of truth for service URLs; drop
|
|
43
|
+
// Vite's redundant Local/Network/"press h" lines but keep readiness, HMR,
|
|
44
|
+
// warnings, and errors.
|
|
45
|
+
logger.info = (msg, options) => {
|
|
46
|
+
if (isViteUrlBannerLine(msg)) return;
|
|
47
|
+
baseInfo(msg, options);
|
|
48
|
+
};
|
|
49
|
+
|
|
39
50
|
return {
|
|
51
|
+
customLogger: logger,
|
|
52
|
+
|
|
40
53
|
plugins: [
|
|
41
54
|
mdx({
|
|
42
55
|
remarkPlugins: [remarkFrontmatter, remarkGfm, remarkMdxFrontmatter],
|
|
@@ -8,7 +8,11 @@ DECLARE
|
|
|
8
8
|
new_user_id UUID;
|
|
9
9
|
BEGIN
|
|
10
10
|
-- Only create if admin email is provided and not empty
|
|
11
|
-
|
|
11
|
+
-- Skip when the email is still an unreplaced "{{ADMIN_EMAIL}}" template token
|
|
12
|
+
-- (e.g. the raw dev template mounted at /tmp/super-admin.sql): the existence
|
|
13
|
+
-- check could never match, so it would always attempt a duplicate INSERT and
|
|
14
|
+
-- collide on UNIQUE(phone) with the admin created at container init.
|
|
15
|
+
IF admin_email IS NOT NULL AND admin_email != '' AND admin_email NOT LIKE '{{%' THEN
|
|
12
16
|
|
|
13
17
|
-- Check if user already exists
|
|
14
18
|
IF NOT EXISTS (SELECT 1 FROM auth.users WHERE email = admin_email) THEN
|
|
@@ -9,7 +9,10 @@ DECLARE
|
|
|
9
9
|
new_user_id UUID;
|
|
10
10
|
BEGIN
|
|
11
11
|
-- Only create if admin email is provided and not empty
|
|
12
|
-
|
|
12
|
+
-- Skip when the email is still an unreplaced "{{ADMIN_EMAIL}}" template token:
|
|
13
|
+
-- the existence check could never match, so it would always attempt a
|
|
14
|
+
-- duplicate INSERT and collide on UNIQUE(phone) with an existing admin.
|
|
15
|
+
IF admin_email IS NOT NULL AND admin_email != '' AND admin_email NOT LIKE '{{%' THEN
|
|
13
16
|
|
|
14
17
|
-- Check if user already exists
|
|
15
18
|
IF NOT EXISTS (SELECT 1 FROM auth.users WHERE email = admin_email) THEN
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vibecarbon",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Create and manage production-ready Vibecarbon applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./src/cli.js",
|
|
@@ -57,12 +57,12 @@
|
|
|
57
57
|
"url": "https://github.com/hyperformant/vibecarbon.git"
|
|
58
58
|
},
|
|
59
59
|
"dependencies": {
|
|
60
|
-
"@aws-sdk/client-s3": "^3.
|
|
61
|
-
"@clack/prompts": "^1.
|
|
62
|
-
"@pulumi/hcloud": "^1.
|
|
63
|
-
"@pulumi/pulumi": "^3.
|
|
60
|
+
"@aws-sdk/client-s3": "^3.1077.0",
|
|
61
|
+
"@clack/prompts": "^1.6.0",
|
|
62
|
+
"@pulumi/hcloud": "^1.39.0",
|
|
63
|
+
"@pulumi/pulumi": "^3.248.0",
|
|
64
64
|
"bcryptjs": "^3.0.3",
|
|
65
|
-
"undici": "^8.
|
|
65
|
+
"undici": "^8.5.0",
|
|
66
66
|
"which": "^7.0.0"
|
|
67
67
|
},
|
|
68
68
|
"pnpm": {
|
|
@@ -81,18 +81,18 @@
|
|
|
81
81
|
}
|
|
82
82
|
},
|
|
83
83
|
"devDependencies": {
|
|
84
|
-
"@biomejs/biome": "^2.
|
|
84
|
+
"@biomejs/biome": "^2.5.1",
|
|
85
85
|
"@semantic-release/changelog": "^6.0.3",
|
|
86
86
|
"@semantic-release/git": "^10.0.1",
|
|
87
87
|
"@types/better-sqlite3": "^7.6.13",
|
|
88
|
-
"@vitest/coverage-v8": "^4.1.
|
|
88
|
+
"@vitest/coverage-v8": "^4.1.9",
|
|
89
89
|
"autocannon": "^8.0.0",
|
|
90
|
-
"better-sqlite3": "^12.
|
|
91
|
-
"conventional-changelog-conventionalcommits": "^
|
|
92
|
-
"semantic-release": "^25.0.
|
|
90
|
+
"better-sqlite3": "^12.11.1",
|
|
91
|
+
"conventional-changelog-conventionalcommits": "^10.2.0",
|
|
92
|
+
"semantic-release": "^25.0.5",
|
|
93
93
|
"tsx": "^4.22.4",
|
|
94
|
-
"vite": "^8.
|
|
95
|
-
"vitest": "^4.1.
|
|
94
|
+
"vite": "^8.1.2",
|
|
95
|
+
"vitest": "^4.1.9",
|
|
96
96
|
"zod": "^4.4.3"
|
|
97
97
|
}
|
|
98
98
|
}
|
package/src/create.js
CHANGED
|
@@ -1165,6 +1165,64 @@ ${runCmd} test:prepush
|
|
|
1165
1165
|
);
|
|
1166
1166
|
await tick();
|
|
1167
1167
|
|
|
1168
|
+
// Create the initial commit so the user starts from a clean, tracked
|
|
1169
|
+
// template baseline (not a pile of untracked files). Guarded by the same
|
|
1170
|
+
// `gitAvailable` flag as `git init` above — if git isn't on PATH we already
|
|
1171
|
+
// logged the skip note and there's no repo to commit into.
|
|
1172
|
+
if (gitAvailable) {
|
|
1173
|
+
s.message('Creating initial commit');
|
|
1174
|
+
|
|
1175
|
+
// The pre-commit hook we just installed runs `${pm} lint` under `set -e`,
|
|
1176
|
+
// and node_modules is absent by default (install is opt-in via -install),
|
|
1177
|
+
// so a verified commit would fail. The template is vibecarbon-authored and
|
|
1178
|
+
// already clean; the hooks exist to gate the user's *future* commits.
|
|
1179
|
+
const commitArgs = ['git', 'commit', '--no-verify', '-m', 'Initial commit from vibecarbon'];
|
|
1180
|
+
|
|
1181
|
+
// `git commit` aborts when no author identity is configured. If neither
|
|
1182
|
+
// user.name nor user.email is set, prepend fallback identity via `-c`
|
|
1183
|
+
// (command-line `-c` only overrides config files, and is itself overridden
|
|
1184
|
+
// by any GIT_AUTHOR_* env — so a real identity, by config or env, always
|
|
1185
|
+
// wins; the fallback only fills a total void).
|
|
1186
|
+
const name = runCommand(['git', 'config', 'user.name'], {
|
|
1187
|
+
cwd: projectDir,
|
|
1188
|
+
cleanEnv: true,
|
|
1189
|
+
silent: true,
|
|
1190
|
+
ignoreError: true,
|
|
1191
|
+
});
|
|
1192
|
+
const email = runCommand(['git', 'config', 'user.email'], {
|
|
1193
|
+
cwd: projectDir,
|
|
1194
|
+
cleanEnv: true,
|
|
1195
|
+
silent: true,
|
|
1196
|
+
ignoreError: true,
|
|
1197
|
+
});
|
|
1198
|
+
if (!name || !email || !String(name).trim() || !String(email).trim()) {
|
|
1199
|
+
commitArgs.splice(
|
|
1200
|
+
1,
|
|
1201
|
+
0,
|
|
1202
|
+
'-c',
|
|
1203
|
+
'user.name=vibecarbon',
|
|
1204
|
+
'-c',
|
|
1205
|
+
'user.email=vibecarbon@users.noreply.github.com',
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const staged = runCommand(['git', 'add', '-A'], {
|
|
1210
|
+
cwd: projectDir,
|
|
1211
|
+
cleanEnv: true,
|
|
1212
|
+
ignoreError: true,
|
|
1213
|
+
});
|
|
1214
|
+
const committed =
|
|
1215
|
+
staged !== null &&
|
|
1216
|
+
runCommand(commitArgs, { cwd: projectDir, cleanEnv: true, ignoreError: true }) !== null;
|
|
1217
|
+
|
|
1218
|
+
if (!committed) {
|
|
1219
|
+
p.log.warn(
|
|
1220
|
+
"Couldn't create the initial commit — run `git add -A && git commit` in the project to capture the baseline.",
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
await tick();
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1168
1226
|
s.stop('Project created successfully');
|
|
1169
1227
|
registerProject(projectName, projectDir);
|
|
1170
1228
|
tracker.finish();
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orphan dev-session reclaim helpers for `vibecarbon up`.
|
|
3
|
+
*
|
|
4
|
+
* When a project port is in use, the most common cause is an orphaned dev
|
|
5
|
+
* session belonging to the SAME project — a previous `node scripts/dev.js`
|
|
6
|
+
* tree (its child Vite + tsx API servers) that outlived its parent and was
|
|
7
|
+
* reparented to init while still holding the port. Rather than dodge the
|
|
8
|
+
* conflict by bumping DEV_PORT_OFFSET (which leaves the runaway alive and
|
|
9
|
+
* drifts the project onto a new port band), we identify the process actually
|
|
10
|
+
* listening on the port and, if it belongs to this project's working
|
|
11
|
+
* directory, kill its whole process group so a clean restart can reuse the
|
|
12
|
+
* normal ports.
|
|
13
|
+
*
|
|
14
|
+
* Strict scoping is the safety contract: we kill a process only when its
|
|
15
|
+
* working directory resolves to the project dir (or a subdir). A process we
|
|
16
|
+
* can't introspect (different user → EACCES) is treated as foreign and never
|
|
17
|
+
* touched. On non-Unix platforms, or when `lsof`/`ps` are unavailable, the
|
|
18
|
+
* helpers degrade to no-ops and the caller falls back to offset-bumping.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import { readlinkSync } from 'node:fs';
|
|
23
|
+
import { resolve, sep } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const isUnix = () => process.platform !== 'win32';
|
|
26
|
+
|
|
27
|
+
const RUN_OPTS = { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] };
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* PIDs listening on the given TCP port. Returns deduped positive integers,
|
|
31
|
+
* or [] on non-Unix, missing `lsof`, or any error.
|
|
32
|
+
* @param {number} port
|
|
33
|
+
* @returns {number[]}
|
|
34
|
+
*/
|
|
35
|
+
export function findPortListeners(port) {
|
|
36
|
+
if (!isUnix()) return [];
|
|
37
|
+
try {
|
|
38
|
+
const out = execFileSync('lsof', ['-t', `-iTCP:${port}`, '-sTCP:LISTEN'], RUN_OPTS);
|
|
39
|
+
const pids = out
|
|
40
|
+
.trim()
|
|
41
|
+
.split('\n')
|
|
42
|
+
.map((line) => Number(line.trim()))
|
|
43
|
+
.filter((n) => Number.isInteger(n) && n > 0);
|
|
44
|
+
return [...new Set(pids)];
|
|
45
|
+
} catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Resolved working directory of a process, or null if it can't be read
|
|
52
|
+
* (gone, foreign user / EACCES, or unsupported platform). Linux reads
|
|
53
|
+
* /proc/<pid>/cwd; other Unixes fall back to `lsof`.
|
|
54
|
+
* @param {number} pid
|
|
55
|
+
* @returns {string | null}
|
|
56
|
+
*/
|
|
57
|
+
export function getProcessCwd(pid) {
|
|
58
|
+
if (!isUnix()) return null;
|
|
59
|
+
try {
|
|
60
|
+
return readlinkSync(`/proc/${pid}/cwd`);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// Different user owns the process — treat as foreign, never kill.
|
|
63
|
+
if (err && err.code === 'EACCES') return null;
|
|
64
|
+
// ENOENT (no /proc, e.g. macOS) → try lsof; other errors → null below.
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
const out = execFileSync('lsof', ['-a', '-p', String(pid), '-d', 'cwd', '-Fn'], RUN_OPTS);
|
|
68
|
+
const nameLine = out.split('\n').find((line) => line.startsWith('n'));
|
|
69
|
+
return nameLine ? nameLine.slice(1) : null;
|
|
70
|
+
} catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Process group id (pgid) of a process, or null on failure.
|
|
77
|
+
* @param {number} pid
|
|
78
|
+
* @returns {number | null}
|
|
79
|
+
*/
|
|
80
|
+
export function getProcessGroup(pid) {
|
|
81
|
+
if (!isUnix()) return null;
|
|
82
|
+
try {
|
|
83
|
+
const out = execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], RUN_OPTS);
|
|
84
|
+
const pgid = Number(out.trim());
|
|
85
|
+
return Number.isInteger(pgid) && pgid > 0 ? pgid : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* True iff the process's working directory resolves to `projectCwd` or a
|
|
93
|
+
* subdirectory of it.
|
|
94
|
+
* @param {number} pid
|
|
95
|
+
* @param {string} projectCwd
|
|
96
|
+
* @returns {boolean}
|
|
97
|
+
*/
|
|
98
|
+
export function isOwnedByProject(pid, projectCwd) {
|
|
99
|
+
const cwd = getProcessCwd(pid);
|
|
100
|
+
if (!cwd) return false;
|
|
101
|
+
const proc = resolve(cwd);
|
|
102
|
+
const root = resolve(projectCwd);
|
|
103
|
+
return proc === root || proc.startsWith(root + sep);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
107
|
+
|
|
108
|
+
/** True if the process is still alive (signal 0 probe). */
|
|
109
|
+
function isAlive(pid) {
|
|
110
|
+
try {
|
|
111
|
+
process.kill(pid, 0);
|
|
112
|
+
return true;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
// ESRCH → gone. EPERM → alive but not ours (shouldn't happen for an
|
|
115
|
+
// owned orphan) — report alive so we don't claim a false reclaim.
|
|
116
|
+
return !!err && err.code !== 'ESRCH';
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Best-effort signal to a target; swallows ESRCH/EPERM races. */
|
|
121
|
+
function signal(target, sig) {
|
|
122
|
+
try {
|
|
123
|
+
process.kill(target, sig);
|
|
124
|
+
} catch {
|
|
125
|
+
// Already gone or not permitted — nothing more we can do.
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Reclaim a port held by this project's own orphaned dev session.
|
|
131
|
+
*
|
|
132
|
+
* Finds the listeners on `port`, keeps only those whose working directory
|
|
133
|
+
* belongs to `projectCwd`, and kills each owned holder's process group
|
|
134
|
+
* (SIGTERM, then SIGKILL after a grace period). Foreign holders are reported
|
|
135
|
+
* but never touched.
|
|
136
|
+
*
|
|
137
|
+
* @param {number} port
|
|
138
|
+
* @param {string} projectCwd
|
|
139
|
+
* @param {{ graceMs?: number, intervalMs?: number }} [opts]
|
|
140
|
+
* @returns {Promise<{ killed: number[], foreign: number[], freed: boolean }>}
|
|
141
|
+
*/
|
|
142
|
+
export async function reclaimPort(port, projectCwd, { graceMs = 2000, intervalMs = 100 } = {}) {
|
|
143
|
+
const listeners = findPortListeners(port);
|
|
144
|
+
if (listeners.length === 0) return { killed: [], foreign: [], freed: false };
|
|
145
|
+
|
|
146
|
+
const owned = [];
|
|
147
|
+
const foreign = [];
|
|
148
|
+
for (const pid of listeners) {
|
|
149
|
+
if (isOwnedByProject(pid, projectCwd)) owned.push(pid);
|
|
150
|
+
else foreign.push(pid);
|
|
151
|
+
}
|
|
152
|
+
if (owned.length === 0) return { killed: [], foreign, freed: false };
|
|
153
|
+
|
|
154
|
+
// Prefer group-targeted kills so the whole dev.js → vite/tsx tree dies in
|
|
155
|
+
// one shot; fall back to the bare pid if we can't resolve a pgid.
|
|
156
|
+
const seenGroups = new Set();
|
|
157
|
+
const targets = [];
|
|
158
|
+
for (const pid of owned) {
|
|
159
|
+
const pgid = getProcessGroup(pid);
|
|
160
|
+
if (pgid) {
|
|
161
|
+
if (!seenGroups.has(pgid)) {
|
|
162
|
+
seenGroups.add(pgid);
|
|
163
|
+
targets.push(-pgid);
|
|
164
|
+
}
|
|
165
|
+
} else {
|
|
166
|
+
targets.push(pid);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
for (const target of targets) signal(target, 'SIGTERM');
|
|
171
|
+
|
|
172
|
+
const deadline = Date.now() + graceMs;
|
|
173
|
+
while (owned.some(isAlive) && Date.now() < deadline) {
|
|
174
|
+
await sleep(Math.min(intervalMs, Math.max(0, deadline - Date.now())));
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (owned.some(isAlive)) {
|
|
178
|
+
for (const target of targets) signal(target, 'SIGKILL');
|
|
179
|
+
await sleep(intervalMs);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const killed = owned.filter((pid) => !isAlive(pid));
|
|
183
|
+
const freed = !findPortListeners(port).length;
|
|
184
|
+
return { killed, foreign, freed };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* @typedef {{ name: string, port: number }} PortConflict
|
|
189
|
+
*/
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Reclaim each conflicting port held by this project's orphaned dev session,
|
|
193
|
+
* then report which conflicts remain (foreign or otherwise unrecoverable) so
|
|
194
|
+
* the caller can fall back to offset-bumping for just those.
|
|
195
|
+
*
|
|
196
|
+
* Each port is re-checked after reclaim because killing one orphan's process
|
|
197
|
+
* group can free a sibling port in the same dev tree.
|
|
198
|
+
*
|
|
199
|
+
* @param {PortConflict[]} conflicts
|
|
200
|
+
* @param {string} projectCwd
|
|
201
|
+
* @param {{
|
|
202
|
+
* reclaim?: (port: number, cwd: string) => Promise<{ killed: number[], foreign: number[], freed: boolean }>,
|
|
203
|
+
* recheck: (port: number) => Promise<boolean>,
|
|
204
|
+
* onReclaim?: (conflict: PortConflict, result: { killed: number[] }) => void,
|
|
205
|
+
* }} deps
|
|
206
|
+
* @returns {Promise<{ reclaimed: Array<PortConflict & { killed: number[] }>, remaining: PortConflict[] }>}
|
|
207
|
+
*/
|
|
208
|
+
export async function reclaimOrphanPorts(conflicts, projectCwd, deps) {
|
|
209
|
+
const { reclaim = reclaimPort, recheck, onReclaim } = deps;
|
|
210
|
+
const reclaimed = [];
|
|
211
|
+
const remaining = [];
|
|
212
|
+
for (const conflict of conflicts) {
|
|
213
|
+
const result = await reclaim(conflict.port, projectCwd);
|
|
214
|
+
if (result.killed.length > 0) {
|
|
215
|
+
reclaimed.push({ ...conflict, killed: result.killed });
|
|
216
|
+
onReclaim?.(conflict, result);
|
|
217
|
+
}
|
|
218
|
+
if (await recheck(conflict.port)) remaining.push(conflict);
|
|
219
|
+
}
|
|
220
|
+
return { reclaimed, remaining };
|
|
221
|
+
}
|
package/src/up.js
CHANGED
|
@@ -14,6 +14,7 @@ import { renderHelp } from './lib/cli/help.js';
|
|
|
14
14
|
import { parseFlags } from './lib/cli/parse-flags.js';
|
|
15
15
|
import { c, printBanner } from './lib/colors.js';
|
|
16
16
|
import { runCommandThroughTaskLog } from './lib/command.js';
|
|
17
|
+
import { reclaimOrphanPorts } from './lib/orphan.js';
|
|
17
18
|
import { detectPackageManager } from './lib/project.js';
|
|
18
19
|
import { assertInProjectDir } from './lib/project-guard.js';
|
|
19
20
|
import { VERSION } from './lib/version.js';
|
|
@@ -219,14 +220,28 @@ export async function run(args = []) {
|
|
|
219
220
|
}
|
|
220
221
|
|
|
221
222
|
if (conflicts.length > 0) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
223
|
+
// First reclaim any ports held by THIS project's own orphaned dev session
|
|
224
|
+
// (a previous `dev.js` tree that outlived its parent and kept squatting on
|
|
225
|
+
// the ports). Killing the orphan lets us reuse the normal port band; only
|
|
226
|
+
// genuinely foreign conflicts fall through to offset-bumping.
|
|
227
|
+
const { remaining } = await reclaimOrphanPorts(conflicts, cwd, {
|
|
228
|
+
recheck: isPortInUse,
|
|
229
|
+
onReclaim: ({ name, port }, { killed }) =>
|
|
230
|
+
p.log.success(
|
|
231
|
+
`Reclaimed ${name} port ${c.bold(String(port))} from orphaned dev session (PID ${killed.join(', ')})`,
|
|
232
|
+
),
|
|
233
|
+
});
|
|
226
234
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
235
|
+
if (remaining.length > 0) {
|
|
236
|
+
p.log.warn('Port conflicts detected (not owned by this project):');
|
|
237
|
+
for (const { name, port } of remaining) {
|
|
238
|
+
p.log.message(` ${c.dim(name.padEnd(20))} port ${c.bold(String(port))} is in use`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const offset = await findFreeOffset(cwd);
|
|
242
|
+
setPortOffset(offset, cwd);
|
|
243
|
+
p.log.success(`Applied DEV_PORT_OFFSET=${offset} to .env.local`);
|
|
244
|
+
}
|
|
230
245
|
}
|
|
231
246
|
|
|
232
247
|
const pm = detectPackageManager(cwd);
|