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.
package/handoff.mjs ADDED
@@ -0,0 +1,272 @@
1
+ // Ship-Safe — SESSION HANDOFF (`ship-safe handoff`).
2
+ //
3
+ // The HOT half of the Project Brain. Where `ship-safe brief` captures the COLD,
4
+ // slow-moving truth of the project (architecture, conventions), `handoff`
5
+ // captures the HOT, volatile "where we left off RIGHT NOW" — so you can drop a
6
+ // long, slow session and start a FRESH, fast one that picks up exactly where you
7
+ // were, WITHOUT re-explaining anything.
8
+ //
9
+ // One command:
10
+ // 1. refreshes PROJECT-BRIEF.md (the COLD brain) so both halves stay in sync,
11
+ // 2. writes / updates HANDOFF.md (the HOT layer): a small auto "what changed
12
+ // since last time" (from git) PLUS a short narrative you (or your agent)
13
+ // fill in — what you were doing, the next steps, open threads, gotchas,
14
+ // 3. prints the one-line "start here" prompt for the next session.
15
+ //
16
+ // Your narrative is PRESERVED across refreshes (only the auto section + the
17
+ // frontmatter timestamp are regenerated). And we NEVER clobber a HANDOFF.md we
18
+ // didn't create: if one exists without our marker, we refuse and suggest --out.
19
+ //
20
+ // Node built-ins only. ESM. Writes two repo-resident files (the handoff + its
21
+ // .ship-safe/handoff.json marker); refreshing the brief writes its files too.
22
+
23
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
24
+ import path from "node:path";
25
+ import { c, gitSafe, relPath } from "./util.mjs";
26
+ import { runBrief, briefStaleness } from "./brief.mjs";
27
+
28
+ const DEFAULT_HANDOFF = "HANDOFF.md";
29
+ const MARKER_DIR = ".ship-safe";
30
+ const MARKER_FILE = "handoff.json";
31
+ // Recognisable first-lines marker so we can tell OUR handoff from a hand-written
32
+ // HANDOFF.md (and so a tool can recognise the file).
33
+ const BANNER = "<!-- ship-safe:project-handoff -->";
34
+ const NOTES_START = "<!-- ship-safe:handoff:notes -->";
35
+ const NOTES_END = "<!-- /ship-safe:handoff:notes -->";
36
+ const AUTO_START = "<!-- ship-safe:handoff:auto -->";
37
+ const AUTO_END = "<!-- /ship-safe:handoff:auto -->";
38
+
39
+ // The narrative template written on the first handoff (and whenever the notes
40
+ // section is empty). This is the part a human/agent fills in — the value.
41
+ const DEFAULT_NOTES = [
42
+ "## Where we are",
43
+ "_(What were you working on? One or two sentences — replace this line.)_",
44
+ "",
45
+ "## Next steps",
46
+ "1. _(The very next thing to do — replace this line.)_",
47
+ "",
48
+ "## Open threads / decisions pending",
49
+ "- _(Anything unresolved or waiting on a decision — replace, or delete if none.)_",
50
+ "",
51
+ "## Gotchas found this session",
52
+ "- _(Anything surprising worth remembering — replace, or delete if none.)_",
53
+ ].join("\n");
54
+
55
+ function readJson(abs) {
56
+ try {
57
+ return JSON.parse(readFileSync(abs, "utf8"));
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ function readTextSafe(abs) {
64
+ try {
65
+ return readFileSync(abs, "utf8");
66
+ } catch {
67
+ return "";
68
+ }
69
+ }
70
+
71
+ /** Content strictly between two markers (exclusive), trimmed of edge blank
72
+ * lines. Returns null if either marker is absent. */
73
+ function between(text, start, end) {
74
+ const i = text.indexOf(start);
75
+ if (i === -1) return null;
76
+ const j = text.indexOf(end, i + start.length);
77
+ if (j === -1) return null;
78
+ return text.slice(i + start.length, j).replace(/^\n+/, "").replace(/\n+$/, "");
79
+ }
80
+
81
+ function markerPath(cwd) {
82
+ return path.join(cwd, MARKER_DIR, MARKER_FILE);
83
+ }
84
+
85
+ /**
86
+ * The auto "what changed since the last handoff" block — git-derived, regenerated
87
+ * each run. Uses `rev-list --count` to distinguish "no new commits" from "the old
88
+ * head is gone" (e.g. after a rebase), so the message is always honest.
89
+ */
90
+ function autoBlock(cwd, lastHead) {
91
+ const L = [];
92
+ const branch = gitSafe(["rev-parse", "--abbrev-ref", "HEAD"], { cwd }) || "(unknown)";
93
+ const head = gitSafe(["rev-parse", "HEAD"], { cwd });
94
+ L.push(`- **Branch:** \`${branch}\`${head ? ` @ \`${head.slice(0, 10)}\`` : ""}`);
95
+ L.push("");
96
+
97
+ let commitLines = [];
98
+ let label = "recent commits";
99
+ let rangeOk = false;
100
+ if (lastHead) {
101
+ const countStr = gitSafe(["rev-list", "--count", `${lastHead}..HEAD`], { cwd });
102
+ if (countStr !== "") {
103
+ rangeOk = true;
104
+ const n = parseInt(countStr, 10) || 0;
105
+ if (n > 0) {
106
+ commitLines = gitSafe(["log", `${lastHead}..HEAD`, "--format=%h %s"], { cwd })
107
+ .split("\n")
108
+ .filter(Boolean);
109
+ label = "Commits since the last handoff";
110
+ } else {
111
+ label = "No new commits since the last handoff";
112
+ }
113
+ }
114
+ }
115
+ if (!rangeOk) {
116
+ commitLines = gitSafe(["log", "-8", "--format=%h %s"], { cwd }).split("\n").filter(Boolean);
117
+ label = lastHead
118
+ ? "Recent commits (couldn't diff from the last handoff — history moved)"
119
+ : "Recent commits";
120
+ }
121
+
122
+ L.push(`**${label}:**`);
123
+ if (label.startsWith("No new commits")) {
124
+ // nothing to list
125
+ } else if (commitLines.length === 0) {
126
+ L.push("- _(none)_");
127
+ } else {
128
+ for (const ln of commitLines.slice(0, 20)) L.push(`- ${ln.replace(/\|/g, "\\|")}`);
129
+ if (commitLines.length > 20) L.push(`- …and ${commitLines.length - 20} more`);
130
+ }
131
+
132
+ // Files changed since the last handoff (only when the range is valid).
133
+ if (lastHead && rangeOk) {
134
+ const changed = gitSafe(["diff", "--name-only", lastHead, "HEAD"], { cwd })
135
+ .split("\n")
136
+ .filter(Boolean);
137
+ if (changed.length) {
138
+ L.push("");
139
+ L.push(`**Files changed since the last handoff (${changed.length}):**`);
140
+ for (const f of changed.slice(0, 30)) L.push(`- \`${f}\``);
141
+ if (changed.length > 30) L.push(`- …and ${changed.length - 30} more`);
142
+ }
143
+ }
144
+
145
+ // Working-tree state right now.
146
+ const porcelain = gitSafe(["status", "--porcelain"], { cwd })
147
+ .split("\n")
148
+ .filter((l) => l.length > 0);
149
+ let tracked = 0;
150
+ let untracked = 0;
151
+ for (const l of porcelain) {
152
+ if (l.startsWith("??")) untracked++;
153
+ else tracked++;
154
+ }
155
+ L.push("");
156
+ L.push(
157
+ `**Working tree right now:** ${tracked} uncommitted change(s), ${untracked} untracked file(s)` +
158
+ `${tracked === 0 && untracked === 0 ? " — clean." : "."}`,
159
+ );
160
+
161
+ return { text: L.join("\n"), head, branch };
162
+ }
163
+
164
+ /**
165
+ * `ship-safe handoff` — refresh the brief and write the HOT handoff layer.
166
+ * @param {object} o
167
+ * @param {string} o.cwd repo root
168
+ * @param {string} [o.out] handoff path (default HANDOFF.md)
169
+ * @param {boolean} [o.noBrief] skip the brief refresh (just warn if stale)
170
+ * @returns {number} exit code (0 on success; 1 if it would clobber a foreign file)
171
+ */
172
+ export function runHandoff(o) {
173
+ const cwd = o.cwd;
174
+ const out = o.out || DEFAULT_HANDOFF;
175
+ const handoffAbs = path.resolve(cwd, out);
176
+
177
+ // ---- Guard: never overwrite a HANDOFF.md we didn't create. ---------------
178
+ const prev = existsSync(handoffAbs) ? readTextSafe(handoffAbs) : "";
179
+ if (prev && !prev.includes(BANNER)) {
180
+ console.error(
181
+ c.red(`✗ ${relPath(handoffAbs, cwd)} already exists and wasn't created by ship-safe — refusing to overwrite it.`),
182
+ );
183
+ console.error(c.gray(` Write to a different file instead: ship-safe handoff --out SESSION-HANDOFF.md`));
184
+ return 1;
185
+ }
186
+
187
+ // ---- 1. Refresh the COLD brain (unless --no-brief) so both halves sync. ---
188
+ if (!o.noBrief) {
189
+ runBrief({ cwd }); // writes PROJECT-BRIEF.md + marker; prints its own line
190
+ } else {
191
+ const s = briefStaleness(cwd);
192
+ if (s.status !== "ok") {
193
+ console.log(` ${c.yellow("⚠")} ${c.bold("Project brief")} — ${s.reason} (run \`ship-safe brief\`)`);
194
+ }
195
+ }
196
+
197
+ // ---- 2. Preserve the existing narrative; regenerate the rest. ------------
198
+ const prevNotes = prev ? between(prev, NOTES_START, NOTES_END) : null;
199
+ const firstTime = !prevNotes;
200
+ const notes = prevNotes && prevNotes.trim() ? prevNotes : DEFAULT_NOTES;
201
+
202
+ const marker = readJson(markerPath(cwd));
203
+ const lastHead = marker && typeof marker.head_sha === "string" ? marker.head_sha : null;
204
+
205
+ const auto = autoBlock(cwd, lastHead);
206
+ const now = new Date().toISOString();
207
+ const repoName = path.basename(cwd);
208
+
209
+ const L = [];
210
+ L.push("---");
211
+ L.push("ship_safe_handoff: 1");
212
+ L.push(`generated_at: ${now}`);
213
+ L.push(`head_sha: ${auto.head || "(none)"}`);
214
+ L.push(`branch: ${auto.branch}`);
215
+ L.push("generator: ship-safe handoff");
216
+ L.push("---");
217
+ L.push("");
218
+ L.push(BANNER);
219
+ L.push("");
220
+ L.push(`# Handoff — ${repoName}`);
221
+ L.push("");
222
+ L.push("> **Start here, with `PROJECT-BRIEF.md`.** The brief is the COLD layer —");
223
+ L.push("> what this project *is* (architecture, conventions). This handoff is the");
224
+ L.push("> HOT layer — where work *left off right now*. Read both, then continue.");
225
+ L.push("> This is what lets you drop a long, slow session and start a fresh, fast");
226
+ L.push("> one with no loss. Refresh both anytime with `ship-safe handoff`.");
227
+ L.push("");
228
+ L.push(NOTES_START);
229
+ L.push(notes);
230
+ L.push(NOTES_END);
231
+ L.push("");
232
+ L.push(AUTO_START);
233
+ L.push("## What changed since the last handoff");
234
+ L.push("*(Auto-generated from git — regenerated each run.)*");
235
+ L.push("");
236
+ L.push(auto.text);
237
+ L.push(AUTO_END);
238
+ L.push("");
239
+ L.push("---");
240
+ L.push(
241
+ `_Generated by \`ship-safe handoff\` at ${now}. Your notes above are preserved across refreshes; the “what changed” section is regenerated each run. Commit this file so the next session — any model, any tool — starts here._`,
242
+ );
243
+ L.push("");
244
+
245
+ writeFileSync(handoffAbs, L.join("\n"), "utf8");
246
+
247
+ // ---- 3. Marker, so the NEXT handoff can diff "since last". ---------------
248
+ const dir = path.join(cwd, MARKER_DIR);
249
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
250
+ const m = {
251
+ schema: 1,
252
+ out: relPath(handoffAbs, cwd),
253
+ head_sha: auto.head || null,
254
+ branch: auto.branch,
255
+ generated_at: now,
256
+ };
257
+ writeFileSync(markerPath(cwd), JSON.stringify(m, null, 2) + "\n", "utf8");
258
+
259
+ // ---- 4. Tell the human what to do next. ----------------------------------
260
+ console.log(c.green(`✓ Handoff written → ${relPath(handoffAbs, cwd)}`));
261
+ if (firstTime) {
262
+ console.log(
263
+ c.yellow(" ▸ First handoff — fill in the short narrative (Where we are / Next steps) so the next session knows the plan."),
264
+ );
265
+ }
266
+ console.log(c.gray(" Next session, paste this to pick up instantly:"));
267
+ console.log(c.cyan(` Read PROJECT-BRIEF.md and ${relPath(handoffAbs, cwd)}, then continue where we left off.`));
268
+ console.log(c.gray(" Commit both files — your brain lives in the repo, not your tool."));
269
+ return 0;
270
+ }
271
+
272
+ export { DEFAULT_HANDOFF };
package/index.mjs ADDED
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ // Ship-Safe — "is this safe to ship?"
3
+ //
4
+ // A local, dependency-free pre-deploy auditor for AI-built apps. Run it in your
5
+ // repo BEFORE you deploy and it gives a plain-language GO / NO-GO:
6
+ //
7
+ // • dirty-tree guard — `vercel --prod` ships the working tree, so a dirty
8
+ // tree (or another session's work) would ship live
9
+ // • secret scan — leaked keys in committed/staged files
10
+ // • build + typecheck — `tsc --noEmit` (and `--build` for a full build)
11
+ // • schema-bump check — DDL changed without a SCHEMA_VERSION bump
12
+ //
13
+ // Plus v1.1 read-only OVERVIEW MAPS (default-on; `--no-overview` to skip) —
14
+ // "understand what you built":
15
+ // • API surface map — every route, its methods, and whether it's auth-gated
16
+ // (⚠ mutating routes with no auth check)
17
+ // • integrations map — external/LLM/3rd-party calls + the env keys behind them
18
+ // (⚠ a secret reachable from the client bundle)
19
+ // • schedules & jobs map — vercel.json crons + cron routes + their gating
20
+ // (⚠ an ungated, publicly-triggerable cron)
21
+ //
22
+ // Node built-ins only. ESM. Nothing here mutates your repo (`check`); only the
23
+ // explicit `deploy` subcommand performs an action, and it deploys from a clean
24
+ // detached worktree of the target commit.
25
+ //
26
+ // Usage:
27
+ // node cli/ship-safe/index.mjs [check] [--build]
28
+ // node cli/ship-safe/index.mjs deploy [--expect-prefix getadvantage-] [--scope <s>]
29
+ // [--commit <ref>] [--token-env VERCEL_TOKEN]
30
+ // [--build] [--force]
31
+
32
+ import { c } from "./util.mjs";
33
+ import { repoRoot } from "./util.mjs";
34
+ import { runChecks } from "./checks-runner.mjs";
35
+ import { deploy } from "./deploy.mjs";
36
+ import { runBrief } from "./brief.mjs";
37
+ import { runHandoff } from "./handoff.mjs";
38
+
39
+ function parseArgs(argv) {
40
+ // First non-flag token is the subcommand; default to "check".
41
+ const flags = {};
42
+ const positional = [];
43
+ for (let i = 0; i < argv.length; i++) {
44
+ const a = argv[i];
45
+ if (a.startsWith("--")) {
46
+ const key = a.slice(2);
47
+ // value-taking flags vs boolean flags
48
+ const valueFlags = new Set(["expect-prefix", "scope", "commit", "token-env", "base-ref", "out"]);
49
+ if (valueFlags.has(key)) {
50
+ flags[key] = argv[++i];
51
+ } else {
52
+ flags[key] = true;
53
+ }
54
+ } else {
55
+ positional.push(a);
56
+ }
57
+ }
58
+ return { cmd: positional[0] || "check", flags };
59
+ }
60
+
61
+ function header() {
62
+ console.log(c.bold("┌──────────────────────────────────────────┐"));
63
+ console.log(c.bold("│ Ship-Safe — is this safe to ship? │"));
64
+ console.log(c.bold("└──────────────────────────────────────────┘"));
65
+ }
66
+
67
+ function printHelp() {
68
+ header();
69
+ console.log(`
70
+ ${c.bold("Commands")}
71
+ ${c.cyan("check")} Run all read-only pre-deploy checks (default). Exits 0 on GO, 1 on NO-GO.
72
+ ${c.cyan("brief")} Generate / refresh the PROJECT BRAIN — a portable, repo-resident project
73
+ brief (default ${c.bold("PROJECT-BRIEF.md")}) that ANY model/session/tool reads on start,
74
+ so you can switch tools without re-explaining the project. ${c.bold("--check")} only warns
75
+ if the brief is missing or stale (never blocks).
76
+ ${c.cyan("handoff")} Refresh the brief AND write ${c.bold("HANDOFF.md")} — the HOT "where we left off"
77
+ layer (what you were doing · next steps · open threads), so you can drop a long,
78
+ slow session and start a fresh, fast one with no loss. Your notes are preserved
79
+ across refreshes.
80
+ ${c.cyan("deploy")} Run check, then deploy from a clean detached worktree and confirm the
81
+ deployment URL prefix. Performs a real ${c.bold("vercel --prod")}.
82
+
83
+ ${c.bold("Flags")}
84
+ --build Also run a full ${c.bold("npm run build")} (default: tsc --noEmit only).
85
+ --base-ref <ref> Merge-base ref for the schema-bump diff (default: main).
86
+ --no-overview Skip the read-only overview maps (API surface, integrations, schedules).
87
+ --no-brief-check Skip the (non-blocking) brief-staleness warning in ${c.cyan("check")}.
88
+
89
+ ${c.dim("brief only:")}
90
+ --out <path> Where to write the brief (default: PROJECT-BRIEF.md at repo root).
91
+ --check Report staleness only (no write); warns if missing/stale.
92
+
93
+ ${c.dim("deploy only:")}
94
+ --expect-prefix <p> Required deployment-host prefix (default: derived from your linked .vercel project; guard skipped if none).
95
+ --scope <scope> Vercel team scope, passed through to vercel.
96
+ --commit <ref> Commit-ish to deploy (default: HEAD).
97
+ --token-env <NAME> Env var NAME holding the Vercel token (default: VERCEL_TOKEN).
98
+ --force Deploy even if checks return NO-GO (use with care).
99
+
100
+ ${c.bold("Examples")}
101
+ ship-safe run the pre-deploy checks (GO / NO-GO)
102
+ ship-safe --build checks + a full build
103
+ ship-safe brief generate / refresh the project brain
104
+ ship-safe handoff save your place for the next session
105
+ ship-safe deploy --expect-prefix myproject-
106
+ `);
107
+ }
108
+
109
+ async function main() {
110
+ const { cmd, flags } = parseArgs(process.argv.slice(2));
111
+
112
+ if (cmd === "help" || flags.help) {
113
+ printHelp();
114
+ process.exit(0);
115
+ }
116
+
117
+ let cwd;
118
+ try {
119
+ cwd = repoRoot();
120
+ } catch {
121
+ console.error(c.red("✗ Not inside a git repository. Ship-Safe must run in your project's repo."));
122
+ process.exit(1);
123
+ }
124
+
125
+ if (cmd === "check") {
126
+ header();
127
+ const { exitCode } = await runChecks({
128
+ cwd,
129
+ runBuild: !!flags.build,
130
+ baseRef: flags["base-ref"],
131
+ // Overviews are default-on; `--no-overview` turns them off.
132
+ overview: !flags["no-overview"],
133
+ // Brief-staleness warning is default-on; `--no-brief-check` turns it off.
134
+ briefCheck: !flags["no-brief-check"],
135
+ });
136
+ process.exit(exitCode);
137
+ }
138
+
139
+ if (cmd === "brief") {
140
+ header();
141
+ const code = runBrief({
142
+ cwd,
143
+ out: flags.out,
144
+ check: !!flags.check,
145
+ });
146
+ process.exit(code);
147
+ }
148
+
149
+ if (cmd === "handoff") {
150
+ header();
151
+ const code = runHandoff({
152
+ cwd,
153
+ out: flags.out,
154
+ noBrief: !!flags["no-brief"],
155
+ });
156
+ process.exit(code);
157
+ }
158
+
159
+ if (cmd === "deploy") {
160
+ header();
161
+ const code = await deploy({
162
+ cwd,
163
+ commit: flags.commit,
164
+ expectPrefix: flags["expect-prefix"],
165
+ scope: flags.scope,
166
+ tokenEnv: flags["token-env"],
167
+ force: !!flags.force,
168
+ runBuild: !!flags.build,
169
+ });
170
+ process.exit(code);
171
+ }
172
+
173
+ console.error(c.red(`✗ Unknown command: ${cmd}`));
174
+ printHelp();
175
+ process.exit(1);
176
+ }
177
+
178
+ main().catch((e) => {
179
+ console.error(c.red(`✗ Ship-Safe crashed: ${e?.stack || e}`));
180
+ process.exit(1);
181
+ });