@pebblehouse/odin-cli 0.16.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 +225 -55
  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
 
@@ -1565,15 +1735,15 @@ var githubRepoStep = {
1565
1735
  };
1566
1736
  var githubSecretStep = {
1567
1737
  id: "github-secret",
1568
- label: "GitHub Actions secret DOPPLER_TOKEN_PRD",
1738
+ label: "GitHub Actions secret DOPPLER_TOKEN",
1569
1739
  async check(ctx) {
1570
1740
  const cli = await run("gh", ["--version"]);
1571
1741
  if (!cli.ok) {
1572
1742
  return { status: "blocked", detail: "GitHub CLI not found. Install + run `gh auth login`." };
1573
1743
  }
1574
1744
  const list = await run("gh", ["secret", "list", "-R", `${ORG}/${ctx.slug}`]);
1575
- if (list.ok && /DOPPLER_TOKEN_PRD/.test(list.stdout)) {
1576
- 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" };
1577
1747
  }
1578
1748
  return null;
1579
1749
  },
@@ -1581,7 +1751,7 @@ var githubSecretStep = {
1581
1751
  if (ctx.dryRun) {
1582
1752
  return {
1583
1753
  status: "applied",
1584
- 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"
1585
1755
  };
1586
1756
  }
1587
1757
  const token = await run("doppler", [
@@ -1599,13 +1769,13 @@ var githubSecretStep = {
1599
1769
  return { status: "failed", detail: `doppler token mint failed: ${token.stderr}` };
1600
1770
  }
1601
1771
  const set = await setSecretViaStdin(
1602
- ["secret", "set", "DOPPLER_TOKEN_PRD", "-R", `${ORG}/${ctx.slug}`],
1772
+ ["secret", "set", "DOPPLER_TOKEN", "-R", `${ORG}/${ctx.slug}`],
1603
1773
  token.stdout.trim()
1604
1774
  );
1605
1775
  if (!set.ok) {
1606
1776
  return { status: "failed", detail: `gh secret set failed: ${set.stderr}` };
1607
1777
  }
1608
- return { status: "applied", detail: "Set DOPPLER_TOKEN_PRD (read-only prd token)" };
1778
+ return { status: "applied", detail: "Set DOPPLER_TOKEN (read-only prd token)" };
1609
1779
  }
1610
1780
  };
1611
1781
  function setSecretViaStdin(args, value) {
@@ -1648,7 +1818,7 @@ var vercelStep = {
1648
1818
  };
1649
1819
 
1650
1820
  // src/commands/pebble/steps/r2.ts
1651
- var bucketName = (slug) => `${slug}-tfstate`;
1821
+ var bucketName = (slug) => `${slug}-web-tfstate`;
1652
1822
  var r2Step = {
1653
1823
  id: "r2",
1654
1824
  label: "Cloudflare R2 tfstate bucket",
@@ -2010,8 +2180,8 @@ var githubProdEnvStep = {
2010
2180
  };
2011
2181
 
2012
2182
  // src/commands/create/steps/supabase.ts
2013
- import { randomBytes } from "crypto";
2014
- var genPassword = () => randomBytes(18).toString("base64url");
2183
+ import { randomBytes as randomBytes2 } from "crypto";
2184
+ var genPassword = () => randomBytes2(18).toString("base64url");
2015
2185
  function supabaseStep2(env) {
2016
2186
  return {
2017
2187
  id: `supabase-${env}`,
@@ -2379,10 +2549,10 @@ ${RESET}
2379
2549
  }
2380
2550
  var program = new Command23();
2381
2551
  program.name("odin").description("CLI for Odin \u2014 the knowledge backbone for Pebble House").version(VERSION);
2382
- 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) => {
2383
2553
  printBanner();
2384
2554
  try {
2385
- await login();
2555
+ await login({ device: options.device });
2386
2556
  console.error(`${GOLD}Logged in successfully.${RESET}`);
2387
2557
  } catch (err) {
2388
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.16.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": {