@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.
- package/dist/index.js +225 -55
- 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/
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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">✓ 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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
86
|
-
const authUrl =
|
|
87
|
-
console.error(
|
|
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
|
-
|
|
90
|
-
|
|
163
|
+
try {
|
|
164
|
+
await onUrl(authUrl);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
finish(() => reject(err));
|
|
167
|
+
}
|
|
91
168
|
});
|
|
92
|
-
const timeout = setTimeout(
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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 &&
|
|
1576
|
-
return { status: "satisfied", detail: "
|
|
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
|
|
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", "
|
|
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
|
|
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 = () =>
|
|
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}`);
|