@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.
- package/CHANGELOG.md +29 -0
- package/LICENSE +21 -0
- package/README.md +237 -0
- package/assets/README.md +16 -0
- package/assets/icon.svg +8 -0
- package/dist/src/agent.js +33 -0
- package/dist/src/claude.js +287 -0
- package/dist/src/cli.js +444 -0
- package/dist/src/commit_message.js +321 -0
- package/dist/src/config.js +348 -0
- package/dist/src/creds.js +158 -0
- package/dist/src/cursor.js +273 -0
- package/dist/src/detect.js +109 -0
- package/dist/src/login.js +152 -0
- package/dist/src/mcp.js +215 -0
- package/dist/src/outbox.js +150 -0
- package/dist/src/push.js +278 -0
- package/dist/src/repo_key.js +98 -0
- package/dist/src/rule-content.js +71 -0
- package/dist/src/send.js +141 -0
- package/dist/src/settings.js +213 -0
- package/dist/src/setup.js +148 -0
- package/dist/src/status.js +150 -0
- package/dist/src/uninstall.js +39 -0
- package/dist/src/version.js +2 -0
- package/package.json +61 -0
- package/snippets/ralph-homer.sh +21 -0
|
@@ -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`";
|
package/dist/src/send.js
ADDED
|
@@ -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
|
+
}
|