vibecarbon 0.7.0 → 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 CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
- "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json",
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
- "recommended": true,
7
+ "preset": "recommended",
7
8
  "complexity": {
8
9
  "noUselessFragments": "warn"
9
10
  },
@@ -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.5.0",
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.49",
62
- "@supabase/supabase-js": "^2.108.2",
63
- "@tanstack/react-query": "^5.100.14",
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.40.0",
69
+ "framer-motion": "^12.42.2",
70
70
  "hono": "^4.12.27",
71
- "i18next": "^26.3.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.17.0",
76
+ "lucide-react": "^1.22.0",
77
77
  "next-themes": "^0.4.6",
78
- "nodemailer": "^9.0.1",
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.77.0",
83
+ "react-hook-form": "^7.80.0",
84
84
  "react-i18next": "^17.0.8",
85
- "react-resizable-panels": "^4.11.2",
86
- "react-router-dom": "^7.16.0",
87
- "recharts": "^3.8.1",
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.2.0",
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.4.16",
125
+ "@biomejs/biome": "^2.5.1",
126
126
  "@hono/swagger-ui": "^0.6.1",
127
- "@scalar/hono-api-reference": "^0.10.19",
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": "^25.9.1",
134
- "@types/nodemailer": "^8.0.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.21.0",
142
+ "pg": "^8.22.0",
143
143
  "pino-pretty": "^13.1.3",
144
144
  "remark-gfm": "^4.0.1",
145
- "shadcn": "^4.10.0",
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.0.16",
150
+ "vite": "^8.1.2",
151
151
  "vitest": "^4.1.9"
152
152
  }
153
153
  }
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vibecarbon",
3
- "version": "0.7.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.1058.0",
61
- "@clack/prompts": "^1.5.0",
62
- "@pulumi/hcloud": "^1.38.0",
63
- "@pulumi/pulumi": "^3.244.0",
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.3.0",
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.4.16",
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.8",
88
+ "@vitest/coverage-v8": "^4.1.9",
89
89
  "autocannon": "^8.0.0",
90
- "better-sqlite3": "^12.10.0",
91
- "conventional-changelog-conventionalcommits": "^9.3.1",
92
- "semantic-release": "^25.0.3",
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.0.16",
95
- "vitest": "^4.1.8",
94
+ "vite": "^8.1.2",
95
+ "vitest": "^4.1.9",
96
96
  "zod": "^4.4.3"
97
97
  }
98
98
  }
@@ -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
- p.log.warn('Port conflicts detected:');
223
- for (const { name, port } of conflicts) {
224
- p.log.message(` ${c.dim(name.padEnd(20))} port ${c.bold(String(port))} is in use`);
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
- const offset = await findFreeOffset(cwd);
228
- setPortOffset(offset, cwd);
229
- p.log.success(`Applied DEV_PORT_OFFSET=${offset} to .env.local`);
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);