@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,321 @@
1
+ // Commit-message composition for `gocode-notify push-on-stop` (PRD §2.3).
2
+ //
3
+ // SCOPE OF THIS MODULE (T-C1): the DETERMINISTIC fallback only — the structured
4
+ // message built purely from the staged diff stat + branch + agent source, plus
5
+ // the cheap path-based `<type>` inference. This path has zero dependencies, never
6
+ // touches the network, and never shells out, so it is ALWAYS available and is the
7
+ // guaranteed fallback the PRD requires ("the push must NEVER block or fail because
8
+ // the AI summary was unavailable" — §0.4).
9
+ //
10
+ // The local-summariser resolution chain + sanitiser + 8s cap that wrap this
11
+ // fallback (the full `composeCommitMessage()` entry point — T-C2) are layered on
12
+ // top of these pure functions at the bottom of this file: they call
13
+ // `buildDeterministicMessage` when the AI path is disabled, fails, or times out.
14
+ //
15
+ // Zero runtime deps — only Node built-ins (child_process / fs for the local
16
+ // summariser subprocess), to match the package's zero-dep rule.
17
+ import { spawn } from "node:child_process";
18
+ import { accessSync, constants as fsConstants } from "node:fs";
19
+ import path from "node:path";
20
+ /** Max number of per-file stat lines rendered before collapsing to "...and M more". */
21
+ const MAX_STAT_LINES = 10;
22
+ const MARKDOWN_RE = /\.(?:md|mdx|markdown)$/i;
23
+ // A path counts as "test" if any segment is a conventional test directory, or the
24
+ // filename carries a `.test.`/`.spec.` infix (covers TS/JS/Dart/Python conventions).
25
+ const TEST_DIR_RE = /(?:^|\/)(?:tests?|__tests__|spec|specs)\//i;
26
+ const TEST_FILE_RE = /(?:^|\/)[^/]*\.(?:test|spec)\.[^/]+$/i;
27
+ // "chore" = config / lockfiles / dotfiles that aren't product code. Kept deliberately
28
+ // conservative: a clear lockfile, a known config filename, or a repo dotfile.
29
+ const LOCKFILE_RE = /(?:^|\/)(?:package-lock\.json|yarn\.lock|pnpm-lock\.yaml|pubspec\.lock|poetry\.lock|Cargo\.lock|Gemfile\.lock|composer\.lock)$/i;
30
+ const CONFIG_FILE_RE = /(?:^|\/)(?:package\.json|tsconfig(?:\.[^/]+)?\.json|pubspec\.yaml|\.gitignore|\.gitattributes|\.editorconfig|\.npmrc|\.nvmrc|\.eslintrc(?:\.[^/]+)?|\.prettierrc(?:\.[^/]+)?|[^/]*\.config\.[^/]+)$/i;
31
+ function isDocsPath(path) {
32
+ return MARKDOWN_RE.test(path);
33
+ }
34
+ function isTestPath(path) {
35
+ return TEST_DIR_RE.test(path) || TEST_FILE_RE.test(path);
36
+ }
37
+ function isChorePath(path) {
38
+ return LOCKFILE_RE.test(path) || CONFIG_FILE_RE.test(path);
39
+ }
40
+ /**
41
+ * Infer the commit `<type>` from the changed paths (PRD §2.3), deterministically:
42
+ * • `docs` — every path is markdown.
43
+ * • `test` — every path lives in a test dir or is a `.test.`/`.spec.` file.
44
+ * • `chore` — every path is a lockfile / known config / repo dotfile.
45
+ * • `feat` — anything else (the catch-all; also the empty-changeset default).
46
+ *
47
+ * "Every path" semantics: a mixed changeset (e.g. a `.ts` source file + its test)
48
+ * falls through to `feat`, which is the safe, honest default for a real change.
49
+ */
50
+ export function inferCommitType(paths) {
51
+ if (paths.length === 0)
52
+ return "feat";
53
+ if (paths.every(isDocsPath))
54
+ return "docs";
55
+ if (paths.every(isTestPath))
56
+ return "test";
57
+ if (paths.every(isChorePath))
58
+ return "chore";
59
+ return "feat";
60
+ }
61
+ /**
62
+ * Parse `git diff --staged --numstat` output into {@link FileStat}s. numstat emits
63
+ * one tab-separated `added\tdeleted\tpath` record per file; binary files report
64
+ * `-` for both counts. Blank lines and malformed records are skipped (forgiving —
65
+ * this feeds a best-effort commit message, never a hard gate).
66
+ *
67
+ * Rename records (`old => new`, `dir/{a => b}/file`) are preserved verbatim in
68
+ * `path`; we do not attempt to expand them — the raw git rendering is fine for a
69
+ * commit-body stat line.
70
+ */
71
+ export function parseNumstat(numstat) {
72
+ const out = [];
73
+ for (const rawLine of numstat.split("\n")) {
74
+ const line = rawLine.replace(/\r$/, "");
75
+ if (line.trim() === "")
76
+ continue;
77
+ const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
78
+ if (!m)
79
+ continue;
80
+ const [, addedRaw, deletedRaw, path] = m;
81
+ const binary = addedRaw === "-" || deletedRaw === "-";
82
+ out.push({
83
+ path,
84
+ added: addedRaw === "-" ? 0 : Number.parseInt(addedRaw, 10),
85
+ deleted: deletedRaw === "-" ? 0 : Number.parseInt(deletedRaw, 10),
86
+ ...(binary ? { binary: true } : {}),
87
+ });
88
+ }
89
+ return out;
90
+ }
91
+ /** Render one file's stat line for the commit body: `path | +A -D` (or `| bin`). */
92
+ function statLine(f) {
93
+ if (f.binary)
94
+ return `${f.path} | bin`;
95
+ return `${f.path} | +${f.added} -${f.deleted}`;
96
+ }
97
+ /**
98
+ * Build the deterministic fallback commit message (PRD §2.3 template):
99
+ *
100
+ * <type>: <N> file(s) changed on <branch>
101
+ *
102
+ * <up to 10 "path | +A -D" lines, then "...and M more">
103
+ *
104
+ * Auto-committed by GoCode Notify (source: <source>).
105
+ *
106
+ * Pure and total: given the same input it always returns the same string, and it
107
+ * never throws on an empty changeset (returns a coherent "0 files" message — the
108
+ * caller is responsible for the clean-tree no-op gate, §2.1 step 5).
109
+ */
110
+ export function buildDeterministicMessage({ files, branch, source }) {
111
+ const type = inferCommitType(files.map((f) => f.path));
112
+ const n = files.length;
113
+ const subject = `${type}: ${n} ${n === 1 ? "file" : "files"} changed on ${branch}`;
114
+ const shown = files.slice(0, MAX_STAT_LINES);
115
+ const lines = shown.map(statLine);
116
+ const more = n - shown.length;
117
+ if (more > 0)
118
+ lines.push(`...and ${more} more`);
119
+ const statBlock = lines.join("\n");
120
+ const footer = `Auto-committed by GoCode Notify (source: ${source}).`;
121
+ // A real changeset always has a stat block; guard the empty case so we never
122
+ // emit a stray blank line that would confuse `git commit -F`.
123
+ return statBlock === ""
124
+ ? `${subject}\n\n${footer}`
125
+ : `${subject}\n\n${statBlock}\n\n${footer}`;
126
+ }
127
+ /** Hard cap (ms) on the WHOLE AI attempt, per PRD §2.3 ("8s hard cap total"). */
128
+ export const AI_TIMEOUT_MS = 8000;
129
+ /** Default diff byte cap (PRD §2.3 / §3.4 `max_diff_bytes`: 60 KB). */
130
+ export const DEFAULT_MAX_DIFF_BYTES = 61440;
131
+ /** Max subject-line length the sanitiser clamps AI output to (PRD §2.3: ≤ 72). */
132
+ export const SUBJECT_MAX = 72;
133
+ /** Fixed prompt prefix sent to the local summariser (PRD §2.3). */
134
+ export const COMMIT_PROMPT = "Write a concise git commit message (imperative subject ≤ 72 chars, optional " +
135
+ "1–3 line body) describing these staged changes. Output ONLY the message.";
136
+ /**
137
+ * Resolve the local summariser command (PRD §2.3 detection order):
138
+ * 1. `settings.command` (explicit user-provided) — wins if non-empty.
139
+ * 2. Auto-detect from `source`: `claude_code` → `claude`, `cursor` →
140
+ * `cursor-agent`, each only if on PATH.
141
+ * 3. Else `ollama` if present.
142
+ * Returns `null` when nothing is configured/available (→ deterministic fallback).
143
+ */
144
+ export function resolveSummariserCommand(settings, source, commandExists) {
145
+ const explicit = settings?.command?.trim();
146
+ if (explicit)
147
+ return explicit;
148
+ if (source === "claude_code" && commandExists("claude"))
149
+ return "claude -p";
150
+ if (source === "cursor" && commandExists("cursor-agent"))
151
+ return "cursor-agent --print";
152
+ if (commandExists("ollama"))
153
+ return "ollama run llama3.2";
154
+ return null;
155
+ }
156
+ /**
157
+ * Sanitise raw AI output into a usable commit message (PRD §2.3):
158
+ * • normalise newlines, strip surrounding whitespace;
159
+ * • drop ``` code-fence lines wherever they appear;
160
+ * • drop a single leading "Here is…"/"Sure,…"/"Commit message:" preamble line;
161
+ * • strip an inline `Commit message:` prefix off the subject;
162
+ * • clamp the subject (first line) to {@link SUBJECT_MAX} chars.
163
+ * Returns `""` for empty/garbage input so the caller uses the deterministic path.
164
+ */
165
+ export function sanitiseAiMessage(raw) {
166
+ if (!raw)
167
+ return "";
168
+ let text = raw.replace(/\r\n/g, "\n").trim();
169
+ if (text === "")
170
+ return "";
171
+ // Drop any code-fence marker lines (handles ```lang … ``` wrapping robustly).
172
+ text = text
173
+ .split("\n")
174
+ .filter((l) => !/^\s*```/.test(l))
175
+ .join("\n")
176
+ .trim();
177
+ const lines = text.split("\n");
178
+ while (lines.length && lines[0].trim() === "")
179
+ lines.shift();
180
+ if (lines.length === 0)
181
+ return "";
182
+ // Strip ONE leading conversational preamble off the subject — whether it sits
183
+ // on its own line ("Here is the commit message:") or is an inline prefix on
184
+ // the subject ("Sure! feat: …", "Commit message: chore: …"). When the line was
185
+ // pure preamble it becomes empty and we drop it, falling through to the body.
186
+ const PREAMBLE_PREFIX_RE = /^\s*(?:here(?:'s| is)\b[^\n:]*:\s*|sure[,!.:]+\s*|certainly[,!.:]*\s*|of course[,!.:]*\s*|okay[,!.:]+\s*|commit message:\s*)/i;
187
+ lines[0] = lines[0].replace(PREAMBLE_PREFIX_RE, "");
188
+ while (lines.length && lines[0].trim() === "")
189
+ lines.shift();
190
+ if (lines.length === 0)
191
+ return "";
192
+ // Clamp the subject (first line) to SUBJECT_MAX chars.
193
+ lines[0] = lines[0].trim();
194
+ if (lines[0].length > SUBJECT_MAX)
195
+ lines[0] = lines[0].slice(0, SUBJECT_MAX).trimEnd();
196
+ return lines.join("\n").trim();
197
+ }
198
+ /**
199
+ * Truncate `diff` to at most `maxBytes` UTF-8 bytes, appending a clear marker
200
+ * when it was cut. Best-effort: a multibyte char straddling the boundary may be
201
+ * dropped — acceptable for a prompt hint that the AI only needs the gist of.
202
+ */
203
+ export function truncateDiff(diff, maxBytes) {
204
+ const buf = Buffer.from(diff, "utf8");
205
+ if (buf.byteLength <= maxBytes)
206
+ return diff;
207
+ const head = buf.subarray(0, maxBytes).toString("utf8");
208
+ return `${head}\n…[diff truncated at ${maxBytes} bytes]`;
209
+ }
210
+ /** Build the summariser stdin: prompt prefix, then the stat summary + diff. */
211
+ function buildSummariserInput(diff, statSummary) {
212
+ const stat = statSummary.trim();
213
+ const statBlock = stat === "" ? "" : `\n\n${stat}`;
214
+ return `${COMMIT_PROMPT}${statBlock}\n\n${diff}`;
215
+ }
216
+ /**
217
+ * Default `commandExists`: true when `bin` is executable on PATH (or, if it
218
+ * contains a path separator, executable at that exact path).
219
+ */
220
+ export function defaultCommandExists(bin) {
221
+ if (bin.includes(path.sep)) {
222
+ try {
223
+ accessSync(bin, fsConstants.X_OK);
224
+ return true;
225
+ }
226
+ catch {
227
+ return false;
228
+ }
229
+ }
230
+ const dirs = (process.env.PATH ?? "").split(path.delimiter).filter(Boolean);
231
+ for (const dir of dirs) {
232
+ try {
233
+ accessSync(path.join(dir, bin), fsConstants.X_OK);
234
+ return true;
235
+ }
236
+ catch {
237
+ // keep looking
238
+ }
239
+ }
240
+ return false;
241
+ }
242
+ /**
243
+ * Default summariser runner: argv-split the command on whitespace, spawn it with
244
+ * the prompt+diff on stdin, and resolve with stdout. The `signal` (the 8s cap)
245
+ * is passed to spawn so an over-budget call is killed. Rejects on spawn error,
246
+ * non-zero exit, or abort — all of which the caller maps to the fallback.
247
+ */
248
+ export const defaultSummariserRunner = ({ command, input, signal }) => new Promise((resolve, reject) => {
249
+ const argv = command.split(/\s+/).filter(Boolean);
250
+ if (argv.length === 0) {
251
+ reject(new Error("empty summariser command"));
252
+ return;
253
+ }
254
+ const child = spawn(argv[0], argv.slice(1), { signal, stdio: ["pipe", "pipe", "ignore"] });
255
+ let out = "";
256
+ child.stdout.setEncoding("utf8");
257
+ child.stdout.on("data", (chunk) => {
258
+ out += chunk;
259
+ });
260
+ child.on("error", reject);
261
+ child.on("close", (code) => {
262
+ if (code === 0)
263
+ resolve(out);
264
+ else
265
+ reject(new Error(`summariser exited with code ${code}`));
266
+ });
267
+ // The child may exit before draining stdin; swallow the resulting EPIPE.
268
+ child.stdin.on("error", () => { });
269
+ child.stdin.end(input);
270
+ });
271
+ /**
272
+ * Attempt the local-AI summary path. Returns the sanitised message on success,
273
+ * or `null` for ANY non-success (disabled mode, no resolvable command, runner
274
+ * error, timeout, empty/garbage output). Never throws.
275
+ */
276
+ async function tryAiSummary(input, deps) {
277
+ const settings = input.settings ?? {};
278
+ const mode = settings.mode ?? "auto";
279
+ if (mode === "deterministic")
280
+ return null;
281
+ const commandExists = deps.commandExists ?? defaultCommandExists;
282
+ const command = resolveSummariserCommand(settings, input.source, commandExists);
283
+ if (!command)
284
+ return null;
285
+ const runSummariser = deps.runSummariser ?? defaultSummariserRunner;
286
+ const timeoutMs = deps.timeoutMs ?? AI_TIMEOUT_MS;
287
+ const maxBytes = settings.max_diff_bytes ?? DEFAULT_MAX_DIFF_BYTES;
288
+ const stdin = buildSummariserInput(truncateDiff(input.stagedDiff, maxBytes), input.statSummary ?? "");
289
+ const controller = new AbortController();
290
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
291
+ try {
292
+ const stdout = await runSummariser({ command, input: stdin, signal: controller.signal });
293
+ const cleaned = sanitiseAiMessage(stdout ?? "");
294
+ return cleaned === "" ? null : cleaned;
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ finally {
300
+ clearTimeout(timer);
301
+ }
302
+ }
303
+ /**
304
+ * Compose the commit message for `push-on-stop` (PRD §2.3). Tries the local-AI
305
+ * summariser (subject to mode + availability + the 8s cap) and ALWAYS falls back
306
+ * to {@link buildDeterministicMessage}. Total + non-blocking: it resolves with a
307
+ * usable message for every input and never rejects.
308
+ */
309
+ export async function composeCommitMessage(input, deps = {}) {
310
+ const ai = await tryAiSummary(input, deps);
311
+ if (ai !== null)
312
+ return { message: ai, generator: "ai" };
313
+ return {
314
+ message: buildDeterministicMessage({
315
+ files: input.files,
316
+ branch: input.branch,
317
+ source: input.source,
318
+ }),
319
+ generator: "deterministic",
320
+ };
321
+ }
@@ -0,0 +1,348 @@
1
+ // `gocode-notify config get|set|pull` — the dev-machine settings editor + the
2
+ // hot-path settings resolver/cache (PRD §2.6, §2.1 step 1, §3.6).
3
+ //
4
+ // Two responsibilities live here:
5
+ //
6
+ // 1. The `config` CLI subcommand (PRD §2.6):
7
+ // config get [--repo] → print merged settings (server or cache)
8
+ // config set <key> <value> [--repo] → validate key, PUT to server, refresh cache
9
+ // config pull → force-refresh the local cache from server
10
+ // Writes go to the server (the source of truth) AND update the local cache so
11
+ // the next stop-hook is immediate. Keys are validated against `settings.ts`
12
+ // (unknown keys rejected with the valid-key list).
13
+ //
14
+ // 2. The settings RESOLUTION + 60s-TTL cache the auto-push hot path needs
15
+ // (PRD §2.1 step 1) — deferred to here by `push.ts` (T-C3). `resolveNotifySettings`
16
+ // returns the merged blob for a repo: a fresh cache hit short-circuits the
17
+ // network; a stale entry re-pulls; if the network is down it falls back to the
18
+ // (possibly stale) cache; with no cache at all it returns the conservative
19
+ // defaults (auto-push OFF — fail-safe, §0.5).
20
+ //
21
+ // The cache lives in `~/.gocode/config.json` under `notify_settings` (PRD §2.1):
22
+ // notify_settings: { entries: { "<repo_key|__global__>": { settings, fetched_at_ms, repo_label? } } }
23
+ //
24
+ // Zero runtime deps — Node built-ins + an injectable `fetch` (so tests stub the
25
+ // network and a temp HOME stubs fs).
26
+ import { readConfig, writeConfig, readCredentials, } from "./creds.js";
27
+ import { DEFAULT_NOTIFY_SETTINGS, setSetting, } from "./settings.js";
28
+ import { deriveRepoIdentity } from "./repo_key.js";
29
+ /** Server paths for the Notify settings API (PRD §3.3). */
30
+ export const SETTINGS_PATH = "/api/v1/notify/settings";
31
+ export const PROJECTS_PATH = "/api/v1/notify/settings/projects";
32
+ /** Default cache TTL on the hot path (PRD §2.1 step 1: "short TTL (default 60s)"). */
33
+ export const DEFAULT_CACHE_TTL_MS = 60_000;
34
+ /** Default network timeout for `config` round-trips (mirrors `send`'s 5s cap). */
35
+ export const DEFAULT_CONFIG_TIMEOUT_MS = 5000;
36
+ /** Cache key used for the no-repo (global) entry. */
37
+ export const GLOBAL_CACHE_KEY = "__global__";
38
+ function errMessage(err) {
39
+ return err instanceof Error ? err.message : String(err);
40
+ }
41
+ function normalizeServer(url) {
42
+ return url.trim().replace(/\/+$/, "");
43
+ }
44
+ /** The cache key for a scope: a repo's `repo_key`, or the global sentinel. */
45
+ export function cacheKeyForScope(scope) {
46
+ return scope.kind === "repo" ? scope.repo.repo_key : GLOBAL_CACHE_KEY;
47
+ }
48
+ // ── cache read/write (within ~/.gocode/config.json) ──────────────────────────
49
+ function readCacheBlock(config) {
50
+ const block = config?.notify_settings;
51
+ if (block && typeof block === "object" && !Array.isArray(block)) {
52
+ const entries = block.entries;
53
+ if (entries && typeof entries === "object" && !Array.isArray(entries)) {
54
+ return { entries: entries };
55
+ }
56
+ }
57
+ return { entries: {} };
58
+ }
59
+ /** Read the settings cache out of `~/.gocode/config.json` (empty when absent). */
60
+ export async function readSettingsCache(opts) {
61
+ let config = null;
62
+ try {
63
+ config = await readConfig(opts);
64
+ }
65
+ catch {
66
+ // A corrupt config.json must never wedge the hot path — treat as empty cache.
67
+ config = null;
68
+ }
69
+ return readCacheBlock(config);
70
+ }
71
+ /**
72
+ * Upsert one cache entry under `~/.gocode/config.json` `notify_settings.entries`,
73
+ * preserving every other key in config.json (round-trips unknown prefs, §0.1).
74
+ */
75
+ export async function writeCacheEntry(key, entry, opts) {
76
+ let config = null;
77
+ try {
78
+ config = await readConfig(opts);
79
+ }
80
+ catch {
81
+ config = null;
82
+ }
83
+ const base = config ?? {};
84
+ const cache = readCacheBlock(base);
85
+ cache.entries[key] = entry;
86
+ await writeConfig({ ...base, notify_settings: cache }, opts);
87
+ }
88
+ /** True when `entry` is within `ttlMs` of `now` (i.e. still fresh). */
89
+ export function isFresh(entry, ttlMs, now) {
90
+ return now - entry.fetched_at_ms < ttlMs;
91
+ }
92
+ function buildUrl(net, scope, forWrite) {
93
+ const base = normalizeServer(net.server);
94
+ if (scope.kind === "repo") {
95
+ const repo = scope.repo;
96
+ if (forWrite) {
97
+ const q = new URLSearchParams({ repo_label: repo.repo_label });
98
+ return `${base}${PROJECTS_PATH}/${encodeURIComponent(repo.repo_key)}?${q.toString()}`;
99
+ }
100
+ const q = new URLSearchParams({ repo_key: repo.repo_key, repo_label: repo.repo_label });
101
+ return `${base}${SETTINGS_PATH}?${q.toString()}`;
102
+ }
103
+ return `${base}${SETTINGS_PATH}`;
104
+ }
105
+ async function request(net, url, method, body) {
106
+ const fetchImpl = net.fetchImpl ?? globalThis.fetch;
107
+ const timeoutMs = net.timeoutMs ?? DEFAULT_CONFIG_TIMEOUT_MS;
108
+ const controller = new AbortController();
109
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
110
+ try {
111
+ const res = await fetchImpl(url, {
112
+ method,
113
+ headers: {
114
+ "content-type": "application/json",
115
+ authorization: `Bearer ${net.apiKey}`,
116
+ },
117
+ body: body === undefined ? undefined : JSON.stringify(body),
118
+ signal: controller.signal,
119
+ });
120
+ if (!res.ok)
121
+ throw new Error(`server responded ${res.status}`);
122
+ const json = (await res.json());
123
+ if (json && typeof json === "object" && !Array.isArray(json)) {
124
+ return json;
125
+ }
126
+ throw new Error("server returned a non-object settings body");
127
+ }
128
+ catch (err) {
129
+ if (controller.signal.aborted)
130
+ throw new Error(`timeout after ${timeoutMs}ms`);
131
+ throw err instanceof Error ? err : new Error(String(err));
132
+ }
133
+ finally {
134
+ clearTimeout(timer);
135
+ }
136
+ }
137
+ /** GET the merged settings for a scope from the server (PRD §3.3). */
138
+ export async function fetchSettings(net, scope) {
139
+ return request(net, buildUrl(net, scope, false), "GET");
140
+ }
141
+ /** PUT a partial settings blob (global) or per-repo override (PRD §3.3). */
142
+ export async function putSettings(net, scope, partial) {
143
+ return request(net, buildUrl(net, scope, true), "PUT", partial);
144
+ }
145
+ /**
146
+ * Resolve the merged Notify settings for a repo (PRD §2.1 step 1, §3.6). Order:
147
+ * 1. fresh cache hit (within TTL) and not forcing → use cache (no network).
148
+ * 2. else pull from server → cache it → return ("network").
149
+ * 3. server unreachable / unpaired → fall back to the (possibly stale) cache.
150
+ * 4. no cache at all → conservative DEFAULTS (auto-push OFF — fail-safe).
151
+ * NEVER throws: the caller (a stop hook) must never be blocked by settings I/O.
152
+ */
153
+ export async function resolveNotifySettings(opts = {}) {
154
+ const now = (opts.now ?? Date.now)();
155
+ const ttlMs = opts.ttlMs ?? DEFAULT_CACHE_TTL_MS;
156
+ const scope = opts.repo ? { kind: "repo", repo: opts.repo } : { kind: "global" };
157
+ const key = cacheKeyForScope(scope);
158
+ const cache = await readSettingsCache(opts);
159
+ const cached = cache.entries[key];
160
+ if (cached && !opts.forcePull && isFresh(cached, ttlMs, now)) {
161
+ return { settings: cached.settings, source: "cache" };
162
+ }
163
+ // Need a network pull. Resolve credentials; if unpaired, fall back gracefully.
164
+ let creds;
165
+ try {
166
+ creds = await readCredentials(opts);
167
+ }
168
+ catch {
169
+ creds = null;
170
+ }
171
+ if (creds) {
172
+ const net = {
173
+ server: normalizeServer(opts.server ?? creds.server),
174
+ apiKey: creds.api_key,
175
+ fetchImpl: opts.fetchImpl,
176
+ timeoutMs: opts.timeoutMs,
177
+ };
178
+ try {
179
+ const settings = await fetchSettings(net, scope);
180
+ await writeCacheEntry(key, {
181
+ settings,
182
+ fetched_at_ms: now,
183
+ repo_label: opts.repo?.repo_label,
184
+ }, opts).catch(() => { });
185
+ return { settings, source: "network" };
186
+ }
187
+ catch {
188
+ // fall through to cache / default
189
+ }
190
+ }
191
+ if (cached)
192
+ return { settings: cached.settings, source: "cache" };
193
+ return { settings: { ...DEFAULT_NOTIFY_SETTINGS }, source: "default" };
194
+ }
195
+ /**
196
+ * Parse `config <sub> [positionals] [--repo] [--json] [--server URL]`. Unlike
197
+ * `cli.parseFlags`, this PRESERVES bare positionals (the `<key> <value>` of
198
+ * `config set`) while still pulling the known flags out of the stream.
199
+ */
200
+ export function parseConfigArgs(args) {
201
+ const out = { positionals: [], repo: false, json: false };
202
+ out.sub = args[0];
203
+ for (let i = 1; i < args.length; i++) {
204
+ const a = args[i];
205
+ if (a === "--repo") {
206
+ out.repo = true;
207
+ }
208
+ else if (a === "--json") {
209
+ out.json = true;
210
+ }
211
+ else if (a === "--server") {
212
+ const next = args[i + 1];
213
+ if (next !== undefined && !next.startsWith("--")) {
214
+ out.server = next;
215
+ i++;
216
+ }
217
+ }
218
+ else if (a.startsWith("--server=")) {
219
+ out.server = a.slice("--server=".length);
220
+ }
221
+ else if (a.startsWith("--")) {
222
+ // Unknown flag — ignore (keeps positionals clean).
223
+ }
224
+ else {
225
+ out.positionals.push(a);
226
+ }
227
+ }
228
+ return out;
229
+ }
230
+ async function resolveScope(parsed, deps) {
231
+ if (!parsed.repo)
232
+ return { kind: "global" };
233
+ const derive = deps.deriveRepo ?? ((cwd) => deriveRepoIdentity(cwd ?? process.cwd()));
234
+ const repo = await derive(deps.cwd);
235
+ return { kind: "repo", repo };
236
+ }
237
+ async function netFromCreds(parsed, deps) {
238
+ let creds;
239
+ try {
240
+ creds = await readCredentials(deps);
241
+ }
242
+ catch (e) {
243
+ return { error: `credentials unreadable: ${errMessage(e)}` };
244
+ }
245
+ if (!creds)
246
+ return { error: "not paired — run `gocode-notify login` first" };
247
+ return {
248
+ server: normalizeServer(parsed.server ?? creds.server),
249
+ apiKey: creds.api_key,
250
+ fetchImpl: deps.fetchImpl,
251
+ timeoutMs: deps.timeoutMs,
252
+ };
253
+ }
254
+ /**
255
+ * Handle `gocode-notify config get|set|pull`. Interactive diagnostic — exits
256
+ * non-zero on a usage/validation/IO error so the human/agent sees it (unlike the
257
+ * fire-and-forget hooks which always exit 0).
258
+ */
259
+ export async function cmdConfig(args, deps = {}) {
260
+ const out = deps.out ?? ((l) => console.log(l));
261
+ const err = deps.err ?? ((l) => console.error(l));
262
+ const parsed = parseConfigArgs(args);
263
+ switch (parsed.sub) {
264
+ case "get":
265
+ return cmdConfigGet(parsed, deps, out, err);
266
+ case "set":
267
+ return cmdConfigSet(parsed, deps, out, err);
268
+ case "pull":
269
+ return cmdConfigPull(parsed, deps, out, err);
270
+ default:
271
+ err(`gocode-notify config: expected a subcommand (get | set | pull), got "${parsed.sub ?? ""}"`);
272
+ return 2;
273
+ }
274
+ }
275
+ async function cmdConfigGet(parsed, deps, out, err) {
276
+ const scope = await resolveScope(parsed, deps);
277
+ const result = await resolveNotifySettings({
278
+ home: deps.home,
279
+ repo: scope.kind === "repo" ? scope.repo : undefined,
280
+ server: parsed.server,
281
+ fetchImpl: deps.fetchImpl,
282
+ timeoutMs: deps.timeoutMs,
283
+ ttlMs: deps.ttlMs,
284
+ now: deps.now,
285
+ });
286
+ out(JSON.stringify(result.settings, null, 2));
287
+ if (result.source === "default") {
288
+ err("gocode-notify config get: server unreachable and no cache — showing defaults");
289
+ }
290
+ return 0;
291
+ }
292
+ async function cmdConfigSet(parsed, deps, out, err) {
293
+ const [key, ...valueParts] = parsed.positionals;
294
+ if (key === undefined || valueParts.length === 0) {
295
+ err("gocode-notify config set: usage: config set <key> <value> [--repo]");
296
+ return 2;
297
+ }
298
+ const value = valueParts.join(" ");
299
+ const validated = setSetting(key, value, { repo: parsed.repo });
300
+ if (!validated.ok) {
301
+ err(`gocode-notify config set: ${validated.error}`);
302
+ return 2;
303
+ }
304
+ const scope = await resolveScope(parsed, deps);
305
+ const net = await netFromCreds(parsed, deps);
306
+ if ("error" in net) {
307
+ err(`gocode-notify config set: ${net.error}`);
308
+ return 1;
309
+ }
310
+ let merged;
311
+ try {
312
+ merged = await putSettings(net, scope, validated.partial);
313
+ }
314
+ catch (e) {
315
+ err(`gocode-notify config set: write failed (${errMessage(e)})`);
316
+ return 1;
317
+ }
318
+ // Refresh the local cache immediately so the next stop-hook honours it (§2.6).
319
+ const now = (deps.now ?? Date.now)();
320
+ await writeCacheEntry(cacheKeyForScope(scope), {
321
+ settings: merged,
322
+ fetched_at_ms: now,
323
+ repo_label: scope.kind === "repo" ? scope.repo.repo_label : undefined,
324
+ }, deps).catch(() => { });
325
+ const where = scope.kind === "repo" ? ` for ${scope.repo.repo_label}` : "";
326
+ out(`✓ Set ${key}=${value}${where}.`);
327
+ return 0;
328
+ }
329
+ async function cmdConfigPull(parsed, deps, out, err) {
330
+ const scope = await resolveScope(parsed, deps);
331
+ const result = await resolveNotifySettings({
332
+ home: deps.home,
333
+ repo: scope.kind === "repo" ? scope.repo : undefined,
334
+ server: parsed.server,
335
+ fetchImpl: deps.fetchImpl,
336
+ timeoutMs: deps.timeoutMs,
337
+ ttlMs: deps.ttlMs,
338
+ now: deps.now,
339
+ forcePull: true,
340
+ });
341
+ if (result.source !== "network") {
342
+ err("gocode-notify config pull: server unreachable — local cache unchanged");
343
+ return 1;
344
+ }
345
+ out(JSON.stringify(result.settings, null, 2));
346
+ out(`✓ Pulled latest settings${scope.kind === "repo" ? ` for ${scope.repo.repo_label}` : ""}.`);
347
+ return 0;
348
+ }