@pebblehouse/odin-cli 0.15.0 → 0.17.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.
Files changed (2) hide show
  1. package/dist/index.js +245 -63
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,9 +4,20 @@
4
4
  import { Command as Command23 } from "commander";
5
5
 
6
6
  // src/auth/login.ts
7
- import { createServer } from "http";
8
7
  import open from "open";
9
8
 
9
+ // src/auth/headless.ts
10
+ function isHeadless(env = process.env, platform = process.platform) {
11
+ if (env.ODIN_FORCE_BROWSER === "1") return false;
12
+ if (env.SSH_CONNECTION || env.SSH_TTY) return true;
13
+ if (platform === "linux" && !env.DISPLAY && !env.WAYLAND_DISPLAY) return true;
14
+ if (env.CI) return true;
15
+ return false;
16
+ }
17
+
18
+ // src/auth/login-loopback.ts
19
+ import { createServer } from "http";
20
+
10
21
  // src/auth/credentials.ts
11
22
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "fs";
12
23
  import { join } from "path";
@@ -34,69 +45,228 @@ function clearCredentials() {
34
45
  }
35
46
  }
36
47
 
37
- // src/auth/login.ts
48
+ // src/auth/pkce.ts
49
+ import { randomBytes, createHash } from "crypto";
50
+ function generateVerifier() {
51
+ return randomBytes(64).toString("base64url");
52
+ }
53
+ function challengeFromVerifier(verifier) {
54
+ return createHash("sha256").update(verifier).digest("base64url");
55
+ }
56
+ function generateState() {
57
+ return randomBytes(32).toString("hex");
58
+ }
59
+
60
+ // src/auth/login-loopback.ts
38
61
  var API_URL = process.env.ODIN_API_URL ?? "https://www.odin.mu";
39
- async function login() {
40
- return new Promise((resolve3, reject) => {
41
- const server = createServer(async (req, res) => {
42
- if (req.method === "POST" && req.url?.startsWith("/callback")) {
43
- let body = "";
44
- for await (const chunk of req) {
45
- body += chunk;
46
- }
47
- const params = new URLSearchParams(body);
48
- const access_token = params.get("access_token");
49
- const refresh_token = params.get("refresh_token");
50
- const expires_at = params.get("expires_at");
51
- if (!access_token || !refresh_token || !expires_at) {
52
- res.writeHead(400, { "Content-Type": "text/html" });
53
- res.end("<html><body><p>Missing tokens. Login failed.</p></body></html>");
54
- server.close();
55
- reject(new Error("Missing tokens in callback"));
56
- return;
57
- }
58
- const creds = {
59
- access_token,
60
- refresh_token,
61
- expires_at: parseInt(expires_at, 10)
62
- };
63
- saveCredentials(creds);
64
- res.writeHead(200, { "Content-Type": "text/html" });
65
- res.end(`<!DOCTYPE html>
62
+ var CLIENT_ID = "odin-cli";
63
+ var TIMEOUT_MS = 18e4;
64
+ var SUCCESS_HTML = `<!DOCTYPE html>
66
65
  <html><body style="margin:0;background:#111;color:#e5e5e5;font-family:monospace;display:flex;align-items:center;justify-content:center;min-height:100vh;text-align:center">
67
66
  <div>
68
67
  <p style="color:#f2c94c;font-size:1.5rem;margin-bottom:0.5rem">&#10003; Login successful</p>
69
68
  <p style="color:#666;font-size:0.9rem">You can close this tab.</p>
70
69
  </div>
71
- </body></html>`);
72
- server.close();
73
- resolve3();
74
- } else {
70
+ </body></html>`;
71
+ function buildAuthorizeUrl(params) {
72
+ const q = new URLSearchParams({
73
+ response_type: "code",
74
+ client_id: CLIENT_ID,
75
+ redirect_uri: params.redirectUri,
76
+ state: params.state,
77
+ code_challenge: params.challenge,
78
+ code_challenge_method: "S256"
79
+ });
80
+ return `${API_URL}/api/oauth/authorize?${q.toString()}`;
81
+ }
82
+ async function exchangeCode(args) {
83
+ const res = await fetch(`${API_URL}/api/oauth/token`, {
84
+ method: "POST",
85
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
86
+ body: new URLSearchParams({
87
+ grant_type: "authorization_code",
88
+ code: args.code,
89
+ code_verifier: args.verifier,
90
+ redirect_uri: args.redirectUri,
91
+ client_id: CLIENT_ID
92
+ }).toString()
93
+ });
94
+ const json = await res.json().catch(() => ({}));
95
+ if (!res.ok) {
96
+ throw new Error(`Token exchange failed: ${json.error ?? res.status}`);
97
+ }
98
+ return {
99
+ access_token: json.access_token,
100
+ refresh_token: json.refresh_token,
101
+ expires_at: Math.floor(Date.now() / 1e3) + (json.expires_in ?? 3600)
102
+ };
103
+ }
104
+ function loginLoopback(onUrl) {
105
+ return new Promise((resolve3, reject) => {
106
+ const verifier = generateVerifier();
107
+ const challenge = challengeFromVerifier(verifier);
108
+ const state = generateState();
109
+ let redirectUri = "";
110
+ let settled = false;
111
+ const finish = (fn) => {
112
+ if (settled) return;
113
+ settled = true;
114
+ server.close();
115
+ fn();
116
+ };
117
+ const server = createServer(async (req, res) => {
118
+ if (req.method !== "GET" || !req.url?.startsWith("/callback")) {
75
119
  res.writeHead(404);
76
120
  res.end();
121
+ return;
122
+ }
123
+ const url = new URL(req.url, redirectUri || "http://127.0.0.1");
124
+ const code = url.searchParams.get("code");
125
+ const returnedState = url.searchParams.get("state");
126
+ const error = url.searchParams.get("error");
127
+ if (error) {
128
+ res.writeHead(400, { "Content-Type": "text/html" });
129
+ res.end(`<html><body><p>Login failed: ${error}</p></body></html>`);
130
+ finish(() => reject(new Error(`Authorization failed: ${error}`)));
131
+ return;
132
+ }
133
+ if (!code || returnedState !== state) {
134
+ res.writeHead(400, { "Content-Type": "text/html" });
135
+ res.end("<html><body><p>Invalid callback. Login failed.</p></body></html>");
136
+ finish(
137
+ () => reject(new Error("Invalid callback: state mismatch or missing code"))
138
+ );
139
+ return;
140
+ }
141
+ try {
142
+ const creds = await exchangeCode({ code, verifier, redirectUri });
143
+ saveCredentials(creds);
144
+ res.writeHead(200, { "Content-Type": "text/html" });
145
+ res.end(SUCCESS_HTML);
146
+ finish(resolve3);
147
+ } catch (err) {
148
+ res.writeHead(500, { "Content-Type": "text/html" });
149
+ res.end("<html><body><p>Token exchange failed.</p></body></html>");
150
+ finish(() => reject(err));
77
151
  }
78
152
  });
79
- server.listen(0, "127.0.0.1", () => {
153
+ server.listen(0, "127.0.0.1", async () => {
80
154
  const addr = server.address();
81
155
  if (!addr || typeof addr === "string") {
82
- reject(new Error("Failed to start callback server"));
156
+ finish(() => reject(new Error("Failed to start callback server")));
83
157
  return;
84
158
  }
85
- const port = addr.port;
86
- const authUrl = `${API_URL}/auth/cli?port=${port}`;
87
- console.error(`Opening browser for authentication...`);
159
+ redirectUri = `http://127.0.0.1:${addr.port}/callback`;
160
+ const authUrl = buildAuthorizeUrl({ redirectUri, state, challenge });
161
+ console.error("Opening browser for authentication...");
88
162
  console.error(`If the browser doesn't open, visit: ${authUrl}`);
89
- open(authUrl).catch(() => {
90
- });
163
+ try {
164
+ await onUrl(authUrl);
165
+ } catch (err) {
166
+ finish(() => reject(err));
167
+ }
91
168
  });
92
- const timeout = setTimeout(() => {
93
- server.close();
94
- reject(new Error("Login timed out. Try again."));
95
- }, 12e4);
169
+ const timeout = setTimeout(
170
+ () => finish(() => reject(new Error("Login timed out. Try 'odin login --device'."))),
171
+ TIMEOUT_MS
172
+ );
96
173
  server.on("close", () => clearTimeout(timeout));
97
174
  });
98
175
  }
99
176
 
177
+ // src/auth/login-device.ts
178
+ var API_URL2 = process.env.ODIN_API_URL ?? "https://www.odin.mu";
179
+ var CLIENT_ID2 = "odin-cli";
180
+ var DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
181
+ async function requestDeviceCode() {
182
+ const res = await fetch(`${API_URL2}/api/oauth/device/code`, {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
185
+ body: new URLSearchParams({ client_id: CLIENT_ID2 }).toString()
186
+ });
187
+ if (!res.ok) {
188
+ throw new Error(`Could not start device login (HTTP ${res.status}).`);
189
+ }
190
+ return await res.json();
191
+ }
192
+ async function pollOnce(deviceCode) {
193
+ const res = await fetch(`${API_URL2}/api/oauth/token`, {
194
+ method: "POST",
195
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
196
+ body: new URLSearchParams({
197
+ grant_type: DEVICE_GRANT,
198
+ device_code: deviceCode,
199
+ client_id: CLIENT_ID2
200
+ }).toString()
201
+ });
202
+ const json = await res.json().catch(() => ({}));
203
+ if (res.ok) {
204
+ return {
205
+ creds: {
206
+ access_token: json.access_token,
207
+ refresh_token: json.refresh_token,
208
+ expires_at: Math.floor(Date.now() / 1e3) + (json.expires_in ?? 3600)
209
+ }
210
+ };
211
+ }
212
+ return { error: json.error ?? `http_${res.status}` };
213
+ }
214
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
215
+ async function loginDevice(deps = {}) {
216
+ const nap = deps.sleepFn ?? sleep;
217
+ const start = await requestDeviceCode();
218
+ console.error("");
219
+ console.error(` Open: ${start.verification_uri}`);
220
+ console.error(` Code: ${start.user_code}`);
221
+ console.error("");
222
+ console.error("Waiting for approval...");
223
+ let interval = Math.max(1, start.interval || 5);
224
+ const deadline = Date.now() + start.expires_in * 1e3;
225
+ while (Date.now() < deadline) {
226
+ await nap(interval * 1e3);
227
+ const { creds, error } = await pollOnce(start.device_code);
228
+ if (creds) {
229
+ saveCredentials(creds);
230
+ return;
231
+ }
232
+ if (error === "authorization_pending") continue;
233
+ if (error === "slow_down") {
234
+ interval += 5;
235
+ continue;
236
+ }
237
+ if (error === "expired_token") {
238
+ throw new Error("The device code expired. Run 'odin login --device' again.");
239
+ }
240
+ if (error === "access_denied") {
241
+ throw new Error("Authorization was denied.");
242
+ }
243
+ throw new Error(`Device login failed: ${error}`);
244
+ }
245
+ throw new Error("The device code expired. Run 'odin login --device' again.");
246
+ }
247
+
248
+ // src/auth/login.ts
249
+ async function login(options = {}) {
250
+ if (options.device || isHeadless()) {
251
+ return loginDevice();
252
+ }
253
+ try {
254
+ return await loginLoopback(async (url) => {
255
+ await open(url);
256
+ });
257
+ } catch (err) {
258
+ if (isOpenFailure(err)) {
259
+ console.error("Could not open a browser \u2014 falling back to device login.");
260
+ return loginDevice();
261
+ }
262
+ throw err;
263
+ }
264
+ }
265
+ function isOpenFailure(err) {
266
+ if (!(err instanceof Error)) return false;
267
+ return /spawn|ENOENT|xdg-open|Exited with code/i.test(err.message);
268
+ }
269
+
100
270
  // src/commands/pebbles.ts
101
271
  import { Command } from "commander";
102
272
 
@@ -262,8 +432,13 @@ pebblesCmd.command("update <slug>").description("Update a pebble").option("--slu
262
432
  import { Command as Command2 } from "commander";
263
433
  import { readFileSync as readFileSync3 } from "fs";
264
434
  var docsCmd = new Command2("docs").description("Manage documents");
265
- docsCmd.command("list <slug>").description("List documents for a pebble").action(async (slug) => {
266
- const res = await apiRequest(`/pebbles/${slug}/documents`);
435
+ docsCmd.command("list <slug>").description("List documents for a pebble").option(
436
+ "--doc-type <type>",
437
+ "Filter by document type (architecture, product, api, readme, spec, research, marketing, other, reference, org_chart, design, schema)"
438
+ ).action(async (slug, opts) => {
439
+ const params = {};
440
+ if (opts.docType) params.doc_type = opts.docType;
441
+ const res = await apiRequest(`/pebbles/${slug}/documents`, { params });
267
442
  process.stdout.write(JSON.stringify(res) + "\n");
268
443
  if (res.error) process.exit(1);
269
444
  });
@@ -405,7 +580,10 @@ function emitListResult(res, noun, resolved) {
405
580
  // src/commands/decisions.ts
406
581
  var decisionsCmd = new Command3("decisions").description("Manage decisions");
407
582
  withListFlags(
408
- decisionsCmd.command("list <slug>").description("List decisions for a pebble")
583
+ decisionsCmd.command("list <slug>").description("List decisions for a pebble").option(
584
+ "--status <status>",
585
+ "Filter by status (active, superseded, all). Defaults to active on the server."
586
+ )
409
587
  ).action(async (slug, opts) => {
410
588
  const resolved = resolveListParams(opts);
411
589
  if (resolved.fatal) {
@@ -413,9 +591,9 @@ withListFlags(
413
591
  process.exit(1);
414
592
  return;
415
593
  }
416
- const res = await apiRequest(`/pebbles/${slug}/decisions`, {
417
- params: resolved.params
418
- });
594
+ const params = { ...resolved.params };
595
+ if (opts.status) params.status = opts.status;
596
+ const res = await apiRequest(`/pebbles/${slug}/decisions`, { params });
419
597
  emitListResult(res, "decisions", resolved);
420
598
  });
421
599
  decisionsCmd.command("get <slug> <id>").description("Get a decision by ID").action(async (slug, id) => {
@@ -549,8 +727,12 @@ var searchCmd = new Command5("search").description("Full-text search across Odin
549
727
  import { Command as Command6 } from "commander";
550
728
  import { readFileSync as readFileSync4 } from "fs";
551
729
  var plansCmd = new Command6("plans").description("Manage plans");
552
- plansCmd.command("list <slug>").description("List plans for a pebble").action(async (slug) => {
553
- const res = await apiRequest(`/pebbles/${slug}/plans`);
730
+ plansCmd.command("list <slug>").description("List plans for a pebble").option("--status <status>", "Filter by status (active, done, archived)").option("--bet <id>", "Filter to plans linked to this bet").option("--task <id>", "Filter to plans linked to this task").action(async (slug, opts) => {
731
+ const params = {};
732
+ if (opts.status) params.status = opts.status;
733
+ if (opts.bet) params.bet_id = opts.bet;
734
+ if (opts.task) params.task_id = opts.task;
735
+ const res = await apiRequest(`/pebbles/${slug}/plans`, { params });
554
736
  process.stdout.write(JSON.stringify(res) + "\n");
555
737
  if (res.error) process.exit(1);
556
738
  });
@@ -1553,15 +1735,15 @@ var githubRepoStep = {
1553
1735
  };
1554
1736
  var githubSecretStep = {
1555
1737
  id: "github-secret",
1556
- label: "GitHub Actions secret DOPPLER_TOKEN_PRD",
1738
+ label: "GitHub Actions secret DOPPLER_TOKEN",
1557
1739
  async check(ctx) {
1558
1740
  const cli = await run("gh", ["--version"]);
1559
1741
  if (!cli.ok) {
1560
1742
  return { status: "blocked", detail: "GitHub CLI not found. Install + run `gh auth login`." };
1561
1743
  }
1562
1744
  const list = await run("gh", ["secret", "list", "-R", `${ORG}/${ctx.slug}`]);
1563
- if (list.ok && /DOPPLER_TOKEN_PRD/.test(list.stdout)) {
1564
- return { status: "satisfied", detail: "DOPPLER_TOKEN_PRD already set" };
1745
+ if (list.ok && /\bDOPPLER_TOKEN\b/.test(list.stdout)) {
1746
+ return { status: "satisfied", detail: "DOPPLER_TOKEN already set" };
1565
1747
  }
1566
1748
  return null;
1567
1749
  },
@@ -1569,7 +1751,7 @@ var githubSecretStep = {
1569
1751
  if (ctx.dryRun) {
1570
1752
  return {
1571
1753
  status: "applied",
1572
- detail: "[dry-run] would mint scoped prd token and set DOPPLER_TOKEN_PRD"
1754
+ detail: "[dry-run] would mint scoped prd token and set DOPPLER_TOKEN"
1573
1755
  };
1574
1756
  }
1575
1757
  const token = await run("doppler", [
@@ -1587,13 +1769,13 @@ var githubSecretStep = {
1587
1769
  return { status: "failed", detail: `doppler token mint failed: ${token.stderr}` };
1588
1770
  }
1589
1771
  const set = await setSecretViaStdin(
1590
- ["secret", "set", "DOPPLER_TOKEN_PRD", "-R", `${ORG}/${ctx.slug}`],
1772
+ ["secret", "set", "DOPPLER_TOKEN", "-R", `${ORG}/${ctx.slug}`],
1591
1773
  token.stdout.trim()
1592
1774
  );
1593
1775
  if (!set.ok) {
1594
1776
  return { status: "failed", detail: `gh secret set failed: ${set.stderr}` };
1595
1777
  }
1596
- return { status: "applied", detail: "Set DOPPLER_TOKEN_PRD (read-only prd token)" };
1778
+ return { status: "applied", detail: "Set DOPPLER_TOKEN (read-only prd token)" };
1597
1779
  }
1598
1780
  };
1599
1781
  function setSecretViaStdin(args, value) {
@@ -1636,7 +1818,7 @@ var vercelStep = {
1636
1818
  };
1637
1819
 
1638
1820
  // src/commands/pebble/steps/r2.ts
1639
- var bucketName = (slug) => `${slug}-tfstate`;
1821
+ var bucketName = (slug) => `${slug}-web-tfstate`;
1640
1822
  var r2Step = {
1641
1823
  id: "r2",
1642
1824
  label: "Cloudflare R2 tfstate bucket",
@@ -1998,8 +2180,8 @@ var githubProdEnvStep = {
1998
2180
  };
1999
2181
 
2000
2182
  // src/commands/create/steps/supabase.ts
2001
- import { randomBytes } from "crypto";
2002
- var genPassword = () => randomBytes(18).toString("base64url");
2183
+ import { randomBytes as randomBytes2 } from "crypto";
2184
+ var genPassword = () => randomBytes2(18).toString("base64url");
2003
2185
  function supabaseStep2(env) {
2004
2186
  return {
2005
2187
  id: `supabase-${env}`,
@@ -2367,10 +2549,10 @@ ${RESET}
2367
2549
  }
2368
2550
  var program = new Command23();
2369
2551
  program.name("odin").description("CLI for Odin \u2014 the knowledge backbone for Pebble House").version(VERSION);
2370
- program.command("login").description("Authenticate with Odin via browser").action(async () => {
2552
+ program.command("login").description("Authenticate with Odin via browser").option("--device", "Use the device-code flow (for headless or remote machines)").action(async (options) => {
2371
2553
  printBanner();
2372
2554
  try {
2373
- await login();
2555
+ await login({ device: options.device });
2374
2556
  console.error(`${GOLD}Logged in successfully.${RESET}`);
2375
2557
  } catch (err) {
2376
2558
  console.error(`Login failed: ${err instanceof Error ? err.message : err}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pebblehouse/odin-cli",
3
- "version": "0.15.0",
3
+ "version": "0.17.0",
4
4
  "type": "module",
5
5
  "description": "CLI for Odin — the knowledge backbone for Pebble House",
6
6
  "bin": {