@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,158 @@
1
+ // Credentials + config storage for gocode-notify (PRD §4.2).
2
+ //
3
+ // Two files live under `~/.gocode/`:
4
+ // - credentials JSON { api_key, server, user_id, label } — chmod 600 (secret)
5
+ // - config.json non-secret prefs { server?, source? }
6
+ //
7
+ // Server-URL precedence (PRD §4.2, the acceptance criterion for this task):
8
+ // --server flag > GOCODE_SERVER env > credentials file > built-in default
9
+ //
10
+ // Zero runtime deps — only Node built-ins, to match the package's zero-dep rule.
11
+ import { promises as fs } from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ /** Built-in default server, used when nothing else resolves a URL. */
15
+ export const DEFAULT_SERVER = "https://oh.jeltechsolutions.com";
16
+ /**
17
+ * Resolve the home directory. Prefers an explicit override, then `$HOME`
18
+ * (so tests can point at a temp dir via the env), then `os.homedir()`.
19
+ */
20
+ export function resolveHome(opts) {
21
+ return opts?.home ?? process.env.HOME ?? os.homedir();
22
+ }
23
+ /** Absolute path to the `~/.gocode/` directory. */
24
+ export function gocodeDir(opts) {
25
+ return path.join(resolveHome(opts), ".gocode");
26
+ }
27
+ /** Absolute path to the secret credentials file. */
28
+ export function credentialsPath(opts) {
29
+ return path.join(gocodeDir(opts), "credentials");
30
+ }
31
+ /** Absolute path to the non-secret config file. */
32
+ export function configPath(opts) {
33
+ return path.join(gocodeDir(opts), "config.json");
34
+ }
35
+ /** Trim a URL and strip any trailing slashes (so `${server}/path` is clean). */
36
+ function normalizeServer(url) {
37
+ return url.trim().replace(/\/+$/, "");
38
+ }
39
+ /** First value that is a non-empty string after trimming; else undefined. */
40
+ function firstNonEmpty(...values) {
41
+ for (const v of values) {
42
+ if (typeof v === "string" && v.trim() !== "")
43
+ return v;
44
+ }
45
+ return undefined;
46
+ }
47
+ async function ensureGocodeDir(opts) {
48
+ // 0o700: the dir holds a secret; keep it owner-only.
49
+ await fs.mkdir(gocodeDir(opts), { recursive: true, mode: 0o700 });
50
+ }
51
+ function isRecord(value) {
52
+ return typeof value === "object" && value !== null && !Array.isArray(value);
53
+ }
54
+ async function readJsonFile(file) {
55
+ let raw;
56
+ try {
57
+ raw = await fs.readFile(file, "utf8");
58
+ }
59
+ catch (err) {
60
+ if (err.code === "ENOENT")
61
+ return undefined;
62
+ throw err;
63
+ }
64
+ try {
65
+ return JSON.parse(raw);
66
+ }
67
+ catch {
68
+ throw new Error(`gocode-notify: ${file} contains invalid JSON`);
69
+ }
70
+ }
71
+ /**
72
+ * Read `~/.gocode/credentials`. Returns null when the file does not exist.
73
+ * Throws if it exists but is not a JSON object with the required string fields.
74
+ */
75
+ export async function readCredentials(opts) {
76
+ const parsed = await readJsonFile(credentialsPath(opts));
77
+ if (parsed === undefined)
78
+ return null;
79
+ if (!isRecord(parsed)) {
80
+ throw new Error(`gocode-notify: ${credentialsPath(opts)} is not a JSON object`);
81
+ }
82
+ for (const field of ["api_key", "server", "user_id", "label"]) {
83
+ if (typeof parsed[field] !== "string") {
84
+ throw new Error(`gocode-notify: credentials missing string field "${field}"`);
85
+ }
86
+ }
87
+ return {
88
+ api_key: parsed.api_key,
89
+ server: parsed.server,
90
+ user_id: parsed.user_id,
91
+ label: parsed.label,
92
+ };
93
+ }
94
+ /**
95
+ * Write `~/.gocode/credentials` with mode 0o600 (owner read/write only).
96
+ * Creates `~/.gocode/` (0o700) if needed.
97
+ */
98
+ export async function writeCredentials(creds, opts) {
99
+ await ensureGocodeDir(opts);
100
+ const file = credentialsPath(opts);
101
+ const body = JSON.stringify(creds, null, 2) + "\n";
102
+ // mode on writeFile is masked by umask, so chmod explicitly afterwards to
103
+ // guarantee 0o600 regardless of the caller's umask.
104
+ await fs.writeFile(file, body, { mode: 0o600 });
105
+ await fs.chmod(file, 0o600);
106
+ }
107
+ /**
108
+ * Read `~/.gocode/config.json`. Returns null when absent. Throws if it exists
109
+ * but is not a JSON object.
110
+ */
111
+ export async function readConfig(opts) {
112
+ const parsed = await readJsonFile(configPath(opts));
113
+ if (parsed === undefined)
114
+ return null;
115
+ if (!isRecord(parsed)) {
116
+ throw new Error(`gocode-notify: ${configPath(opts)} is not a JSON object`);
117
+ }
118
+ return parsed;
119
+ }
120
+ /** Write `~/.gocode/config.json` (non-secret; default 0o644 perms). */
121
+ export async function writeConfig(config, opts) {
122
+ await ensureGocodeDir(opts);
123
+ const body = JSON.stringify(config, null, 2) + "\n";
124
+ await fs.writeFile(configPath(opts), body);
125
+ }
126
+ /**
127
+ * Resolve the effective server URL from the precedence chain
128
+ * `flag > env > creds.server > default`. Empty/whitespace values are skipped.
129
+ * The result is trimmed and has trailing slashes stripped.
130
+ */
131
+ export function resolveServer(sources) {
132
+ const chosen = firstNonEmpty(sources.flag, sources.env, sources.creds?.server, sources.default ?? DEFAULT_SERVER);
133
+ // The default is always non-empty, so `chosen` is defined here.
134
+ return normalizeServer(chosen ?? DEFAULT_SERVER);
135
+ }
136
+ /**
137
+ * Convenience wrapper: resolve the server URL using the `--server` flag, the
138
+ * live `GOCODE_SERVER` env var, and the on-disk credentials file.
139
+ */
140
+ export async function resolveServerUrl(flag, opts) {
141
+ // Credentials are the LOWEST non-default precedence tier, so a corrupt or
142
+ // unreadable credentials file must never block a higher-precedence --server
143
+ // flag or GOCODE_SERVER env. Swallow read errors here and treat them as
144
+ // "no creds" — callers that actually need a valid api_key (e.g. `send`)
145
+ // call readCredentials() directly and get the strict error there.
146
+ let creds = null;
147
+ try {
148
+ creds = await readCredentials(opts);
149
+ }
150
+ catch {
151
+ creds = null;
152
+ }
153
+ return resolveServer({
154
+ flag,
155
+ env: process.env.GOCODE_SERVER ?? null,
156
+ creds,
157
+ });
158
+ }
@@ -0,0 +1,273 @@
1
+ // Cursor config writer (PRD §5.4, §5.5) — the installer's per-runtime writer for
2
+ // Cursor. It does three things, all idempotently and without clobbering the
3
+ // user's existing config:
4
+ //
5
+ // 1. MERGE a fire-and-forget `stop` hook into `~/.cursor/hooks.json`:
6
+ // stop → "finished" (the agent completed a turn).
7
+ // The command shells out to `gocode-notify send … || true` so a failed push
8
+ // NEVER blocks the agent's turn (PRD §4.4, §5.4). The file's `version` is
9
+ // preserved (or set to 1 when creating it fresh).
10
+ // 2. MERGE an `mcpServers` entry into `~/.cursor/mcp.json` pointing at
11
+ // `npx -y @trygocode/notify mcp`.
12
+ // 3. WRITE the on-demand rule to `~/.cursor/rules/gocode-notify.md` (the
13
+ // anti-double-ping rule, PRD §5.5, `alwaysApply: false`).
14
+ //
15
+ // MERGE, never clobber: the user's own hooks / MCP servers / top-level keys are
16
+ // preserved. Re-running converges (idempotent) — our stop entry and MCP entry
17
+ // are replaced in place, not duplicated. `uninstallCursorConfig` removes EXACTLY
18
+ // our entries and nothing else (PRD §11, the uninstall test).
19
+ //
20
+ // Our entries are identified by a stable marker (the command contains both
21
+ // `gocode-notify` and `--source cursor`) so idempotency and uninstall work even
22
+ // across version bumps to the exact command string.
23
+ //
24
+ // Zero runtime deps — Node built-ins only, matching the package's zero-dep rule.
25
+ import { promises as fs } from "node:fs";
26
+ import path from "node:path";
27
+ import { resolveHome } from "./creds.js";
28
+ import { buildRuleContent, CURSOR_FRONTMATTER, CURSOR_HOOK_DESCRIPTION, } from "./rule-content.js";
29
+ /** Display name of the runtime this writer handles (matches the detector). */
30
+ export const CURSOR_RUNTIME_NAME = "Cursor";
31
+ /** MCP server key written into `~/.cursor/mcp.json` `mcpServers`. */
32
+ export const MCP_SERVER_NAME = "gocode-notify";
33
+ /** The MCP server entry we register (PRD §4.3, §5.4 invocation form). */
34
+ export const MCP_SERVER_ENTRY = {
35
+ command: "npx",
36
+ args: ["-y", "@trygocode/notify", "mcp"],
37
+ };
38
+ /**
39
+ * The Cursor `stop` hook command (PRD §5.4, verbatim shape). Ends in `|| true`
40
+ * so a notification failure can never block the agent's turn, and carries a
41
+ * `--dedupe-key` so overlapping triggers (e.g. Cursor `stop` + Claude `Stop`)
42
+ * coalesce server-side.
43
+ */
44
+ export const CURSOR_STOP_COMMAND = "gocode-notify send --kind finished --source cursor --dedupe-key cursor-stop || true";
45
+ /**
46
+ * Substrings that together identify a `stop` hook entry as OURS. Used for
47
+ * idempotent merge (replace, don't duplicate) and for surgical uninstall (remove
48
+ * exactly ours). A command must contain BOTH to be considered ours.
49
+ */
50
+ const HOOK_MARKERS = ["gocode-notify", "--source cursor"];
51
+ /**
52
+ * The on-demand rule written to `~/.cursor/rules/gocode-notify.md` (PRD §5.5).
53
+ * The crucial content is the anti-double-ping rule: the automatic pings are
54
+ * owned by the Cursor `stop` hook, so the agent must only call the MCP tool when
55
+ * the user EXPLICITLY asks. `alwaysApply: false` + a trigger-y description so
56
+ * Cursor surfaces it on "notify me / ping me / let me know when". Built from the
57
+ * shared {@link buildRuleContent} so the body stays in lockstep with the Claude
58
+ * Code skill.
59
+ */
60
+ export const RULE_CONTENT = buildRuleContent({
61
+ frontmatter: CURSOR_FRONTMATTER,
62
+ hookDescription: CURSOR_HOOK_DESCRIPTION,
63
+ });
64
+ function isRecord(value) {
65
+ return typeof value === "object" && value !== null && !Array.isArray(value);
66
+ }
67
+ function errMessage(err) {
68
+ return err instanceof Error ? err.message : String(err);
69
+ }
70
+ /** `~/.cursor/` directory for the given (optional) HOME override. */
71
+ function cursorDir(opts) {
72
+ return path.join(resolveHome(opts), ".cursor");
73
+ }
74
+ /** Absolute path to Cursor's `hooks.json`. */
75
+ export function cursorHooksPath(opts) {
76
+ return path.join(cursorDir(opts), "hooks.json");
77
+ }
78
+ /** Absolute path to Cursor's `mcp.json`. */
79
+ export function cursorMcpPath(opts) {
80
+ return path.join(cursorDir(opts), "mcp.json");
81
+ }
82
+ /** Absolute path to the directory holding Cursor rules. */
83
+ export function cursorRulesDir(opts) {
84
+ return path.join(cursorDir(opts), "rules");
85
+ }
86
+ /** Absolute path to our rule file. */
87
+ export function cursorRulePath(opts) {
88
+ return path.join(cursorRulesDir(opts), "gocode-notify.md");
89
+ }
90
+ /**
91
+ * Read a JSON object from `file`. Returns null when the file does not exist.
92
+ * Throws when it exists but is not a JSON object — so we never silently clobber
93
+ * a file we failed to parse (the caller surfaces it as a write failure).
94
+ */
95
+ async function readJsonObject(file) {
96
+ let raw;
97
+ try {
98
+ raw = await fs.readFile(file, "utf8");
99
+ }
100
+ catch (err) {
101
+ if (err.code === "ENOENT")
102
+ return null;
103
+ throw err;
104
+ }
105
+ let parsed;
106
+ try {
107
+ parsed = JSON.parse(raw);
108
+ }
109
+ catch {
110
+ throw new Error(`gocode-notify: ${file} contains invalid JSON`);
111
+ }
112
+ if (!isRecord(parsed)) {
113
+ throw new Error(`gocode-notify: ${file} is not a JSON object`);
114
+ }
115
+ return parsed;
116
+ }
117
+ /** Write a JSON object with 2-space indent + trailing newline (matches creds). */
118
+ async function writeJsonFile(file, value) {
119
+ await fs.mkdir(path.dirname(file), { recursive: true });
120
+ await fs.writeFile(file, JSON.stringify(value, null, 2) + "\n");
121
+ }
122
+ /** True when a single `stop` hook entry (`{ command }`) is one we wrote. */
123
+ function isOurStopHook(h) {
124
+ return (isRecord(h) &&
125
+ typeof h.command === "string" &&
126
+ HOOK_MARKERS.every((m) => h.command.includes(m)));
127
+ }
128
+ /**
129
+ * Strip OUR entries out of the `stop` array. Returns the cleaned array plus
130
+ * whether anything of ours was removed (so callers can detect a real change).
131
+ * Never mutates the input.
132
+ */
133
+ function stripOurStopHooks(entries) {
134
+ const kept = entries.filter((h) => !isOurStopHook(h));
135
+ return { entries: kept, removed: kept.length !== entries.length };
136
+ }
137
+ /**
138
+ * Merge our `stop` hook into the hooks config, preserving the user's own stop
139
+ * entries and `version`. Strips any prior copy of OUR command (idempotent /
140
+ * version-safe) then appends a single fresh entry. Mutates `config` in place.
141
+ * A fresh file gets `version: 1` (PRD §5.4); an existing `version` is preserved.
142
+ */
143
+ function mergeStopHook(config) {
144
+ if (typeof config.version !== "number")
145
+ config.version = 1;
146
+ const hooks = isRecord(config.hooks) ? config.hooks : {};
147
+ const existing = Array.isArray(hooks.stop) ? hooks.stop : [];
148
+ const preserved = stripOurStopHooks(existing).entries;
149
+ preserved.push({ command: CURSOR_STOP_COMMAND });
150
+ hooks.stop = preserved;
151
+ config.hooks = hooks;
152
+ }
153
+ /** Merge our MCP server entry into `mcp.mcpServers`. Mutates in place. */
154
+ function mergeMcp(mcp) {
155
+ const servers = isRecord(mcp.mcpServers) ? mcp.mcpServers : {};
156
+ servers[MCP_SERVER_NAME] = { ...MCP_SERVER_ENTRY, args: [...MCP_SERVER_ENTRY.args] };
157
+ mcp.mcpServers = servers;
158
+ }
159
+ /**
160
+ * Install Cursor config (PRD §5.4): merge the `stop` hook into `hooks.json`,
161
+ * merge the MCP entry into `mcp.json`, and write the rule. A
162
+ * {@link RuntimeConfigWriter} — never throws; returns a {@link ConfigWriteResult}.
163
+ * Idempotent: re-running converges without duplicating our entries.
164
+ */
165
+ export async function writeCursorConfig(runtime, opts) {
166
+ const name = runtime?.name ?? CURSOR_RUNTIME_NAME;
167
+ // Track paths as they land so a mid-way failure reports what WAS actually
168
+ // written rather than claiming nothing changed.
169
+ const written = [];
170
+ try {
171
+ await fs.mkdir(cursorDir(opts), { recursive: true });
172
+ const hooksPath = cursorHooksPath(opts);
173
+ const hooksConfig = (await readJsonObject(hooksPath)) ?? {};
174
+ mergeStopHook(hooksConfig);
175
+ await writeJsonFile(hooksPath, hooksConfig);
176
+ written.push(hooksPath);
177
+ const mcpPath = cursorMcpPath(opts);
178
+ const mcpConfig = (await readJsonObject(mcpPath)) ?? {};
179
+ mergeMcp(mcpConfig);
180
+ await writeJsonFile(mcpPath, mcpConfig);
181
+ written.push(mcpPath);
182
+ const rulePath = cursorRulePath(opts);
183
+ await fs.mkdir(cursorRulesDir(opts), { recursive: true });
184
+ await fs.writeFile(rulePath, RULE_CONTENT);
185
+ written.push(rulePath);
186
+ return {
187
+ runtime: name,
188
+ written,
189
+ skipped: false,
190
+ detail: "merged stop hook + MCP entry; wrote rule",
191
+ };
192
+ }
193
+ catch (err) {
194
+ return {
195
+ runtime: name,
196
+ written,
197
+ skipped: false,
198
+ failed: true,
199
+ detail: `Cursor config write failed: ${errMessage(err)}`,
200
+ };
201
+ }
202
+ }
203
+ /**
204
+ * Remove EXACTLY the entries this writer added (PRD §11): our `stop` hook entry,
205
+ * our MCP server entry, and our rule file. The user's own hooks, MCP servers,
206
+ * `version`, and other keys are preserved untouched. Idempotent — a second run
207
+ * (or a run when nothing was installed) is a clean no-op. Never throws.
208
+ */
209
+ export async function uninstallCursorConfig(opts) {
210
+ const removed = [];
211
+ try {
212
+ // hooks.json — strip our stop entry (command-level, preserving the user's).
213
+ const hooksPath = cursorHooksPath(opts);
214
+ const hooksConfig = await readJsonObject(hooksPath);
215
+ if (hooksConfig && isRecord(hooksConfig.hooks)) {
216
+ const hooks = hooksConfig.hooks;
217
+ let changed = false;
218
+ if (Array.isArray(hooks.stop)) {
219
+ const { entries: kept, removed: r } = stripOurStopHooks(hooks.stop);
220
+ if (r) {
221
+ changed = true;
222
+ if (kept.length > 0)
223
+ hooks.stop = kept;
224
+ else
225
+ delete hooks.stop;
226
+ }
227
+ }
228
+ if (Object.keys(hooks).length === 0)
229
+ delete hooksConfig.hooks;
230
+ if (changed) {
231
+ await writeJsonFile(hooksPath, hooksConfig);
232
+ removed.push(hooksPath);
233
+ }
234
+ }
235
+ // mcp.json — remove our server entry only.
236
+ const mcpPath = cursorMcpPath(opts);
237
+ const mcpConfig = await readJsonObject(mcpPath);
238
+ if (mcpConfig && isRecord(mcpConfig.mcpServers) && MCP_SERVER_NAME in mcpConfig.mcpServers) {
239
+ delete mcpConfig.mcpServers[MCP_SERVER_NAME];
240
+ if (Object.keys(mcpConfig.mcpServers).length === 0)
241
+ delete mcpConfig.mcpServers;
242
+ await writeJsonFile(mcpPath, mcpConfig);
243
+ removed.push(mcpPath);
244
+ }
245
+ // rules/gocode-notify.md — remove only OUR rule file, never the rules dir.
246
+ const rulePath = cursorRulePath(opts);
247
+ let ruleExisted = false;
248
+ try {
249
+ await fs.stat(rulePath);
250
+ ruleExisted = true;
251
+ }
252
+ catch (err) {
253
+ // Only ENOENT means "not installed". A permission/IO error must surface as
254
+ // a failure, not be silently reported as a clean uninstall.
255
+ if (err.code !== "ENOENT")
256
+ throw err;
257
+ ruleExisted = false;
258
+ }
259
+ if (ruleExisted) {
260
+ await fs.rm(rulePath, { force: true });
261
+ removed.push(rulePath);
262
+ }
263
+ return {
264
+ removed,
265
+ detail: removed.length > 0
266
+ ? `removed gocode-notify entries (${removed.length} path${removed.length === 1 ? "" : "s"})`
267
+ : "no gocode-notify entries found",
268
+ };
269
+ }
270
+ catch (err) {
271
+ return { removed, failed: true, detail: `Cursor uninstall failed: ${errMessage(err)}` };
272
+ }
273
+ }
@@ -0,0 +1,109 @@
1
+ // Canonical agent-runtime detection (PRD §5.2).
2
+ //
3
+ // Owns the runtime table, the {@link RuntimeStatus} shape, and the detector that
4
+ // both `setup` (the installer) and `status` consume through a single seam. A
5
+ // runtime counts as "detected" when EITHER:
6
+ // - one of its well-known config dirs exists under the home dir, OR
7
+ // - one of its `pathCommands` resolves to a file on `$PATH`.
8
+ // The PATH branch covers the PRD §5.2 rule for Claude Code — "`~/.claude/` exists
9
+ // OR `claude` on PATH" — so a globally-installed runtime is found even before it
10
+ // has written its config dir.
11
+ //
12
+ // Both the home dir (`PathOpts.home`) and `$PATH` (`DetectOptions.path`) are
13
+ // injectable, so the whole detector is unit-testable against fixture HOMEs with
14
+ // zero reliance on the machine's real config dirs or installed binaries.
15
+ //
16
+ // Detection is best-effort and NEVER throws (a stat failure just reads as
17
+ // "absent"). Zero runtime deps — Node built-ins only, matching the package rule.
18
+ import { promises as fs } from "node:fs";
19
+ import path from "node:path";
20
+ import { resolveHome } from "./creds.js";
21
+ /** The runtimes we detect, in install-priority order (PRD §5.2). */
22
+ export const RUNTIMES = [
23
+ {
24
+ name: "Claude Code",
25
+ detectDirs: [".claude"],
26
+ configBasename: "settings.json",
27
+ pathCommands: ["claude"],
28
+ },
29
+ { name: "Cursor", detectDirs: [".cursor"], configBasename: "hooks.json" },
30
+ {
31
+ name: "OpenCode",
32
+ detectDirs: [".config/opencode", ".opencode"],
33
+ configBasename: "mcp.json",
34
+ },
35
+ ];
36
+ /** True when `p` exists (any stat error → false). Never throws. */
37
+ async function pathExists(p) {
38
+ try {
39
+ await fs.stat(p);
40
+ return true;
41
+ }
42
+ catch {
43
+ return false;
44
+ }
45
+ }
46
+ /**
47
+ * Best-effort check: does `cmd` resolve to a file on any entry of `pathEnv`?
48
+ * On Windows we also try the usual executable extensions. Empty/undefined PATH →
49
+ * false. Never throws.
50
+ */
51
+ async function commandOnPath(cmd, pathEnv) {
52
+ if (!pathEnv)
53
+ return false;
54
+ const names = process.platform === "win32" ? [cmd, `${cmd}.exe`, `${cmd}.cmd`, `${cmd}.bat`] : [cmd];
55
+ for (const dir of pathEnv.split(path.delimiter)) {
56
+ if (!dir)
57
+ continue;
58
+ for (const name of names) {
59
+ if (await pathExists(path.join(dir, name)))
60
+ return true;
61
+ }
62
+ }
63
+ return false;
64
+ }
65
+ /**
66
+ * Detect every runtime in {@link RUNTIMES}. The installer (`setup`) and `status`
67
+ * both consume this as their single detection seam, so detection stays
68
+ * consistent across the CLI. Resolves (never rejects).
69
+ */
70
+ export async function detectRuntimes(opts) {
71
+ return Promise.all(RUNTIMES.map((spec) => detectRuntime(spec, opts)));
72
+ }
73
+ /**
74
+ * Detect one runtime: which (if any) detect-dir exists, else whether a PATH
75
+ * command resolves, plus the config path we'd write and whether it already
76
+ * exists.
77
+ */
78
+ export async function detectRuntime(spec, opts) {
79
+ const home = resolveHome(opts);
80
+ let detectedDir;
81
+ for (const rel of spec.detectDirs) {
82
+ if (await pathExists(path.join(home, rel))) {
83
+ detectedDir = rel;
84
+ break;
85
+ }
86
+ }
87
+ // Only consult PATH when no config dir was found (cheap-first), and only for
88
+ // runtimes that declare PATH commands.
89
+ let onPath = false;
90
+ if (detectedDir === undefined && spec.pathCommands?.length) {
91
+ const pathEnv = opts?.path ?? process.env.PATH;
92
+ for (const cmd of spec.pathCommands) {
93
+ if (await commandOnPath(cmd, pathEnv)) {
94
+ onPath = true;
95
+ break;
96
+ }
97
+ }
98
+ }
99
+ const detected = detectedDir !== undefined || onPath;
100
+ // Anchor the config path at the detected dir, else the primary detect-dir.
101
+ const baseDir = detectedDir ?? spec.detectDirs[0];
102
+ const configPath = path.join(home, baseDir, spec.configBasename);
103
+ return {
104
+ name: spec.name,
105
+ detected,
106
+ configPath,
107
+ configWritten: detected ? await pathExists(configPath) : false,
108
+ };
109
+ }
@@ -0,0 +1,152 @@
1
+ // gocode-notify `login` — device pairing flow (PRD §1.1, §4.1, §5.1).
2
+ //
3
+ // Exchanges a 6-digit pairing code (shown in the GoCode app's "Connect a coding
4
+ // agent" screen) for a scoped push-only API key via
5
+ // POST /api/v1/notify/pair/claim (NO JWT — the CLI has none yet)
6
+ // then writes ~/.gocode/credentials (chmod 600) so every later `send` is
7
+ // authenticated. When `--code` is absent the code is read interactively.
8
+ //
9
+ // The server URL follows the same precedence as everywhere else:
10
+ // --server flag > GOCODE_SERVER env > credentials file > built-in default
11
+ // (resolved via resolveServerUrl in creds.ts).
12
+ //
13
+ // Zero runtime deps — only Node built-ins, to match the package's zero-dep rule.
14
+ import os from "node:os";
15
+ import { createInterface } from "node:readline/promises";
16
+ import { resolveServerUrl, writeCredentials, } from "./creds.js";
17
+ /** Endpoint the CLI hits to exchange a pairing code for an API key (PRD §3.2). */
18
+ export const CLAIM_PATH = "/api/v1/notify/pair/claim";
19
+ /**
20
+ * Default timeout for the claim request. Longer than `send`'s 5s because login
21
+ * is an explicit, user-initiated action (not a fire-and-forget hook), so a
22
+ * brief extra wait is acceptable and surfaces a clearer failure than a clip.
23
+ */
24
+ export const LOGIN_TIMEOUT_MS = 10_000;
25
+ function errMessage(err) {
26
+ return err instanceof Error ? err.message : String(err);
27
+ }
28
+ /** Strip trailing slashes so `${server}/path` is always clean. */
29
+ function normalizeServer(url) {
30
+ return url.trim().replace(/\/+$/, "");
31
+ }
32
+ /** First non-empty (after trim) string, else undefined. */
33
+ function firstNonEmpty(...values) {
34
+ for (const v of values) {
35
+ if (typeof v === "string" && v.trim() !== "")
36
+ return v;
37
+ }
38
+ return undefined;
39
+ }
40
+ /** A short, friendly default label for this machine (hostname, sans domain). */
41
+ export function defaultLabel() {
42
+ const host = os.hostname().split(".")[0];
43
+ return firstNonEmpty(host) ?? "this machine";
44
+ }
45
+ /** Read a 6-digit code from the terminal (the non-test, interactive path). */
46
+ async function defaultPromptCode() {
47
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
48
+ try {
49
+ return await rl.question("Enter the 6-digit pairing code from the GoCode app: ");
50
+ }
51
+ finally {
52
+ rl.close();
53
+ }
54
+ }
55
+ function isClaimResponse(value) {
56
+ if (typeof value !== "object" || value === null)
57
+ return false;
58
+ const r = value;
59
+ return typeof r.api_key === "string" && typeof r.user_id === "string";
60
+ }
61
+ /**
62
+ * Exchange a pairing `code` for an API key at `POST /pair/claim`. No JWT and no
63
+ * API key are sent (this is the unauthenticated bootstrap). Resolves (never
64
+ * rejects) to a {@link ClaimResult}; failures carry a generic message because
65
+ * the server intentionally does not leak which condition failed (PRD §3.2).
66
+ */
67
+ export async function claim(code, label, opts) {
68
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
69
+ const url = `${normalizeServer(opts.server)}${CLAIM_PATH}`;
70
+ const controller = new AbortController();
71
+ const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? LOGIN_TIMEOUT_MS);
72
+ try {
73
+ const res = await fetchImpl(url, {
74
+ method: "POST",
75
+ headers: { "content-type": "application/json" },
76
+ body: JSON.stringify({ code, label }),
77
+ signal: controller.signal,
78
+ });
79
+ if (!res.ok) {
80
+ return {
81
+ ok: false,
82
+ status: res.status,
83
+ error: `pairing failed (server responded ${res.status}) — check the code and try again`,
84
+ };
85
+ }
86
+ let data;
87
+ try {
88
+ data = await res.json();
89
+ }
90
+ catch {
91
+ return { ok: false, status: res.status, error: "pairing response was not valid JSON" };
92
+ }
93
+ if (!isClaimResponse(data)) {
94
+ return { ok: false, status: res.status, error: "pairing response missing api_key/user_id" };
95
+ }
96
+ return { ok: true, data };
97
+ }
98
+ catch (err) {
99
+ const reason = controller.signal.aborted
100
+ ? "pairing request timed out"
101
+ : `pairing request failed: ${errMessage(err)}`;
102
+ return { ok: false, error: reason };
103
+ }
104
+ finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+ /**
109
+ * Run the full `login` flow: resolve the server, obtain the 6-digit code (flag
110
+ * or interactive prompt), claim an API key, and write ~/.gocode/credentials.
111
+ * Credentials are written ONLY on success. Resolves (never rejects) to a
112
+ * {@link LoginResult}.
113
+ */
114
+ export async function login(opts = {}) {
115
+ const server = await resolveServerUrl(opts.server, opts);
116
+ // Resolve the code. Prompt ONLY when the --code flag is truly absent
117
+ // (undefined). An explicitly-supplied-but-empty code must NOT silently fall
118
+ // into the interactive prompt — it falls through to validation and is
119
+ // rejected, so a bad `--code ""` never hangs on stdin.
120
+ let code = opts.code;
121
+ if (code === undefined) {
122
+ const prompt = opts.promptCode ?? defaultPromptCode;
123
+ try {
124
+ code = await prompt();
125
+ }
126
+ catch (err) {
127
+ return { ok: false, error: `could not read pairing code: ${errMessage(err)}` };
128
+ }
129
+ }
130
+ code = code.trim();
131
+ if (!/^\d{6}$/.test(code)) {
132
+ return { ok: false, error: "pairing code must be 6 digits" };
133
+ }
134
+ const label = firstNonEmpty(opts.label) ?? defaultLabel();
135
+ const result = await claim(code, label, {
136
+ server,
137
+ fetchImpl: opts.fetchImpl,
138
+ timeoutMs: opts.timeoutMs,
139
+ });
140
+ if (!result.ok) {
141
+ return { ok: false, status: result.status, error: result.error };
142
+ }
143
+ const creds = {
144
+ api_key: result.data.api_key,
145
+ server,
146
+ user_id: result.data.user_id,
147
+ // Prefer the label the server echoed back; fall back to the one we sent.
148
+ label: firstNonEmpty(result.data.label, label) ?? label,
149
+ };
150
+ await writeCredentials(creds, opts);
151
+ return { ok: true, credentials: creds };
152
+ }