@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.
- package/dist/index.js +245 -63
- 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
|
|
|
@@ -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").
|
|
266
|
-
|
|
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
|
|
417
|
-
|
|
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
|
|
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
|
|
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 &&
|
|
1564
|
-
return { status: "satisfied", detail: "
|
|
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
|
|
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", "
|
|
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
|
|
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 = () =>
|
|
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}`);
|