octarin-cli 0.2.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/dist/login.js ADDED
@@ -0,0 +1,209 @@
1
+ /**
2
+ * `octarin login` — device-code browser flow for per-user CLI auth.
3
+ *
4
+ * The repo (team) install committed `OCTARIN_PROJECT=<org/project>` in the
5
+ * project's `.octarin.env`. This command turns that public slug into a
6
+ * per-user, per-device ingest key without ever passing a shared secret around:
7
+ *
8
+ * 1. POST /v1/devices/start { project, device_label } → get a `device_code`
9
+ * + verification URL.
10
+ * 2. Open the URL in the user's browser; they sign in (Supabase Google SSO)
11
+ * and click "Authorize this machine."
12
+ * 3. Poll /v1/devices/{code}/poll every ~2s. First poll after approval
13
+ * returns the minted plaintext (exactly once — see service.ts).
14
+ * 4. Write the plaintext to `~/.octarin/octarin.env` (chmod 600) so the
15
+ * capture hooks pick it up.
16
+ *
17
+ * The CLI never reads or writes a shared team key; revocation is one DB row.
18
+ */
19
+ import { promises as fs } from "node:fs";
20
+ import { hostname, userInfo } from "node:os";
21
+ import { spawn } from "node:child_process";
22
+ import { CliError, resolveBaseUrl } from "./client.js";
23
+ import { logErr } from "./output.js";
24
+ import { flagStr } from "./args.js";
25
+ import { findRepoProjectFile, mergeUserEnv, parseEnvFile } from "./envfile.js";
26
+ /**
27
+ * Anonymous POST/GET helper for the device endpoints — login is what mints the
28
+ * key, so we have nothing to send in `Authorization`. Errors map the gateway's
29
+ * `{ error: { code, message } }` envelope to a friendly `CliError`.
30
+ */
31
+ async function deviceFetch(baseUrl, path, init) {
32
+ const url = `${baseUrl}${path}`;
33
+ let resp;
34
+ try {
35
+ resp = await fetch(url, {
36
+ ...init,
37
+ headers: { "Content-Type": "application/json", ...(init.headers || {}) },
38
+ });
39
+ }
40
+ catch (err) {
41
+ const detail = err instanceof Error ? err.message : String(err);
42
+ throw new CliError(`Could not reach ${url}: ${detail}`, 1);
43
+ }
44
+ const text = await resp.text();
45
+ let body;
46
+ if (text) {
47
+ try {
48
+ body = JSON.parse(text);
49
+ }
50
+ catch {
51
+ // non-JSON body falls through to status handling below
52
+ }
53
+ }
54
+ if (!resp.ok) {
55
+ const envelope = body;
56
+ const msg = envelope?.error?.message || text || `HTTP ${resp.status}`;
57
+ const code = envelope?.error?.code;
58
+ throw new CliError(`Request failed (${resp.status}${code ? ` ${code}` : ""}): ${msg}`, 1);
59
+ }
60
+ return body;
61
+ }
62
+ // ──────────────────────────────── project resolution ─────────────────────────
63
+ /**
64
+ * Resolve the project identifier ("org/project" or a UUID) the user wants to
65
+ * authorize for. Precedence: `--project` > `$OCTARIN_PROJECT` > the
66
+ * `.octarin/project` walked up from CWD.
67
+ */
68
+ async function resolveProject(projectFlag) {
69
+ if (projectFlag)
70
+ return projectFlag;
71
+ const fromEnv = process.env.OCTARIN_PROJECT;
72
+ if (fromEnv)
73
+ return fromEnv;
74
+ const envPath = await findRepoProjectFile(process.cwd());
75
+ if (envPath) {
76
+ const parsed = parseEnvFile(await fs.readFile(envPath, "utf8"));
77
+ if (parsed.OCTARIN_PROJECT)
78
+ return parsed.OCTARIN_PROJECT;
79
+ }
80
+ throw new CliError("No project. Run inside a repo whose `.octarin/project` carries OCTARIN_PROJECT, " +
81
+ "or pass `--project <org/project>`.", 2);
82
+ }
83
+ // ──────────────────────────────── browser open ───────────────────────────────
84
+ /**
85
+ * Open `url` in the user's default browser, best-effort. Detached + ignored
86
+ * stdio so we don't hang waiting on the browser process. Failures (no DISPLAY,
87
+ * SSH session, headless box) are logged but never throw — the user can still
88
+ * paste the URL manually.
89
+ */
90
+ function openInBrowser(url) {
91
+ const platform = process.platform;
92
+ let cmd;
93
+ let args;
94
+ if (platform === "darwin") {
95
+ cmd = "open";
96
+ args = [url];
97
+ }
98
+ else if (platform === "win32") {
99
+ cmd = "cmd";
100
+ args = ["/c", "start", "", url];
101
+ }
102
+ else {
103
+ cmd = "xdg-open";
104
+ args = [url];
105
+ }
106
+ try {
107
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
108
+ child.on("error", () => {
109
+ logErr(`(could not auto-open the browser; please open this URL manually: ${url})`);
110
+ });
111
+ child.unref();
112
+ }
113
+ catch {
114
+ logErr(`(could not auto-open the browser; please open this URL manually: ${url})`);
115
+ }
116
+ }
117
+ // ──────────────────────────────── command ────────────────────────────────────
118
+ const LOGIN_HELP = `octarin login — device-code browser flow
119
+
120
+ USAGE
121
+ octarin login [--project <slug>] [--device-label <name>]
122
+
123
+ OPTIONS
124
+ --project <slug> Project to authorize for ("org/project" or a UUID).
125
+ Defaults to OCTARIN_PROJECT or the repo-root .octarin.env.
126
+ --device-label <s> Human label shown on the Authorize page.
127
+ Default: "<user>@<hostname>".
128
+ --base-url <url> API base URL. Or set OCTARIN_BASE_URL.
129
+ --port <port> Override the port (self-host parity).
130
+ -h, --help Show this help.
131
+
132
+ WHAT IT DOES
133
+ 1. Asks the Octarin API for a device code.
134
+ 2. Opens https://octarin.ai/devices/<code> in your browser.
135
+ 3. You sign in, see what you're authorizing, and click "Authorize this machine".
136
+ 4. The CLI receives a per-user ingest key and writes it to
137
+ ~/.octarin/octarin.env (mode 600). The Cursor/Claude/Codex capture hooks
138
+ pick it up on the next session.
139
+
140
+ Nothing is committed to git; the key is unique to you + this machine and can
141
+ be revoked from Octarin without affecting your teammates.
142
+ `;
143
+ /**
144
+ * `octarin login` — device-code handshake. Polls every 2s until the user
145
+ * approves (or the row times out), then writes the minted key to disk.
146
+ */
147
+ export async function cmdLogin(positionals, flags) {
148
+ void positionals; // login takes no positionals
149
+ if (flags.help === true) {
150
+ logErr(LOGIN_HELP);
151
+ return;
152
+ }
153
+ const projectFlag = flagStr(flags, "project");
154
+ const labelFlag = flagStr(flags, "device-label");
155
+ const project = await resolveProject(projectFlag);
156
+ const deviceLabel = labelFlag || `${userInfo().username}@${hostname()}`.slice(0, 200);
157
+ const baseUrl = resolveBaseUrl({
158
+ baseUrl: flagStr(flags, "base-url"),
159
+ port: flagStr(flags, "port"),
160
+ });
161
+ // 1. /start
162
+ logErr(`> authorizing this machine for project: ${project}`);
163
+ const start = await deviceFetch(baseUrl, "/v1/devices/start", {
164
+ method: "POST",
165
+ body: JSON.stringify({ project, device_label: deviceLabel }),
166
+ });
167
+ logErr("");
168
+ logErr(` Open this URL in your browser to authorize:`);
169
+ logErr(` ${start.verification_url}`);
170
+ logErr("");
171
+ openInBrowser(start.verification_url);
172
+ // 2. /poll every 2s until terminal.
173
+ const deadline = Date.now() + start.expires_in * 1000;
174
+ let lastStatus = "pending";
175
+ let waitedDots = 0;
176
+ while (Date.now() < deadline) {
177
+ await new Promise((r) => setTimeout(r, 2000));
178
+ const poll = await deviceFetch(baseUrl, `/v1/devices/${encodeURIComponent(start.device_code)}/poll`, { method: "POST" });
179
+ if (poll.status === "pending") {
180
+ // Light progress feedback so the user knows we're alive.
181
+ if (waitedDots % 5 === 0)
182
+ process.stderr.write(".");
183
+ waitedDots++;
184
+ lastStatus = poll.status;
185
+ continue;
186
+ }
187
+ if (poll.status === "approved" && poll.api_key) {
188
+ process.stderr.write("\n");
189
+ const projectSlug = poll.project?.public_slug || project;
190
+ const path = await mergeUserEnv({ OCTARIN_API_KEY: poll.api_key, OCTARIN_PROJECT: projectSlug }, "# Octarin per-user capture credentials. Written by `octarin login`.");
191
+ logErr(`✓ Authorized as ${poll.user_email}.`);
192
+ logErr(` Wrote ${path} (mode 600, key ${poll.key_prefix}…).`);
193
+ logErr(" Cursor / Claude Code / Codex sessions in this repo now stream to Octarin.");
194
+ return;
195
+ }
196
+ // Anything else is terminal: denied, expired, or 'collected' (another
197
+ // poller already grabbed the key — shouldn't happen in single-CLI use).
198
+ process.stderr.write("\n");
199
+ lastStatus = poll.status;
200
+ break;
201
+ }
202
+ if (lastStatus === "denied") {
203
+ throw new CliError("Authorization was denied. Re-run `octarin login` when ready.", 1);
204
+ }
205
+ if (lastStatus === "collected") {
206
+ throw new CliError("Authorization was already collected by another CLI session. Re-run `octarin login`.", 1);
207
+ }
208
+ throw new CliError("Authorization timed out. Re-run `octarin login` and approve within ~10 minutes.", 1);
209
+ }
package/dist/output.js ADDED
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Output helpers — strict stdout/stderr separation for agent-friendly piping.
3
+ *
4
+ * Contract (mirrors Laminar's `lmnr-cli`): *structured* output (the actual
5
+ * query result / schema, as JSON or a rendered table) goes to **stdout**;
6
+ * *everything else* — progress, hints, warnings, errors — goes to **stderr**.
7
+ * That way `octarin sql query "..." --json | jq` receives clean JSON on stdout
8
+ * with no log noise, and a non-zero exit code signals failure to the caller.
9
+ */
10
+ /** Write a line to stderr (human/progress/diagnostic messages). */
11
+ export function logErr(message) {
12
+ process.stderr.write(message + "\n");
13
+ }
14
+ /** Write structured output to stdout (the machine-readable result). */
15
+ export function out(text) {
16
+ process.stdout.write(text + "\n");
17
+ }
18
+ /** Pretty-print a value as indented JSON to stdout. */
19
+ export function outJson(value) {
20
+ out(JSON.stringify(value, null, 2));
21
+ }
22
+ /**
23
+ * Render rows as a monospace ASCII table to stdout.
24
+ *
25
+ * `columns` are the header names; `rows` are arrays aligned to them. Cells are
26
+ * stringified (null → "" , objects → compact JSON) and each column is padded to
27
+ * its widest cell. Designed for human reading in a terminal — use `--json` for
28
+ * anything a program will parse.
29
+ */
30
+ export function outTable(columns, rows) {
31
+ if (columns.length === 0) {
32
+ out("(no columns)");
33
+ return;
34
+ }
35
+ const cells = rows.map((r) => r.map(formatCell));
36
+ const widths = columns.map((c, i) => Math.max(c.length, ...cells.map((row) => (row[i] ?? "").length), 0));
37
+ const sep = "+" + widths.map((w) => "-".repeat(w + 2)).join("+") + "+";
38
+ const renderRow = (vals) => "| " + vals.map((v, i) => v.padEnd(widths[i])).join(" | ") + " |";
39
+ out(sep);
40
+ out(renderRow(columns));
41
+ out(sep);
42
+ for (const row of cells) {
43
+ // Pad short rows so the table stays rectangular.
44
+ const padded = columns.map((_, i) => row[i] ?? "");
45
+ out(renderRow(padded));
46
+ }
47
+ out(sep);
48
+ }
49
+ /** Stringify a single cell value for the ASCII table. */
50
+ function formatCell(value) {
51
+ if (value === null || value === undefined)
52
+ return "";
53
+ if (typeof value === "object")
54
+ return JSON.stringify(value);
55
+ return String(value);
56
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "octarin-cli",
3
+ "version": "0.2.0",
4
+ "description": "Octarin's per-user CLI: install AI-coding capture (`octarin init` / `init-repo`), authorize a machine (`octarin login`), and run scoped read-only SQL over your own data. Never holds database credentials.",
5
+ "keywords": [
6
+ "octarin",
7
+ "sql",
8
+ "cli",
9
+ "observability",
10
+ "llm",
11
+ "analytics",
12
+ "agent"
13
+ ],
14
+ "license": "MIT",
15
+ "type": "module",
16
+ "bin": {
17
+ "octarin": "dist/index.js"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "assets",
22
+ "README.md"
23
+ ],
24
+ "engines": {
25
+ "node": ">=18"
26
+ },
27
+ "scripts": {
28
+ "copy-assets": "node scripts/copy-assets.mjs",
29
+ "build": "npm run copy-assets && tsc -p tsconfig.json",
30
+ "prepublishOnly": "npm run build",
31
+ "start": "node dist/index.js"
32
+ },
33
+ "devDependencies": {
34
+ "typescript": "^5.4.0",
35
+ "@types/node": "^20.11.0"
36
+ }
37
+ }