vibecarbon 0.6.1 → 0.7.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.
@@ -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": "docker compose exec db bash -c 'for f in /migrations/*.sql; do psql -U postgres -d postgres -f \"$f\" || exit 1; done' && docker compose exec db psql -U postgres -d postgres -f /tmp/super-admin.sql",
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",
@@ -75,7 +75,7 @@
75
75
  "lenis": "^1.3.25",
76
76
  "lucide-react": "^1.17.0",
77
77
  "next-themes": "^0.4.6",
78
- "nodemailer": "^8.0.10",
78
+ "nodemailer": "^9.0.1",
79
79
  "pino": "^10.3.1",
80
80
  "react": "^19.2.7",
81
81
  "react-day-picker": "^10.0.1",
@@ -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=""
@@ -109,22 +109,53 @@ if (conflicts.length > 0) {
109
109
  process.exit(1);
110
110
  }
111
111
 
112
- console.log(`
113
- ${cyan('✦')} Starting ${projectName} services...
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')} (requires login)
118
- ${cyan('→')} ${bold('Traefik:')} ${cyan('http://traefik.localhost')} (requires login)
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 architecture with auth, billing, infrastructure, and deployment — built for AI-driven development.",
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": {
@@ -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
- CREATE INDEX IF NOT EXISTS idx_customers_stripe_customer_id ON customers(stripe_customer_id);
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
- CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id);
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
- CREATE INDEX IF NOT EXISTS idx_newsletter_subscribers_email ON public.newsletter_subscribers (email);
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
 
@@ -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
- IF admin_email IS NOT NULL AND admin_email != '' THEN
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
- IF admin_email IS NOT NULL AND admin_email != '' THEN
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.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "Create and manage production-ready Vibecarbon applications",
5
5
  "type": "module",
6
6
  "bin": "./src/cli.js",
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();