@trygocode/notify 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,98 @@
1
+ // `repo_key` derivation — a stable per-repo identity both the dev machine and the
2
+ // phone app agree on (PRD §2.5). Per-project auto-push overrides + the app's
3
+ // branch picker key off this so "this repo" means the same thing everywhere.
4
+ //
5
+ // repo_key = sha256( normalize(git remote get-url origin) )[:16]
6
+ //
7
+ // normalize() lowercases, strips a trailing `.git`, and canonicalises the SCP
8
+ // form `git@github.com:owner/repo` and the URL form
9
+ // `https://github.com/owner/repo` to the SAME `github.com/owner/repo` string so
10
+ // the two clone styles of one repo derive an IDENTICAL key. If there is no
11
+ // `origin` remote we fall back to `local:<basename>` — clearly machine-local
12
+ // (not syncable across machines).
13
+ //
14
+ // Zero runtime deps beyond Node built-ins; the git runner is injectable so this
15
+ // is unit-testable with a fake `git` (no real subprocess).
16
+ import { createHash } from "node:crypto";
17
+ import path from "node:path";
18
+ import { makeGitRunner } from "./push.js";
19
+ /**
20
+ * Canonicalise a git remote URL to a scheme-/auth-/`.git`-free
21
+ * `host/owner/repo` string so that the SSH and HTTPS clone forms of the same
22
+ * repo normalise IDENTICALLY (PRD §2.5). Returns "" for empty input.
23
+ *
24
+ * Handled forms:
25
+ * - `https://github.com/owner/repo(.git)`
26
+ * - `https://user@github.com/owner/repo` (userinfo stripped)
27
+ * - `git@github.com:owner/repo(.git)` (SCP syntax)
28
+ * - `ssh://git@github.com:22/owner/repo(.git)` (scheme + port stripped)
29
+ * - `git://github.com/owner/repo(.git)`
30
+ */
31
+ export function normalizeRemoteUrl(raw) {
32
+ let s = raw.trim();
33
+ if (!s)
34
+ return "";
35
+ // Strip any `scheme://` prefix (https, http, ssh, git, …).
36
+ s = s.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
37
+ // Strip `user@` / `user:pass@` userinfo (the leading authority chunk).
38
+ s = s.replace(/^[^@/]+@/, "");
39
+ // If a ':' precedes any '/', it separates host from either an SCP path
40
+ // (`host:owner/repo`) or a port (`host:22/owner/repo`). Canonicalise both to
41
+ // `host/owner/repo`.
42
+ const colon = s.indexOf(":");
43
+ const slash = s.indexOf("/");
44
+ if (colon !== -1 && (slash === -1 || colon < slash)) {
45
+ const host = s.slice(0, colon);
46
+ let rest = s.slice(colon + 1);
47
+ const port = rest.match(/^(\d+)(\/.*)?$/); // `22/owner/repo` or bare `22`
48
+ if (port)
49
+ rest = port[2] ?? "";
50
+ rest = rest.replace(/^\/+/, "");
51
+ s = rest ? `${host}/${rest}` : host;
52
+ }
53
+ // Collapse duplicate slashes, drop trailing slash + `.git`, lowercase.
54
+ s = s.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
55
+ s = s.replace(/\.git$/i, "");
56
+ return s.toLowerCase();
57
+ }
58
+ /**
59
+ * Derive a friendly `owner/repo` label from a normalised remote (the last two
60
+ * path segments). Falls back to the final segment, or "" when there is none.
61
+ */
62
+ export function repoLabelFromNormalized(normalized) {
63
+ const parts = normalized.split("/").filter(Boolean);
64
+ if (parts.length === 0)
65
+ return "";
66
+ if (parts.length === 1)
67
+ return parts[0]; // pathless — shouldn't happen for a real remote
68
+ return parts.slice(-2).join("/");
69
+ }
70
+ /** The 16-hex-char repo key for a normalised remote URL (PRD §2.5). */
71
+ export function repoKeyFromNormalized(normalized) {
72
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
73
+ }
74
+ /**
75
+ * Resolve this repo's {@link RepoIdentity} via the injected git runner.
76
+ *
77
+ * - `git remote get-url origin` succeeds with a non-empty URL → key derived
78
+ * from the normalised URL; `synced: true`.
79
+ * - no origin (no remote, git missing, not a repo, empty URL) → fall back to
80
+ * `local:<basename of cwd>`; `synced: false`.
81
+ *
82
+ * Never throws: the runner reports failures as non-zero codes, which map to the
83
+ * local fallback.
84
+ */
85
+ export async function deriveRepoIdentity(cwd = process.cwd(), git = makeGitRunner(cwd)) {
86
+ const res = await git(["remote", "get-url", "origin"]);
87
+ const url = res.code === 0 ? res.stdout.trim() : "";
88
+ const normalized = normalizeRemoteUrl(url);
89
+ if (!normalized) {
90
+ const base = path.basename(path.resolve(cwd)) || "repo";
91
+ return { repo_key: `local:${base}`, repo_label: base, synced: false };
92
+ }
93
+ return {
94
+ repo_key: repoKeyFromNormalized(normalized),
95
+ repo_label: repoLabelFromNormalized(normalized) || normalized,
96
+ synced: true,
97
+ };
98
+ }
@@ -0,0 +1,71 @@
1
+ // Shared rule/skill content (PRD §5.5 — anti-double-ping) — the single source of
2
+ // truth for the on-demand-notification guidance shipped into BOTH clients:
3
+ //
4
+ // • Claude Code → `~/.claude/skills/gocode-notify/SKILL.md`
5
+ // • Cursor → `~/.cursor/rules/gocode-notify.md`
6
+ //
7
+ // The body is identical for both clients; only two things differ per client:
8
+ // 1. The frontmatter block (Claude SKILL.md `name:`+`description:` vs Cursor
9
+ // rule `description:`+`alwaysApply:`).
10
+ // 2. The parenthetical naming WHICH runtime hook owns the automatic pings
11
+ // (Claude fires `Stop`+`Notification`+`SubagentStop`; Cursor fires `stop`).
12
+ //
13
+ // Centralising the prose here means the anti-double-ping invariant ("the hooks
14
+ // own the automatic done/idle/error pings — only call the MCP tool when the user
15
+ // EXPLICITLY asks") cannot silently drift between the two clients. `claude.ts`
16
+ // and `cursor.ts` build their shipped constants from this module.
17
+ //
18
+ // Zero runtime deps — pure string assembly.
19
+ /**
20
+ * Build the §5.5 rule/skill Markdown for one client. The body is byte-identical
21
+ * across clients except for the frontmatter and the named hook mechanism, so the
22
+ * anti-double-ping guidance stays in lockstep.
23
+ */
24
+ export function buildRuleContent({ frontmatter, hookDescription }) {
25
+ return `${frontmatter}
26
+
27
+ # gocode-notify — on-demand phone notifications
28
+
29
+ You have a \`gocode_notify\` MCP tool that sends a push notification to the user's
30
+ phone via the GoCode app.
31
+
32
+ ## CRITICAL: do NOT double-notify
33
+
34
+ Automatic "session done", "agent idle / needs input", and "error" notifications
35
+ are ALREADY handled by a runtime hook (${hookDescription}). You MUST NOT call
36
+ \`gocode_notify\` for those — the user would get two pings. The hook is
37
+ deterministic and always fires.
38
+
39
+ ## When to call gocode_notify
40
+
41
+ ONLY when the user EXPLICITLY asks to be pinged about a specific thing mid-task,
42
+ e.g. "let me know when the build passes", "ping my phone when the migration is
43
+ done". Then call it once, at that moment, with a clear \`title\`/\`body\`.
44
+
45
+ ## If it fails
46
+
47
+ If \`gocode_notify_status\` reports no credentials, tell the user to run
48
+ \`npx @trygocode/notify login\` and pair from the GoCode app's "Connect a coding
49
+ agent" screen. Do not retry more than twice.
50
+ `;
51
+ }
52
+ /**
53
+ * Claude Code SKILL.md frontmatter (PRD §5.5). The `description` is trigger-y so
54
+ * the skill surfaces on "text me / ping me / notify me when …".
55
+ */
56
+ export const CLAUDE_FRONTMATTER = `---
57
+ name: gocode-notify
58
+ description: On-demand phone notifications via the GoCode app. Use ONLY when the user EXPLICITLY asks to be pinged/notified when something finishes (e.g. "text me when the build is done").
59
+ ---`;
60
+ /** How Claude Code's automatic-ping hooks are named in the double-notify warning. */
61
+ export const CLAUDE_HOOK_DESCRIPTION = "Claude Code `Stop` + `Notification` +\n`SubagentStop`";
62
+ /**
63
+ * Cursor rule frontmatter (PRD §5.5). `alwaysApply: false` + a trigger-y
64
+ * description so Cursor surfaces it on "notify me / ping me / let me know when".
65
+ */
66
+ export const CURSOR_FRONTMATTER = `---
67
+ description: On-demand phone notifications via the GoCode app. Use ONLY when the user EXPLICITLY asks to be pinged/notified when something finishes (e.g. "notify me when the build is done", "ping me", "let me know when").
68
+ alwaysApply: false
69
+ ---`;
70
+ /** How Cursor's automatic-ping hook is named in the double-notify warning. */
71
+ export const CURSOR_HOOK_DESCRIPTION = "Cursor `stop`";
@@ -0,0 +1,141 @@
1
+ // Internal send() — the single code path every trigger (hook / loop / MCP / CLI)
2
+ // funnels through to POST `/api/v1/notify/send` (PRD §3.2, §4.4).
3
+ //
4
+ // Contract (PRD §4.4 "Never block the agent"):
5
+ // - Hard timeout (default 5s) via AbortController.
6
+ // - NEVER throws and NEVER rejects — always resolves to a {@link SendResult}.
7
+ // A failed notification must never block a hook's turn or fail a loop.
8
+ // - Failures are appended to `~/.gocode/notify.log` (size-capped + rotated).
9
+ //
10
+ // This module owns ONLY the request build + timeout + logging. The CLI `send`
11
+ // command (kind-enum validation, flag parsing) and the offline outbox are
12
+ // separate tasks; this stays self-contained and dependency-free.
13
+ import { promises as fs } from "node:fs";
14
+ import path from "node:path";
15
+ import { DEFAULT_SERVER, gocodeDir, readCredentials, } from "./creds.js";
16
+ /** Canonical notification kinds accepted by `/notify/send` (PRD §3.2). */
17
+ export const NOTIFY_KINDS = [
18
+ "finished",
19
+ "error",
20
+ "awaiting_input",
21
+ "loop_completed",
22
+ "loop_halted",
23
+ ];
24
+ /** True when `value` is one of the canonical {@link NOTIFY_KINDS}. */
25
+ export function isNotifyKind(value) {
26
+ return typeof value === "string" && NOTIFY_KINDS.includes(value);
27
+ }
28
+ /** Default request timeout (PRD §4.4: 5s hard cap). */
29
+ export const DEFAULT_TIMEOUT_MS = 5000;
30
+ /** Rotate `notify.log` once it grows past this (keeps one `.1` backup). */
31
+ export const MAX_LOG_BYTES = 256 * 1024;
32
+ /** Absolute path to the failure log. */
33
+ export function notifyLogPath(opts) {
34
+ return path.join(gocodeDir(opts), "notify.log");
35
+ }
36
+ function errMessage(err) {
37
+ if (err instanceof Error)
38
+ return err.message;
39
+ return String(err);
40
+ }
41
+ /** Strip trailing slashes so `${server}/path` is always clean. */
42
+ function normalizeServer(url) {
43
+ return url.trim().replace(/\/+$/, "");
44
+ }
45
+ /** Build the JSON body, dropping any undefined/empty optional fields. */
46
+ function buildBody(payload) {
47
+ const body = { kind: payload.kind };
48
+ for (const field of ["title", "body", "source", "project", "dedupe_key"]) {
49
+ const v = payload[field];
50
+ if (typeof v === "string" && v !== "")
51
+ body[field] = v;
52
+ }
53
+ return body;
54
+ }
55
+ /**
56
+ * Append one line to `~/.gocode/notify.log`. Best-effort: any error here is
57
+ * swallowed — logging must never be the thing that blocks a notification.
58
+ * Rotates to `notify.log.1` once the file exceeds {@link MAX_LOG_BYTES}.
59
+ */
60
+ export async function appendLog(line, opts) {
61
+ try {
62
+ await fs.mkdir(gocodeDir(opts), { recursive: true, mode: 0o700 });
63
+ const file = notifyLogPath(opts);
64
+ try {
65
+ const st = await fs.stat(file);
66
+ if (st.size > MAX_LOG_BYTES) {
67
+ // Overwrite any prior backup; keep exactly one rotation.
68
+ await fs.rename(file, `${file}.1`).catch(() => { });
69
+ }
70
+ }
71
+ catch {
72
+ // No existing log → nothing to rotate.
73
+ }
74
+ const ts = opts?.timestamp ? opts.timestamp() : new Date().toISOString();
75
+ await fs.appendFile(file, `${ts} ${line}\n`);
76
+ }
77
+ catch {
78
+ // Intentionally silent — see contract above.
79
+ }
80
+ }
81
+ async function failure(reason, opts, extra = {}) {
82
+ await appendLog(`SEND FAIL: ${reason}`, opts);
83
+ return { ok: false, error: reason, ...extra };
84
+ }
85
+ /**
86
+ * Send one push to `/notify/send`. Resolves (never rejects) to a
87
+ * {@link SendResult}; callers may treat ANY result as "exit 0".
88
+ */
89
+ export async function send(payload, opts = {}) {
90
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
91
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
92
+ let creds;
93
+ try {
94
+ creds = await readCredentials(opts);
95
+ }
96
+ catch (err) {
97
+ return failure(`credentials unreadable: ${errMessage(err)}`, opts);
98
+ }
99
+ if (!creds) {
100
+ return failure("not paired — run `gocode-notify login` first", opts);
101
+ }
102
+ const server = normalizeServer(opts.server ?? creds.server ?? DEFAULT_SERVER);
103
+ const url = `${server}/api/v1/notify/send`;
104
+ const controller = new AbortController();
105
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
106
+ try {
107
+ const res = await fetchImpl(url, {
108
+ method: "POST",
109
+ headers: {
110
+ "content-type": "application/json",
111
+ authorization: `Bearer ${creds.api_key}`,
112
+ },
113
+ body: JSON.stringify(buildBody(payload)),
114
+ signal: controller.signal,
115
+ });
116
+ if (!res.ok) {
117
+ return failure(`server responded ${res.status} for kind=${payload.kind}`, opts, {
118
+ status: res.status,
119
+ });
120
+ }
121
+ let sent;
122
+ try {
123
+ const json = (await res.json());
124
+ if (typeof json?.sent === "number")
125
+ sent = json.sent;
126
+ }
127
+ catch {
128
+ // A 2xx with an unparseable body still counts as delivered.
129
+ }
130
+ return { ok: true, status: res.status, sent };
131
+ }
132
+ catch (err) {
133
+ const reason = controller.signal.aborted
134
+ ? `timeout after ${timeoutMs}ms for kind=${payload.kind}`
135
+ : `request failed: ${errMessage(err)}`;
136
+ return failure(reason, opts);
137
+ }
138
+ finally {
139
+ clearTimeout(timer);
140
+ }
141
+ }
@@ -0,0 +1,213 @@
1
+ // settings.ts — the canonical GoCode Notify settings schema (PRD §3.4), mirrored
2
+ // in the TypeScript CLI. ONE source of truth for:
3
+ // • the default settings blob (auto-push OFF, all kinds on — the conservative
4
+ // posture from §0.5),
5
+ // • the set of VALID dotted setting keys + their value types/enums,
6
+ // • coercion of a CLI string value into the typed value the schema expects,
7
+ // • the deep-merge that layers a per-repo override over the global blob (§3.4).
8
+ //
9
+ // `config.ts` (T-C5) uses this to validate `config set <key> <value>` before it
10
+ // PUTs to the server, and to merge cached global ← per-repo settings on read.
11
+ //
12
+ // Zero runtime deps — only the type system + plain objects; no Node built-ins
13
+ // needed here. Keep this in lockstep with the server validator (§3.3) and the
14
+ // Flutter `notify_settings.dart` model (§3.4, T-A1).
15
+ /** Schema version stamped into a fresh settings blob (PRD §3.4). */
16
+ export const NOTIFY_SETTINGS_VERSION = 1;
17
+ /**
18
+ * The default global settings blob (PRD §3.4). Conservative posture (§0.5):
19
+ * auto-push OFF, branch guard ON, every notification kind ON. Used as the
20
+ * fail-safe when no server/cache value is available.
21
+ */
22
+ export const DEFAULT_NOTIFY_SETTINGS = {
23
+ version: NOTIFY_SETTINGS_VERSION,
24
+ kinds: {
25
+ finished: true,
26
+ error: true,
27
+ awaiting_input: true,
28
+ loop_completed: true,
29
+ loop_halted: true,
30
+ },
31
+ min_duration_seconds: 0,
32
+ quiet_hours: {
33
+ enabled: false,
34
+ start: "23:00",
35
+ end: "07:00",
36
+ tz: "America/Chicago",
37
+ },
38
+ auto_push: {
39
+ enabled: false,
40
+ default_branch: null,
41
+ allow_protected: false,
42
+ remote: "origin",
43
+ skip_git_hooks: false,
44
+ },
45
+ commit_message: {
46
+ mode: "auto",
47
+ command: null,
48
+ max_diff_bytes: 61440,
49
+ },
50
+ };
51
+ /**
52
+ * Every dotted setting key the CLI accepts in `config set <key> <value>`, with
53
+ * the value type the schema requires. This is the allow-list: any key NOT here is
54
+ * rejected with a helpful error (PRD §2.6 "reject unknown keys").
55
+ */
56
+ export const KEY_SPECS = {
57
+ "kinds.finished": { kind: "boolean" },
58
+ "kinds.error": { kind: "boolean" },
59
+ "kinds.awaiting_input": { kind: "boolean" },
60
+ "kinds.loop_completed": { kind: "boolean" },
61
+ "kinds.loop_halted": { kind: "boolean" },
62
+ min_duration_seconds: { kind: "int_nonneg" },
63
+ "quiet_hours.enabled": { kind: "boolean" },
64
+ "quiet_hours.start": { kind: "time" },
65
+ "quiet_hours.end": { kind: "time" },
66
+ "quiet_hours.tz": { kind: "string" },
67
+ "auto_push.enabled": { kind: "boolean" },
68
+ "auto_push.default_branch": { kind: "string_or_null" },
69
+ // Per-repo override branch — only meaningful with --repo (§2.4 step 1).
70
+ "auto_push.branch": { kind: "string_or_null", repoOnly: true },
71
+ "auto_push.allow_protected": { kind: "boolean" },
72
+ "auto_push.remote": { kind: "string" },
73
+ "auto_push.skip_git_hooks": { kind: "boolean" },
74
+ "commit_message.mode": { kind: "enum", enumValues: ["auto", "ai", "deterministic"] },
75
+ "commit_message.command": { kind: "string_or_null" },
76
+ "commit_message.max_diff_bytes": { kind: "int_pos" },
77
+ };
78
+ /** Sorted list of every valid `config set` key — for help text + error messages. */
79
+ export function validKeys() {
80
+ return Object.keys(KEY_SPECS).sort();
81
+ }
82
+ const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
83
+ // Convenience shorthand: `config set quiet_hours "23:00-07:00"` (PRD §2.6 example).
84
+ const QUIET_RANGE_RE = /^([01]\d|2[0-3]:[0-5]\d|[0-2]?\d:[0-5]\d)-([0-2]?\d:[0-5]\d)$/;
85
+ function ok(partial) {
86
+ return { ok: true, partial };
87
+ }
88
+ function fail(error) {
89
+ return { ok: false, error };
90
+ }
91
+ /** Build the nested partial blob `{ a: { b: value } }` from a dotted key path. */
92
+ function nest(key, value) {
93
+ const parts = key.split(".");
94
+ const root = {};
95
+ let cursor = root;
96
+ for (let i = 0; i < parts.length - 1; i++) {
97
+ const next = {};
98
+ cursor[parts[i]] = next;
99
+ cursor = next;
100
+ }
101
+ cursor[parts[parts.length - 1]] = value;
102
+ return root;
103
+ }
104
+ /** Coerce a raw CLI string into the typed value the key expects, or an error. */
105
+ function coerce(spec, key, raw) {
106
+ switch (spec.kind) {
107
+ case "boolean": {
108
+ if (raw === "true")
109
+ return { value: true };
110
+ if (raw === "false")
111
+ return { value: false };
112
+ return { error: `"${key}" expects a boolean (true|false), got "${raw}"` };
113
+ }
114
+ case "int_nonneg":
115
+ case "int_pos": {
116
+ if (!/^-?\d+$/.test(raw))
117
+ return { error: `"${key}" expects an integer, got "${raw}"` };
118
+ const n = Number.parseInt(raw, 10);
119
+ if (spec.kind === "int_nonneg" && n < 0)
120
+ return { error: `"${key}" must be ≥ 0, got ${n}` };
121
+ if (spec.kind === "int_pos" && n <= 0)
122
+ return { error: `"${key}" must be > 0, got ${n}` };
123
+ return { value: n };
124
+ }
125
+ case "time": {
126
+ if (!TIME_RE.test(raw))
127
+ return { error: `"${key}" expects a time HH:MM (24h), got "${raw}"` };
128
+ return { value: raw };
129
+ }
130
+ case "string": {
131
+ if (raw.trim() === "")
132
+ return { error: `"${key}" must be a non-empty string` };
133
+ return { value: raw };
134
+ }
135
+ case "string_or_null": {
136
+ if (raw === "null")
137
+ return { value: null };
138
+ if (raw.trim() === "")
139
+ return { error: `"${key}" must be a non-empty string or "null"` };
140
+ return { value: raw };
141
+ }
142
+ case "enum": {
143
+ const allowed = spec.enumValues ?? [];
144
+ if (!allowed.includes(raw)) {
145
+ return { error: `"${key}" must be one of: ${allowed.join(", ")} (got "${raw}")` };
146
+ }
147
+ return { value: raw };
148
+ }
149
+ }
150
+ }
151
+ /**
152
+ * Validate + coerce a single `config set <key> <value>` into the partial blob to
153
+ * PUT to the server. `repo` indicates the write targets a per-repo override
154
+ * (`--repo`); per-repo-only keys are rejected on the global path and vice-versa.
155
+ *
156
+ * Special case: the `quiet_hours` shorthand `HH:MM-HH:MM` (PRD §2.6 example)
157
+ * expands to `{ quiet_hours: { enabled: true, start, end } }`.
158
+ */
159
+ export function setSetting(key, raw, opts = {}) {
160
+ // Convenience: `quiet_hours "23:00-07:00"` → enable + start/end (PRD §2.6).
161
+ if (key === "quiet_hours") {
162
+ const m = raw.match(QUIET_RANGE_RE);
163
+ if (!m)
164
+ return fail(`"quiet_hours" shorthand expects "HH:MM-HH:MM" (e.g. 23:00-07:00), got "${raw}"`);
165
+ const start = m[1].length === 2 ? `${m[1]}:00` : m[1];
166
+ const end = m[2];
167
+ if (!TIME_RE.test(start) || !TIME_RE.test(end)) {
168
+ return fail(`"quiet_hours" times must be HH:MM (24h), got "${raw}"`);
169
+ }
170
+ return ok({ quiet_hours: { enabled: true, start, end } });
171
+ }
172
+ const spec = KEY_SPECS[key];
173
+ if (!spec) {
174
+ return fail(`unknown setting key "${key}". Valid keys:\n ${validKeys().join("\n ")}`);
175
+ }
176
+ if (spec.repoOnly && !opts.repo) {
177
+ return fail(`"${key}" is a per-repo override — pass --repo to set it for the current repo`);
178
+ }
179
+ const c = coerce(spec, key, raw);
180
+ if ("error" in c)
181
+ return fail(c.error);
182
+ return ok(nest(key, c.value));
183
+ }
184
+ function isPlainObject(v) {
185
+ return typeof v === "object" && v !== null && !Array.isArray(v);
186
+ }
187
+ /**
188
+ * Deep-merge `override` over `base` (PRD §3.4): nested plain objects merge
189
+ * recursively; scalars, arrays, and explicit `null` in the override replace the
190
+ * base value. Neither input is mutated.
191
+ */
192
+ export function deepMerge(base, override) {
193
+ const out = { ...base };
194
+ for (const [k, ov] of Object.entries(override)) {
195
+ const bv = out[k];
196
+ if (isPlainObject(ov) && isPlainObject(bv)) {
197
+ out[k] = deepMerge(bv, ov);
198
+ }
199
+ else {
200
+ out[k] = ov;
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+ /**
206
+ * Merge a per-repo override blob over the global blob (PRD §3.4 "Merge = deep-merge
207
+ * of the override over the global blob"). Returns a new object; inputs untouched.
208
+ */
209
+ export function mergeSettings(global, override) {
210
+ if (!override)
211
+ return { ...global };
212
+ return deepMerge(global, override);
213
+ }
@@ -0,0 +1,148 @@
1
+ // gocode-notify `setup` — the one-line installer orchestration (PRD §5).
2
+ //
3
+ // Drives the documented install sequence end-to-end:
4
+ // 1. Pair — exchange a 6-digit code for an API key (skip if already
5
+ // paired; `--force` re-pairs). Reuses the `login` flow.
6
+ // 2. Detect — probe well-known config dirs for installed agent runtimes
7
+ // (Claude Code / Cursor / OpenCode). Reuses `detectRuntimes`.
8
+ // 3. Write configs — for each DETECTED runtime, write its hooks/rule/MCP entry.
9
+ // 4. Summary — report exactly what was paired / detected / written.
10
+ //
11
+ // This module owns the ORCHESTRATION and its idempotency contract. The actual
12
+ // per-runtime config writers (Claude Code / Cursor) are dedicated follow-up
13
+ // tracker tasks; `setup` consumes them through the {@link RuntimeConfigWriter}
14
+ // seam. Until those land, the default writer reports each detected runtime as
15
+ // "pending" (nothing written, no failure) so the orchestration is complete,
16
+ // honest, and testable now, and the writers drop in without touching this file.
17
+ //
18
+ // Every step is reported as a {@link StepLine}, so the CLI layer can render the
19
+ // same result either as a human summary or as one JSON line per step in
20
+ // `--agent-driven` mode (PRD §2 Path 2, §4.4) without re-deriving anything.
21
+ //
22
+ // Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
23
+ import { login } from "./login.js";
24
+ import { readCredentials } from "./creds.js";
25
+ import { detectRuntimes } from "./status.js";
26
+ import { writeClaudeConfig, CLAUDE_RUNTIME_NAME } from "./claude.js";
27
+ import { writeCursorConfig, CURSOR_RUNTIME_NAME } from "./cursor.js";
28
+ /**
29
+ * Default config writer: reports the detected runtime as pending. Replaced by
30
+ * the Claude Code / Cursor config-writer tasks (which register real writers via
31
+ * {@link SetupOptions.writeConfig}). Writing nothing is fully idempotent.
32
+ */
33
+ export const pendingConfigWriter = async (runtime) => ({
34
+ runtime: runtime.name,
35
+ written: [],
36
+ skipped: true,
37
+ detail: "config writer not yet implemented",
38
+ });
39
+ /**
40
+ * The default config writer used in production: routes each detected runtime to
41
+ * its dedicated writer. Claude Code is wired to {@link writeClaudeConfig} and
42
+ * Cursor to {@link writeCursorConfig}; runtimes whose writer has not landed yet
43
+ * (OpenCode) fall through to {@link pendingConfigWriter} (a no-op, fully
44
+ * idempotent).
45
+ */
46
+ export const defaultConfigWriter = async (runtime, opts) => {
47
+ if (runtime.name === CLAUDE_RUNTIME_NAME)
48
+ return writeClaudeConfig(runtime, opts);
49
+ if (runtime.name === CURSOR_RUNTIME_NAME)
50
+ return writeCursorConfig(runtime, opts);
51
+ return pendingConfigWriter(runtime, opts);
52
+ };
53
+ /**
54
+ * Run the installer orchestration (PRD §5). Resolves (never rejects) to a
55
+ * {@link SetupResult}. Idempotent: a second run with credentials already present
56
+ * skips pairing (unless `--force`), and the config writers are themselves
57
+ * idempotent, so re-running converges without clobbering.
58
+ */
59
+ export async function setup(opts = {}) {
60
+ const pathOpts = { home: opts.home };
61
+ const steps = [];
62
+ // ── Step 1: pair (skip when already paired and not forced) ────────────────
63
+ const pair = await runPairStep(opts, pathOpts);
64
+ steps.push({
65
+ step: pair.skipped ? "pair (skipped)" : "pair",
66
+ ok: pair.ok,
67
+ detail: pair.detail,
68
+ });
69
+ // ── Step 2: detect installed runtimes ─────────────────────────────────────
70
+ const detect = opts.detectImpl ?? detectRuntimes;
71
+ const detected = await detect(pathOpts);
72
+ const detectedNames = detected.filter((r) => r.detected).map((r) => r.name);
73
+ steps.push({
74
+ step: "detect",
75
+ ok: true,
76
+ detail: detectedNames.length > 0
77
+ ? `detected: ${detectedNames.join(", ")}`
78
+ : "no agent runtimes detected",
79
+ });
80
+ // ── Step 3: write configs for each DETECTED runtime ───────────────────────
81
+ // Undetected runtimes are skipped silently (PRD §5.2) — only what's installed
82
+ // gets configured.
83
+ const writeConfig = opts.writeConfig ?? defaultConfigWriter;
84
+ const configs = [];
85
+ for (const runtime of detected.filter((r) => r.detected)) {
86
+ const result = await writeConfig(runtime, pathOpts);
87
+ configs.push(result);
88
+ steps.push({
89
+ step: `config:${result.runtime}`,
90
+ ok: !result.failed,
91
+ detail: result.detail,
92
+ });
93
+ }
94
+ // ── Step 4: summary ───────────────────────────────────────────────────────
95
+ const writtenCount = configs.filter((c) => c.written.length > 0).length;
96
+ const hardFail = configs.some((c) => c.failed);
97
+ const ok = pair.ok && !hardFail;
98
+ steps.push({
99
+ step: "summary",
100
+ ok,
101
+ detail: `${pair.skipped ? "already paired" : pair.ok ? "paired" : "not paired"}; ` +
102
+ `${detectedNames.length} runtime(s) detected; ${writtenCount} config(s) written`,
103
+ });
104
+ return { ok, pair, detected, configs, steps };
105
+ }
106
+ /**
107
+ * The pairing sub-step. Reads existing credentials first: if valid creds are
108
+ * present and `--force` was not passed, pairing is SKIPPED (idempotent re-run).
109
+ * Otherwise it runs the `login` flow (code from `--pair-code` or the injected
110
+ * prompt). A malformed creds file is treated as "not paired" so `--force` is
111
+ * not required to recover from corruption.
112
+ */
113
+ async function runPairStep(opts, pathOpts) {
114
+ let existing = null;
115
+ try {
116
+ existing = await readCredentials(pathOpts);
117
+ }
118
+ catch {
119
+ existing = null; // malformed → re-pair to recover
120
+ }
121
+ if (existing && !opts.force) {
122
+ return {
123
+ ok: true,
124
+ skipped: true,
125
+ credentials: existing,
126
+ detail: `already paired as ${existing.user_id} (${existing.label}) — use --force to re-pair`,
127
+ };
128
+ }
129
+ const pair = opts.pairImpl ?? login;
130
+ const result = await pair({
131
+ code: opts.pairCode,
132
+ label: opts.label,
133
+ server: opts.server,
134
+ home: opts.home,
135
+ fetchImpl: opts.fetchImpl,
136
+ timeoutMs: opts.timeoutMs,
137
+ promptCode: opts.promptCode,
138
+ });
139
+ if (result.ok && result.credentials) {
140
+ return {
141
+ ok: true,
142
+ skipped: false,
143
+ credentials: result.credentials,
144
+ detail: `paired as ${result.credentials.user_id} (${result.credentials.label})`,
145
+ };
146
+ }
147
+ return { ok: false, skipped: false, detail: result.error ?? "pairing failed" };
148
+ }