getadvantage 0.1.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.
@@ -0,0 +1,136 @@
1
+ // Ship-Safe — runs the full check suite and renders the per-check + overall
2
+ // verdict. Shared by both `ship-safe check` and `ship-safe deploy` so the gate
3
+ // is identical in both paths.
4
+
5
+ import { c, GLYPH, printResult, section } from "./util.mjs";
6
+ import {
7
+ checkDirtyTree,
8
+ checkSecrets,
9
+ checkTypecheck,
10
+ checkBuild,
11
+ checkSchemaBump,
12
+ } from "./checks.mjs";
13
+ import {
14
+ overviewApiSurface,
15
+ overviewIntegrations,
16
+ overviewSchedules,
17
+ } from "./overviews.mjs";
18
+ import { briefStaleness } from "./brief.mjs";
19
+
20
+ /**
21
+ * Run every check and print a clean summary.
22
+ * @param {object} o
23
+ * @param {string} o.cwd repo root
24
+ * @param {boolean} o.runBuild also run a full `npm run build` (slower)
25
+ * @param {string} [o.baseRef] merge-base ref for the schema diff (default main)
26
+ * @param {boolean} [o.overview] run the v1.1 read-only overview scanners
27
+ * (API surface · integrations · schedules).
28
+ * Defaults to ON — they're fast, read-only maps.
29
+ * @param {boolean} [o.briefCheck] emit a NON-BLOCKING staleness warning if the
30
+ * PROJECT BRAIN (PROJECT-BRIEF.md) is missing or
31
+ * out of date. Defaults to ON. Never a fail.
32
+ * @returns {Promise<{ exitCode: number, results: any[] }>} exitCode 0=GO, 1=NO-GO
33
+ */
34
+ export async function runChecks(o) {
35
+ const cwd = o.cwd;
36
+ const results = [];
37
+
38
+ section("Checks");
39
+
40
+ // a. Dirty-tree guard
41
+ results.push(safe(() => checkDirtyTree(cwd), "Dirty-tree guard"));
42
+ printResult(results[results.length - 1]);
43
+
44
+ // b. Secret scan
45
+ results.push(safe(() => checkSecrets(cwd), "Secret scan"));
46
+ printResult(results[results.length - 1]);
47
+
48
+ // c. Typecheck (always) + optional full build
49
+ results.push(safe(() => checkTypecheck(cwd), "Typecheck (tsc --noEmit)"));
50
+ printResult(results[results.length - 1]);
51
+ if (o.runBuild) {
52
+ results.push(safe(() => checkBuild(cwd), "Production build (npm run build)"));
53
+ printResult(results[results.length - 1]);
54
+ }
55
+
56
+ // d. Schema-bump check
57
+ results.push(safe(() => checkSchemaBump(cwd, o.baseRef || "main"), "Schema-bump check"));
58
+ printResult(results[results.length - 1]);
59
+
60
+ // ---- v1.1 OVERVIEW SCANNERS (read-only maps) ----------------------------
61
+ // Default-on: three fast, read-only repo scans that give the builder a map of
62
+ // what their app actually has. They emit pass/warn only (never a blocking
63
+ // fail) — a surprising map is informational, the v1 safety checks own NO-GO.
64
+ // Disable with `--no-overview`.
65
+ const runOverview = o.overview !== false;
66
+ if (runOverview) {
67
+ section("Overview — what your app has (read-only)");
68
+
69
+ results.push(safe(() => overviewApiSurface(cwd), "API surface map"));
70
+ printResult(results[results.length - 1]);
71
+
72
+ results.push(safe(() => overviewIntegrations(cwd), "Agents & integrations map"));
73
+ printResult(results[results.length - 1]);
74
+
75
+ results.push(safe(() => overviewSchedules(cwd), "Schedules & jobs map"));
76
+ printResult(results[results.length - 1]);
77
+ }
78
+
79
+ // ---- PROJECT BRAIN staleness (non-blocking) -----------------------------
80
+ // Warn (never fail) if the repo-resident project brief is missing or stale,
81
+ // so the portable context any model/tool reads on start doesn't silently rot.
82
+ if (o.briefCheck !== false) {
83
+ results.push(
84
+ safe(() => {
85
+ const s = briefStaleness(cwd);
86
+ if (s.status === "ok") {
87
+ return { status: "pass", label: "Project brief", detail: s.reason, extra: [] };
88
+ }
89
+ return {
90
+ status: "warn",
91
+ label: "Project brief",
92
+ detail: s.reason,
93
+ extra: ["project brief is stale, run `ship-safe brief` to refresh."],
94
+ };
95
+ }, "Project brief"),
96
+ );
97
+ printResult(results[results.length - 1]);
98
+ }
99
+
100
+ // ---- Overall verdict ----------------------------------------------------
101
+ const fails = results.filter((r) => r.status === "fail").length;
102
+ const warns = results.filter((r) => r.status === "warn").length;
103
+ const passes = results.filter((r) => r.status === "pass").length;
104
+
105
+ section("Verdict");
106
+ console.log(` ${GLYPH.pass} ${passes} ${GLYPH.warn} ${warns} ${GLYPH.fail} ${fails}`);
107
+
108
+ if (fails > 0) {
109
+ console.log(
110
+ "\n" + c.red(c.bold(" NO-GO")) + c.red(` — ${fails} blocking issue(s). Do not ship until these are clear.`),
111
+ );
112
+ return { exitCode: 1, results };
113
+ }
114
+ if (warns > 0) {
115
+ console.log(
116
+ "\n" + c.green(c.bold(" GO")) + c.yellow(` — with ${warns} warning(s) to eyeball first.`),
117
+ );
118
+ return { exitCode: 0, results };
119
+ }
120
+ console.log("\n" + c.green(c.bold(" GO")) + c.green(" — all checks clear. Safe to ship."));
121
+ return { exitCode: 0, results };
122
+ }
123
+
124
+ /** Never let one check throwing crash the whole gate — surface it as a fail. */
125
+ function safe(fn, label) {
126
+ try {
127
+ return fn();
128
+ } catch (e) {
129
+ return {
130
+ status: "fail",
131
+ label,
132
+ detail: `Check errored: ${e.message || e}`,
133
+ extra: [],
134
+ };
135
+ }
136
+ }
package/checks.mjs ADDED
@@ -0,0 +1,327 @@
1
+ // Ship-Safe — the read-only pre-deploy checks.
2
+ //
3
+ // Every check returns a `result(status, label, detail, extra)`:
4
+ // pass (✓) · warn (⚠) · fail (✗ → NO-GO).
5
+ // Nothing here mutates the repo. The secret patterns + dirty-tree reasoning are
6
+ // lifted from getAdvantage's own conventions (CLAUDE.md hard-rule #2 and
7
+ // app/lib/safety.ts) so the gate matches what the project already enforces.
8
+
9
+ import { execFileSync } from "node:child_process";
10
+ import { readFileSync, statSync } from "node:fs";
11
+ import path from "node:path";
12
+ import { result, fingerprint, git, gitRaw, gitSafe } from "./util.mjs";
13
+
14
+ // ===========================================================================
15
+ // a. DIRTY-TREE GUARD
16
+ // ===========================================================================
17
+ // `vercel --prod` ships the WORKING TREE, not a commit — so any tracked,
18
+ // modified/staged file (possibly another concurrent session's uncommitted
19
+ // work) would ship live. BLOCK on tracked changes; WARN on untracked (often
20
+ // scratch). This is the single biggest known foot-gun in this repo.
21
+ export function checkDirtyTree(cwd) {
22
+ // gitRaw (NOT git) — porcelain's leading status columns are space-significant,
23
+ // so we must not trim the output before parsing.
24
+ const porcelain = gitRaw(["status", "--porcelain"], { cwd });
25
+ if (!porcelain.trim()) {
26
+ return result("pass", "Dirty-tree guard", "Working tree is clean — nothing unintended would ship.");
27
+ }
28
+
29
+ const lines = porcelain.split("\n").filter((l) => l.length > 0);
30
+ const tracked = []; // modified / staged / renamed / deleted tracked files
31
+ const untracked = []; // ?? — new files git isn't tracking yet
32
+
33
+ for (const line of lines) {
34
+ // Porcelain v1: XY<space>path (X=staged, Y=worktree). "??" = untracked.
35
+ const xy = line.slice(0, 2);
36
+ const file = line.slice(3);
37
+ if (xy === "??") untracked.push(file);
38
+ else tracked.push(`${xy.trim() || xy} ${file}`);
39
+ }
40
+
41
+ if (tracked.length > 0) {
42
+ return result(
43
+ "fail",
44
+ "Dirty-tree guard",
45
+ `${tracked.length} tracked file(s) modified/staged — a 'vercel --prod' would ship this unintended work.`,
46
+ [
47
+ ...tracked.slice(0, 20).map((t) => t),
48
+ ...(tracked.length > 20 ? [`…and ${tracked.length - 20} more`] : []),
49
+ "Commit, stash, or revert before shipping. Deploy from a clean detached worktree of the intended commit.",
50
+ ],
51
+ );
52
+ }
53
+
54
+ // Only untracked files → warn, list them (they're usually scratch/logs).
55
+ return result(
56
+ "warn",
57
+ "Dirty-tree guard",
58
+ `${untracked.length} untracked file(s) present (not tracked — likely scratch, but confirm they shouldn't ship).`,
59
+ [
60
+ ...untracked.slice(0, 20),
61
+ ...(untracked.length > 20 ? [`…and ${untracked.length - 20} more`] : []),
62
+ ],
63
+ );
64
+ }
65
+
66
+ // ===========================================================================
67
+ // b. SECRET SCAN
68
+ // ===========================================================================
69
+ // Patterns reuse getAdvantage's own definitions:
70
+ // • CLAUDE.md hard-rule #2: sk-proj / sk-live / sk_live / whsec_ / vcp_ /
71
+ // KV_REST / "Bearer " / long token literals.
72
+ // • app/lib/safety.ts SECRET_PATTERNS: OpenAI sk-, Stripe sk_live_/rk_live_,
73
+ // AWS AKIA, GitHub PAT (classic + fine-grained), Google OAuth, Slack,
74
+ // SendGrid, private-key blocks.
75
+ // A match BLOCKS (✗); we print the file + a masked FINGERPRINT, never the
76
+ // full secret. Binary/lockfiles/node_modules/.git are skipped.
77
+
78
+ const SECRET_PATTERNS = [
79
+ // --- from app/lib/safety.ts ---
80
+ { id: "openai", label: "OpenAI secret key", re: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,}/ },
81
+ { id: "stripe-live", label: "Stripe live secret key", re: /\bsk_live_[A-Za-z0-9]{20,}/ },
82
+ { id: "stripe-restricted", label: "Stripe restricted key", re: /\brk_live_[A-Za-z0-9]{20,}/ },
83
+ { id: "aws", label: "AWS access key id", re: /\bAKIA[0-9A-Z]{16}\b/ },
84
+ { id: "github-pat", label: "GitHub personal access token", re: /\bghp_[A-Za-z0-9]{36}\b/ },
85
+ { id: "github-fine", label: "GitHub fine-grained token", re: /\bgithub_pat_[A-Za-z0-9_]{22,}\b/ },
86
+ { id: "google-oauth", label: "Google OAuth secret", re: /\bGOCSPX-[A-Za-z0-9_-]{20,}\b/ },
87
+ { id: "slack", label: "Slack token", re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
88
+ { id: "private-key", label: "Private key block", re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
89
+ { id: "sendgrid", label: "SendGrid key", re: /\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}\b/ },
90
+ // --- from CLAUDE.md hard-rule #2 (the pre-commit scan literals) ---
91
+ { id: "stripe-webhook", label: "Stripe webhook secret (whsec_)", re: /\bwhsec_[A-Za-z0-9]{20,}/ },
92
+ { id: "vercel-token", label: "Vercel token (vcp_)", re: /\bvcp_[A-Za-z0-9]{20,}/ },
93
+ { id: "kv-rest", label: "KV/Redis REST credential", re: /\bKV_REST_API_(?:URL|TOKEN|READ_ONLY_TOKEN)\s*=\s*\S+/ },
94
+ // "Bearer <token>" literal. The CLAUDE.md pre-commit scan lists "Bearer " as a
95
+ // tell. We capture the token and only flag it if it LOOKS like a real
96
+ // credential (mixed case + a digit, ≥20 chars) — so legitimate test fixtures
97
+ // and placeholders like `Bearer test_cron_secret...` or `Bearer ${token}`
98
+ // don't trip a false NO-GO. The `validate` predicate runs on the captured group.
99
+ {
100
+ id: "bearer",
101
+ label: "Bearer auth token literal",
102
+ re: /\bBearer\s+([A-Za-z0-9_\-.]{20,})/,
103
+ validate: (tok) => /[a-z]/.test(tok) && /[A-Z]/.test(tok) && /[0-9]/.test(tok),
104
+ },
105
+ ];
106
+
107
+ // Files we never scan (binary-ish, generated, vendored, or the env files that
108
+ // are gitignored by design and legitimately hold secrets locally).
109
+ const SKIP_DIR = new Set([".git", "node_modules", ".next", ".vercel", ".data", "dist", "build", "coverage"]);
110
+ const SKIP_BASENAME = new Set([
111
+ "package-lock.json",
112
+ "pnpm-lock.yaml",
113
+ "yarn.lock",
114
+ ".env",
115
+ ".env.local",
116
+ ".env.development.local",
117
+ ".env.production.local",
118
+ ]);
119
+ const SKIP_EXT = new Set([
120
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg", ".pdf",
121
+ ".woff", ".woff2", ".ttf", ".eot", ".mp4", ".webm", ".zip", ".gz",
122
+ ".lock", ".map",
123
+ ]);
124
+
125
+ const MAX_FILE_BYTES = 2_000_000; // skip very large files (same cap as safety.ts corpus)
126
+
127
+ /** Heuristic: does this buffer look binary? (null byte in the first 4KB) */
128
+ function looksBinary(buf) {
129
+ const n = Math.min(buf.length, 4096);
130
+ for (let i = 0; i < n; i++) if (buf[i] === 0) return true;
131
+ return false;
132
+ }
133
+
134
+ /** Files to scan: tracked + staged + (added) untracked-but-not-ignored, deduped.
135
+ * We deliberately DON'T scan gitignored files (the local .env lives there and
136
+ * is supposed to hold secrets). */
137
+ function filesToScan(cwd) {
138
+ const set = new Set();
139
+ // Tracked files.
140
+ for (const f of gitSafe(["ls-files"], { cwd }).split("\n")) if (f) set.add(f);
141
+ // Untracked-but-not-ignored (new files a dev created; --others honours .gitignore).
142
+ for (const f of gitSafe(["ls-files", "--others", "--exclude-standard"], { cwd }).split("\n")) if (f) set.add(f);
143
+ return [...set];
144
+ }
145
+
146
+ export function checkSecrets(cwd) {
147
+ const files = filesToScan(cwd);
148
+ const hits = []; // { file, label, fp }
149
+
150
+ for (const rel of files) {
151
+ const base = path.basename(rel);
152
+ const ext = path.extname(rel).toLowerCase();
153
+ if (SKIP_BASENAME.has(base)) continue;
154
+ if (SKIP_EXT.has(ext)) continue;
155
+ // Skip anything inside a skipped directory.
156
+ if (rel.split(/[\\/]/).some((seg) => SKIP_DIR.has(seg))) continue;
157
+
158
+ const abs = path.join(cwd, rel);
159
+ let buf;
160
+ try {
161
+ const st = statSync(abs);
162
+ if (!st.isFile() || st.size > MAX_FILE_BYTES) continue;
163
+ buf = readFileSync(abs);
164
+ } catch {
165
+ continue; // unreadable / deleted-but-staged etc.
166
+ }
167
+ if (looksBinary(buf)) continue;
168
+ const text = buf.toString("utf8");
169
+
170
+ for (const p of SECRET_PATTERNS) {
171
+ const m = text.match(p.re);
172
+ if (!m) continue;
173
+ // If the pattern has a capture group + validator (e.g. Bearer), apply the
174
+ // validator to the captured token so test fixtures/placeholders don't trip.
175
+ const token = m[1] ?? m[0];
176
+ if (p.validate && !p.validate(token)) continue;
177
+ hits.push({ file: rel, label: p.label, fp: fingerprint(token) });
178
+ }
179
+ }
180
+
181
+ if (hits.length === 0) {
182
+ return result(
183
+ "pass",
184
+ "Secret scan",
185
+ `Scanned ${files.length} tracked/staged file(s) — no leaked-secret patterns matched.`,
186
+ );
187
+ }
188
+
189
+ // De-dupe identical (file,label) pairs for a tidy report.
190
+ const seen = new Set();
191
+ const lines = [];
192
+ for (const h of hits) {
193
+ const k = `${h.file}::${h.label}`;
194
+ if (seen.has(k)) continue;
195
+ seen.add(k);
196
+ lines.push(`${h.file} → ${h.label}: ${h.fp}`);
197
+ }
198
+ return result(
199
+ "fail",
200
+ "Secret scan",
201
+ `${lines.length} possible secret(s) in committed/staged files — remove + rotate before shipping.`,
202
+ lines.slice(0, 30),
203
+ );
204
+ }
205
+
206
+ // ===========================================================================
207
+ // c. BUILD + TYPECHECK
208
+ // ===========================================================================
209
+ // Default: `npx tsc --noEmit` (fast). `--build` also runs `npm run build`.
210
+ // BLOCK on either failing; print the tail of the output so the founder sees
211
+ // the actual error without scrollback.
212
+
213
+ function runCapture(cmd, args, cwd) {
214
+ try {
215
+ const out = execFileSync(cmd, args, {
216
+ cwd,
217
+ encoding: "utf8",
218
+ stdio: ["ignore", "pipe", "pipe"],
219
+ maxBuffer: 32 * 1024 * 1024,
220
+ // On Windows npx/tsc are .cmd shims — shell:true lets them resolve.
221
+ shell: process.platform === "win32",
222
+ });
223
+ return { ok: true, out };
224
+ } catch (e) {
225
+ const out = `${e.stdout || ""}${e.stderr || ""}`.trim();
226
+ return { ok: false, out: out || String(e.message || e) };
227
+ }
228
+ }
229
+
230
+ function tail(text, n = 25) {
231
+ const lines = text.split("\n").filter(Boolean);
232
+ return lines.slice(-n);
233
+ }
234
+
235
+ export function checkTypecheck(cwd) {
236
+ const r = runCapture("npx", ["--yes", "tsc", "--noEmit"], cwd);
237
+ if (r.ok) {
238
+ return result("pass", "Typecheck (tsc --noEmit)", "TypeScript compiled with no type errors.");
239
+ }
240
+ return result(
241
+ "fail",
242
+ "Typecheck (tsc --noEmit)",
243
+ "tsc reported type errors — fix them before shipping.",
244
+ tail(r.out, 25),
245
+ );
246
+ }
247
+
248
+ export function checkBuild(cwd) {
249
+ const r = runCapture("npm", ["run", "build"], cwd);
250
+ if (r.ok) {
251
+ return result("pass", "Production build (npm run build)", "Next build completed successfully.");
252
+ }
253
+ return result(
254
+ "fail",
255
+ "Production build (npm run build)",
256
+ "The production build failed — fix it before shipping.",
257
+ tail(r.out, 30),
258
+ );
259
+ }
260
+
261
+ // ===========================================================================
262
+ // d. SCHEMA-BUMP CHECK
263
+ // ===========================================================================
264
+ // db.ts uses a SCHEMA_VERSION sentinel: additive DDL is SKIPPED on an existing
265
+ // prod DB whose stored version is already >= SCHEMA_VERSION. So a DDL change
266
+ // that forgets to bump SCHEMA_VERSION builds + passes every proof on a fresh
267
+ // PGlite DB, then silently no-ops in prod. We diff db.ts (committed vs the
268
+ // merge-base with main, PLUS any uncommitted edits): if DDL-ish lines changed
269
+ // but the `const SCHEMA_VERSION = N;` line did NOT, WARN loudly.
270
+
271
+ const DB_PATH = "app/lib/server/db.ts";
272
+ const DDL_RE = /\b(CREATE\s+TABLE|ADD\s+COLUMN|ALTER\s+TABLE|CREATE\s+(?:UNIQUE\s+)?INDEX|DROP\s+(?:TABLE|COLUMN|INDEX))\b/i;
273
+ const VERSION_RE = /SCHEMA_VERSION\s*=\s*\d+/;
274
+
275
+ export function checkSchemaBump(cwd, baseRef = "main") {
276
+ // Combined diff = (merge-base…HEAD committed) + (working-tree uncommitted).
277
+ // Use `git diff base...HEAD` for the committed delta on this lane, then a
278
+ // plain `git diff HEAD` for anything not yet committed. We never EDIT db.ts —
279
+ // this only READS the diff.
280
+ const haveBase = !!gitSafe(["rev-parse", "--verify", "--quiet", baseRef], { cwd });
281
+ let committedDiff = "";
282
+ if (haveBase) {
283
+ // base...HEAD = changes on HEAD since it diverged from base (symmetric range).
284
+ committedDiff = gitSafe(["diff", `${baseRef}...HEAD`, "--", DB_PATH], { cwd });
285
+ }
286
+ const uncommittedDiff = gitSafe(["diff", "HEAD", "--", DB_PATH], { cwd });
287
+ const combined = `${committedDiff}\n${uncommittedDiff}`;
288
+
289
+ // Only consider ADDED/REMOVED lines (diff bodies, prefixed +/-), not context.
290
+ const changedLines = combined
291
+ .split("\n")
292
+ .filter((l) => /^[+-]/.test(l) && !/^[+-]{3}\s/.test(l)); // skip +++/--- headers
293
+
294
+ if (changedLines.length === 0) {
295
+ if (!haveBase) {
296
+ return result(
297
+ "pass",
298
+ "Schema-bump check",
299
+ `No changes to ${DB_PATH} in the working tree (base ref '${baseRef}' not found, so the committed lane delta was skipped).`,
300
+ );
301
+ }
302
+ return result("pass", "Schema-bump check", `No changes to ${DB_PATH} vs ${baseRef}.`);
303
+ }
304
+
305
+ const ddlChanged = changedLines.some((l) => DDL_RE.test(l));
306
+ const versionChanged = changedLines.some((l) => VERSION_RE.test(l));
307
+
308
+ if (ddlChanged && !versionChanged) {
309
+ return result(
310
+ "warn",
311
+ "Schema-bump check",
312
+ `${DB_PATH} has DDL-ish changes but SCHEMA_VERSION was NOT bumped.`,
313
+ [
314
+ "Additive DDL (CREATE TABLE / ADD COLUMN / ALTER / CREATE INDEX) is SKIPPED on an existing prod DB",
315
+ "whose stored version already matches — so it builds + passes proofs on fresh PGlite, then silently",
316
+ "no-ops in prod. Bump `const SCHEMA_VERSION` (and coordinate the migration). See the schema-version memory.",
317
+ ...changedLines.filter((l) => DDL_RE.test(l)).slice(0, 6).map((l) => l.trim()),
318
+ ],
319
+ );
320
+ }
321
+
322
+ if (ddlChanged && versionChanged) {
323
+ return result("pass", "Schema-bump check", `${DB_PATH} changed DDL and SCHEMA_VERSION was bumped alongside it.`);
324
+ }
325
+
326
+ return result("pass", "Schema-bump check", `${DB_PATH} changed but no DDL-shaped statements were touched.`);
327
+ }
package/deploy.mjs ADDED
@@ -0,0 +1,203 @@
1
+ // Ship-Safe — the safe-deploy ritual (`ship-safe deploy`).
2
+ //
3
+ // This productizes getAdvantage's hard-won deploy rules:
4
+ // 1. `vercel --prod` ships the WORKING TREE, so we deploy from a CLEAN
5
+ // DETACHED worktree of the exact target commit — never the live, possibly
6
+ // dirty, shared checkout (a concurrent session's uncommitted work has
7
+ // shipped to prod this way before).
8
+ // 2. We CONFIRM the resulting deployment URL starts with the expected project
9
+ // prefix (default "getadvantage-"); a different prefix (e.g. "plusxplus-")
10
+ // means we deployed the WRONG project — STOP, non-zero exit.
11
+ // 3. The Vercel token is read from an ENV VAR by NAME — never hardcoded,
12
+ // never echoed/logged.
13
+ //
14
+ // IMPORTANT: this module is implemented but is only invoked when the founder
15
+ // explicitly runs `ship-safe deploy`. It runs `vercel --prod` for real.
16
+
17
+ import { execFileSync, spawnSync } from "node:child_process";
18
+ import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import path from "node:path";
21
+ import { c, git, gitSafe, section } from "./util.mjs";
22
+ import { runChecks } from "./checks-runner.mjs";
23
+
24
+ /** Read the linked Vercel project (.vercel/project.json), or null. Used to derive
25
+ * a sensible wrong-project guard prefix when --expect-prefix isn't given. */
26
+ function readVercelProject(cwd) {
27
+ try {
28
+ return JSON.parse(readFileSync(path.join(cwd, ".vercel", "project.json"), "utf8"));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /**
35
+ * @param {object} o
36
+ * @param {string} o.cwd repo root
37
+ * @param {string} o.commit target commit-ish (default HEAD)
38
+ * @param {string} o.expectPrefix required deployment-URL prefix (default: derived from .vercel projectName; guard skipped if none)
39
+ * @param {string} o.scope Vercel team scope (passed through to vercel)
40
+ * @param {string} o.tokenEnv NAME of the env var holding the Vercel token
41
+ * @param {boolean} o.force deploy even if `check` returns NO-GO
42
+ * @param {boolean} o.runBuild whether `check` should run a full build too
43
+ */
44
+ export async function deploy(o) {
45
+ const cwd = o.cwd;
46
+ const commit = o.commit || "HEAD";
47
+ const tokenEnv = o.tokenEnv || "VERCEL_TOKEN";
48
+ // The wrong-project guard. Prefer an explicit --expect-prefix; otherwise derive
49
+ // it from the linked Vercel project so the guard works in ANY repo (not just
50
+ // ours). If we can't derive one, the guard is disabled and we say so — we never
51
+ // silently assume a project.
52
+ let expectPrefix = o.expectPrefix;
53
+ if (!expectPrefix) {
54
+ const proj = readVercelProject(cwd);
55
+ if (proj && typeof proj.projectName === "string" && proj.projectName) {
56
+ expectPrefix = `${proj.projectName}-`;
57
+ }
58
+ }
59
+
60
+ console.log(c.bold("\nShip-Safe — safe deploy"));
61
+ console.log(
62
+ c.dim(` target commit: ${commit} expect prefix: ${expectPrefix || "(none — wrong-project guard disabled)"}\n`),
63
+ );
64
+
65
+ // ---- 0. Resolve the token by NAME, never echo it. -----------------------
66
+ const token = process.env[tokenEnv];
67
+ if (!token) {
68
+ console.error(c.red(`✗ Env var ${tokenEnv} is not set — can't authenticate to Vercel.`));
69
+ console.error(c.gray(` Set it (e.g. from your User-scoped VERCEL_TOKEN) and retry; the value is never printed.`));
70
+ return 1;
71
+ }
72
+
73
+ // ---- 1. Gate on `check` first (unless --force). -------------------------
74
+ section("Pre-deploy checks");
75
+ const { exitCode } = await runChecks({ cwd, runBuild: o.runBuild });
76
+ if (exitCode !== 0) {
77
+ if (!o.force) {
78
+ console.error(c.red("\n✗ NO-GO — checks failed. Aborting deploy. (Re-run with --force to override at your own risk.)"));
79
+ return 1;
80
+ }
81
+ console.error(c.yellow("\n⚠ Checks failed but --force was passed — proceeding anyway."));
82
+ }
83
+
84
+ // ---- 2. Resolve the exact commit SHA we're shipping. --------------------
85
+ const sha = git(["rev-parse", commit], { cwd });
86
+
87
+ // ---- 3. Create a CLEAN DETACHED worktree of that commit. ----------------
88
+ // A detached worktree is a pristine checkout of the SHA — none of the live
89
+ // checkout's uncommitted work can ride along. We place it in the OS temp dir
90
+ // so we never disturb sibling repos / the shared folder.
91
+ const wtDir = mkdtempSync(path.join(tmpdir(), "ship-safe-deploy-"));
92
+ let deployed = false;
93
+ let deployUrl = "";
94
+ try {
95
+ section("Clean worktree");
96
+ console.log(c.gray(` Creating detached worktree at ${wtDir} @ ${sha.slice(0, 10)}`));
97
+ // --detach: no branch, just the commit. The worktree is a clean tree.
98
+ git(["worktree", "add", "--detach", wtDir, sha], { cwd });
99
+
100
+ // ---- 4. Copy `.vercel` (project link) into the worktree. --------------
101
+ // The worktree is a fresh checkout and won't contain the gitignored .vercel
102
+ // dir, so vercel wouldn't know which project to target. Copy it over.
103
+ const srcVercel = path.join(cwd, ".vercel");
104
+ if (existsSync(srcVercel)) {
105
+ cpSync(srcVercel, path.join(wtDir, ".vercel"), { recursive: true });
106
+ console.log(c.gray(" Copied .vercel project link into the worktree."));
107
+ } else {
108
+ console.error(c.red("✗ No .vercel/ found in the repo — can't confirm the target project. Run `vercel link` first."));
109
+ return 1;
110
+ }
111
+
112
+ // ---- 5. Run `vercel --prod` FROM the worktree. ------------------------
113
+ // We pass the token via the --token flag value (read from env above) and the
114
+ // scope through --scope. The token value is never logged by us; we spawn
115
+ // with stdio inherited so vercel's own output streams to the founder.
116
+ section("Deploying");
117
+ const vercelArgs = ["--yes", "vercel", "--prod", "--yes"];
118
+ if (o.scope) vercelArgs.push("--scope", o.scope);
119
+ vercelArgs.push("--token", token); // value from env; not printed by us
120
+
121
+ // Print a redacted version of the command (token masked) for transparency.
122
+ const shown = vercelArgs.map((a) => (a === token ? "<token:hidden>" : a));
123
+ console.log(c.gray(` npx ${shown.join(" ")} (cwd=${wtDir})`));
124
+
125
+ // Capture stdout so we can read the deployment URL, but also echo it live.
126
+ const proc = spawnSync("npx", vercelArgs, {
127
+ cwd: wtDir,
128
+ encoding: "utf8",
129
+ shell: process.platform === "win32",
130
+ maxBuffer: 32 * 1024 * 1024,
131
+ });
132
+ deployed = true;
133
+ const stdout = proc.stdout || "";
134
+ const stderr = proc.stderr || "";
135
+ // Echo vercel's output (it normally prints the URL on stdout).
136
+ if (stdout) process.stdout.write(stdout);
137
+ if (stderr) process.stderr.write(stderr);
138
+
139
+ if (proc.status !== 0) {
140
+ console.error(c.red(`\n✗ vercel exited with code ${proc.status}. Deploy not confirmed.`));
141
+ return proc.status || 1;
142
+ }
143
+
144
+ // ---- 6. Extract + CONFIRM the deployment URL prefix. ------------------
145
+ // Vercel prints a https://<project>-<hash>-<scope>.vercel.app URL. Grab the
146
+ // last vercel.app URL in the output and check its host's prefix.
147
+ const urls = `${stdout}\n${stderr}`.match(/https:\/\/[a-z0-9-]+\.vercel\.app/gi) || [];
148
+ deployUrl = urls.length ? urls[urls.length - 1] : "";
149
+ if (!deployUrl) {
150
+ console.error(c.red("\n✗ Could not find a deployment URL in vercel's output — STOP and verify manually."));
151
+ return 1;
152
+ }
153
+
154
+ const host = deployUrl.replace(/^https:\/\//, "");
155
+ section("Confirm target");
156
+ if (expectPrefix) {
157
+ if (!host.startsWith(expectPrefix)) {
158
+ // WRONG PROJECT. This is the load-bearing guard — e.g. a "plusxplus-" host
159
+ // means we shipped to the wrong product. Hard stop.
160
+ console.error(
161
+ c.red(
162
+ `\n✗ STOP — deployment host "${host}" does NOT start with the expected "${expectPrefix}".`,
163
+ ),
164
+ );
165
+ console.error(
166
+ c.red(
167
+ " You may have deployed the WRONG project. Verify in the Vercel dashboard and roll back if needed.",
168
+ ),
169
+ );
170
+ return 2;
171
+ }
172
+ console.log(c.green(`✓ Deployed to ${deployUrl} — host prefix "${expectPrefix}" confirmed.`));
173
+ } else {
174
+ console.log(
175
+ c.yellow(`⚠ Deployed to ${deployUrl} — no expected prefix set, so the wrong-project guard was skipped.`),
176
+ );
177
+ console.log(c.gray(" Pass --expect-prefix <project>- (or link a .vercel project) to enable the guard."));
178
+ }
179
+ return 0;
180
+ } catch (e) {
181
+ console.error(c.red(`\n✗ Deploy failed: ${e.message || e}`));
182
+ return 1;
183
+ } finally {
184
+ // ---- 7. Always clean up the temp worktree. ---------------------------
185
+ // Deregister it from git AND remove the dir, best-effort. We never touch
186
+ // the live checkout here.
187
+ try {
188
+ gitSafe(["worktree", "remove", "--force", wtDir], { cwd });
189
+ } catch {
190
+ /* ignore */
191
+ }
192
+ try {
193
+ if (existsSync(wtDir)) rmSync(wtDir, { recursive: true, force: true });
194
+ } catch {
195
+ /* ignore */
196
+ }
197
+ // Prune any dangling worktree admin entry.
198
+ gitSafe(["worktree", "prune"], { cwd });
199
+ if (deployed && deployUrl) {
200
+ console.log(c.dim(" (temp worktree cleaned up.)"));
201
+ }
202
+ }
203
+ }