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/README.md +202 -0
- package/assets/backfill.py +1113 -0
- package/assets/claude_code/hook.py +573 -0
- package/assets/codex/hook.mjs +487 -0
- package/assets/cursor/hook-handler.js +41 -0
- package/assets/cursor/lib/canonical.js +240 -0
- package/assets/cursor/lib/utils.js +138 -0
- package/assets/repo-template/dot-claude/octarin/hook.py +685 -0
- package/assets/repo-template/dot-claude/octarin/run.sh +41 -0
- package/assets/repo-template/dot-claude/settings.json +15 -0
- package/assets/repo-template/dot-codex/config.toml +6 -0
- package/assets/repo-template/dot-codex/hooks/hook.mjs +531 -0
- package/assets/repo-template/dot-codex/hooks/run.sh +38 -0
- package/assets/repo-template/dot-cursor/hooks/hook-handler.js +41 -0
- package/assets/repo-template/dot-cursor/hooks/lib/canonical.js +240 -0
- package/assets/repo-template/dot-cursor/hooks/lib/utils.js +196 -0
- package/assets/repo-template/dot-cursor/hooks/run.sh +41 -0
- package/assets/repo-template/dot-cursor/hooks.json +13 -0
- package/dist/args.js +85 -0
- package/dist/assets.js +28 -0
- package/dist/client.js +105 -0
- package/dist/envfile.js +94 -0
- package/dist/index.js +192 -0
- package/dist/init.js +314 -0
- package/dist/init_repo.js +348 -0
- package/dist/login.js +209 -0
- package/dist/output.js +56 -0
- package/package.json +37 -0
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
|
+
}
|