@ait-co/console-cli 0.1.2 → 0.1.4

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/cli.mjs CHANGED
@@ -1,291 +1,549 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from "citty";
3
3
  import { spawn } from "node:child_process";
4
- import { randomBytes, timingSafeEqual } from "node:crypto";
5
- import { createServer } from "node:http";
6
- import { chmod, mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
7
- import { basename, dirname, join } from "node:path";
8
- import { homedir } from "node:os";
9
- //#region src/browser.ts
10
- function openBrowser(url) {
11
- if (process.env.AIT_CONSOLE_NO_BROWSER === "1") return Promise.resolve({ launched: false });
12
- try {
13
- const parsed = new URL(url);
14
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return Promise.resolve({ launched: false });
15
- } catch {
16
- return Promise.resolve({ launched: false });
4
+ import { constants } from "node:fs";
5
+ import { chmod, mkdir, mkdtemp, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
6
+ import { homedir, tmpdir } from "node:os";
7
+ import { basename, dirname, join, win32 } from "node:path";
8
+ //#region src/api/http.ts
9
+ var TossApiError = class extends Error {
10
+ constructor(status, errorCode, reason, errorType) {
11
+ super(`Toss API error ${errorCode}: ${reason} (HTTP ${status})`);
12
+ this.status = status;
13
+ this.errorCode = errorCode;
14
+ this.reason = reason;
15
+ this.errorType = errorType;
16
+ this.name = "TossApiError";
17
17
  }
18
- return new Promise((resolve) => {
19
- try {
20
- if (process.platform === "win32") {
21
- const child = spawn("cmd", [
22
- "/c",
23
- "start",
24
- "\"\"",
25
- `"${url.replace(/"/g, "%22")}"`
26
- ], {
27
- stdio: "ignore",
28
- detached: true,
29
- windowsHide: true,
30
- windowsVerbatimArguments: true
31
- });
32
- child.once("error", () => resolve({ launched: false }));
33
- child.once("spawn", () => {
34
- child.unref();
35
- resolve({ launched: true });
36
- });
37
- return;
38
- }
39
- const child = spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], {
40
- stdio: "ignore",
41
- detached: true
42
- });
43
- child.once("error", () => resolve({ launched: false }));
44
- child.once("spawn", () => {
45
- child.unref();
46
- resolve({ launched: true });
47
- });
48
- } catch {
49
- resolve({ launched: false });
50
- }
51
- });
52
- }
53
- //#endregion
54
- //#region src/exit.ts
55
- const ExitCode = {
56
- Ok: 0,
57
- Generic: 1,
58
- Usage: 2,
59
- NotAuthenticated: 10,
60
- NetworkError: 11,
61
- LoginTimeout: 12,
62
- LoginStateMismatch: 13,
63
- UpgradeUnavailable: 20,
64
- UpgradeAlreadyLatest: 21
65
- };
66
- //#endregion
67
- //#region src/flush.ts
68
- async function exitAfterFlush(code) {
69
- await new Promise((resolve) => process.stdout.write("", () => resolve()));
70
- process.exit(code);
71
- }
72
- //#endregion
73
- //#region src/oauth.ts
74
- var CallbackTimeoutError = class extends Error {
75
- constructor(seconds) {
76
- super(`Login timed out after ${seconds}s`);
77
- this.name = "CallbackTimeoutError";
18
+ /** Cookie-based auth rejected — session missing/expired/invalidated. */
19
+ get isAuthError() {
20
+ return this.status === 401 || this.errorCode === "4010";
78
21
  }
79
22
  };
80
- var CallbackStateMismatchError = class extends Error {
81
- constructor() {
82
- super("Invalid or missing state parameter");
83
- this.name = "CallbackStateMismatchError";
23
+ var NetworkError = class extends Error {
24
+ constructor(url, cause) {
25
+ super(`Network request to ${url} failed: ${cause.message}`);
26
+ this.url = url;
27
+ this.name = "NetworkError";
28
+ this.cause = cause;
84
29
  }
85
30
  };
86
- var CallbackMissingCodeError = class extends Error {
87
- constructor() {
88
- super("Missing code parameter");
89
- this.name = "CallbackMissingCodeError";
31
+ var MalformedResponseError = class extends Error {
32
+ constructor(url, status, message, bodyPreview) {
33
+ const suffix = bodyPreview ? ` (body: ${bodyPreview})` : "";
34
+ super(`Malformed response from ${url} (HTTP ${status}): ${message}${suffix}`);
35
+ this.url = url;
36
+ this.status = status;
37
+ this.bodyPreview = bodyPreview;
38
+ this.name = "MalformedResponseError";
90
39
  }
91
40
  };
92
- function randomState() {
93
- return randomBytes(32).toString("base64url");
41
+ /**
42
+ * RFC 6265-ish domain match. We accept the bare hostname case plus the
43
+ * standard suffix match (`.example.com` cookie matches `foo.example.com`),
44
+ * because CDP `Network.getAllCookies` normalises cookie Domain to a form
45
+ * with a leading dot for host-matching cookies but without for explicit-host
46
+ * cookies. Either form should round-trip correctly.
47
+ */
48
+ function domainMatches(cookieDomain, hostname) {
49
+ if (cookieDomain.length === 0) return false;
50
+ const lower = cookieDomain.toLowerCase();
51
+ const host = hostname.toLowerCase();
52
+ if (lower === host) return true;
53
+ if (lower.startsWith(".") && host.endsWith(lower)) return true;
54
+ if (!lower.startsWith(".") && host.endsWith(`.${lower}`)) return true;
55
+ return false;
56
+ }
57
+ /**
58
+ * RFC 6265 §5.1.4 path match. A cookie Path C matches a request path P iff
59
+ * C and P are identical, OR C is a prefix of P and either C already ends
60
+ * with '/' or the character in P immediately after C is '/'. Notably
61
+ * "/foo" does NOT match "/foobar".
62
+ */
63
+ function pathMatches(cookiePath, requestPath) {
64
+ if (!cookiePath) return true;
65
+ if (cookiePath === requestPath) return true;
66
+ if (!requestPath.startsWith(cookiePath)) return false;
67
+ return cookiePath.endsWith("/") || requestPath.charAt(cookiePath.length) === "/";
94
68
  }
95
- function constantTimeStringEqual(a, b) {
96
- const bufA = Buffer.from(a, "utf8");
97
- const bufB = Buffer.from(b, "utf8");
98
- if (bufA.length !== bufB.length) return false;
99
- return timingSafeEqual(bufA, bufB);
69
+ function isSafeCookiePart(s) {
70
+ return !/[\x00-\x1f;\x7f]/.test(s);
100
71
  }
101
- function escapeHtml(s) {
102
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;").replace(/\//g, "&#47;").replace(/`/g, "&#96;");
72
+ /**
73
+ * Build a `Cookie:` header value for the given URL from a captured cookie
74
+ * set. Returns `null` when no cookies match — the caller should skip the
75
+ * header entirely rather than emit `Cookie: ` with an empty value.
76
+ *
77
+ * Ordering follows RFC 6265 §5.4: cookies with longer paths come before
78
+ * cookies with shorter paths; ties break on earliest creation time, which
79
+ * we don't track, so we preserve capture order as a stable tiebreaker.
80
+ */
81
+ function cookieHeaderFor(url, cookies) {
82
+ const matching = cookies.map((c, i) => ({
83
+ c,
84
+ i
85
+ })).filter(({ c }) => {
86
+ if (!isSafeCookiePart(c.name) || !isSafeCookiePart(c.value)) return false;
87
+ if (!domainMatches(c.domain, url.hostname)) return false;
88
+ if (!pathMatches(c.path, url.pathname)) return false;
89
+ if (c.secure && url.protocol !== "https:") return false;
90
+ return true;
91
+ }).sort((a, b) => {
92
+ const byPath = b.c.path.length - a.c.path.length;
93
+ return byPath !== 0 ? byPath : a.i - b.i;
94
+ }).map(({ c }) => c);
95
+ if (matching.length === 0) return null;
96
+ return matching.map((c) => `${c.name}=${c.value}`).join("; ");
103
97
  }
104
- function parseCallbackUrl(reqUrl, expectedState) {
105
- if (!reqUrl) return { kind: "malformed" };
98
+ /**
99
+ * Perform a request against the console API and unwrap the Toss envelope.
100
+ *
101
+ * Always sets `Accept: application/json` and propagates the captured cookie
102
+ * set. Callers may pass additional headers (useful for CSRF tokens that
103
+ * later endpoints turn out to require — discovery is per-feature).
104
+ */
105
+ async function requestConsoleApi(options) {
106
+ const url = new URL(options.url);
107
+ const cookieHeader = cookieHeaderFor(url, options.cookies);
108
+ const headers = {
109
+ Accept: "application/json, text/plain, */*",
110
+ ...options.headers
111
+ };
112
+ if (cookieHeader) headers.Cookie = cookieHeader;
113
+ const init = {
114
+ method: options.method ?? "GET",
115
+ headers,
116
+ redirect: "follow"
117
+ };
118
+ if (options.body !== void 0) {
119
+ headers["Content-Type"] = "application/json";
120
+ init.body = JSON.stringify(options.body);
121
+ }
122
+ const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
123
+ let res;
124
+ try {
125
+ res = await fetchImpl(url, init);
126
+ } catch (err) {
127
+ throw new NetworkError(url.toString(), err);
128
+ }
129
+ let text;
130
+ try {
131
+ text = await res.text();
132
+ } catch (err) {
133
+ throw new MalformedResponseError(url.toString(), res.status, err.message);
134
+ }
106
135
  let parsed;
107
136
  try {
108
- parsed = new URL(reqUrl, "http://127.0.0.1");
109
- } catch {
110
- return { kind: "malformed" };
111
- }
112
- if (parsed.pathname !== "/callback") return { kind: "not-found" };
113
- const raw = {};
114
- for (const [k, v] of parsed.searchParams) raw[k] = v;
115
- const state = raw.state ?? "";
116
- const code = raw.code ?? "";
117
- if (!state || !constantTimeStringEqual(state, expectedState)) return { kind: "state-mismatch" };
118
- if (!code) return { kind: "missing-code" };
119
- return {
120
- kind: "ok",
121
- query: {
122
- code,
123
- state,
124
- raw
125
- }
126
- };
137
+ parsed = JSON.parse(text);
138
+ } catch (err) {
139
+ const preview = text.slice(0, 200).replace(/\s+/g, " ").trim();
140
+ throw new MalformedResponseError(url.toString(), res.status, err.message, preview);
141
+ }
142
+ if (parsed.resultType === "SUCCESS") return parsed.success;
143
+ throw new TossApiError(res.status, parsed.error.errorCode, parsed.error.reason, parsed.error.errorType);
144
+ }
145
+ //#endregion
146
+ //#region src/api/me.ts
147
+ const MEMBER_USER_INFO_URL = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole/members/me/user-info";
148
+ async function fetchConsoleMemberUserInfo(cookies, opts = {}) {
149
+ return requestConsoleApi({
150
+ url: MEMBER_USER_INFO_URL,
151
+ cookies,
152
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
153
+ });
154
+ }
155
+ //#endregion
156
+ //#region src/cdp.ts
157
+ function isResponse(m) {
158
+ return "id" in m;
127
159
  }
128
- const ERROR_MESSAGES = {
129
- "state-mismatch": "Invalid or missing state parameter",
130
- "missing-code": "Missing code parameter",
131
- malformed: "Malformed request URL",
132
- "not-found": "Not found"
160
+ var CdpProtocolError = class extends Error {
161
+ constructor(method, code, message) {
162
+ super(`CDP error for ${method}: ${message} (code=${code})`);
163
+ this.method = method;
164
+ this.code = code;
165
+ this.name = "CdpProtocolError";
166
+ }
133
167
  };
134
- const ERROR_STATUS = {
135
- "state-mismatch": 400,
136
- "missing-code": 400,
137
- malformed: 400,
138
- "not-found": 404
168
+ var CdpConnectionClosedError = class extends Error {
169
+ constructor() {
170
+ super("CDP connection closed before the response arrived.");
171
+ this.name = "CdpConnectionClosedError";
172
+ }
139
173
  };
140
- const SUCCESS_HTML = `<!doctype html>
141
- <html lang="en">
142
- <head><meta charset="utf-8"><title>ait-console</title>
143
- <style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}h1{font-size:1.25rem}</style>
144
- </head>
145
- <body>
146
- <h1>Logged in to ait-console</h1>
147
- <p>You can close this window and return to your terminal.</p>
148
- </body></html>`;
149
- const GONE_HTML = `<!doctype html>
150
- <html lang="en">
151
- <head><meta charset="utf-8"><title>ait-console</title>
152
- <style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}h1{font-size:1.25rem}</style>
153
- </head>
154
- <body>
155
- <h1>This login flow is already complete</h1>
156
- <p>Return to your terminal.</p>
157
- </body></html>`;
158
- function errorHtml(message) {
159
- return `<!doctype html>
160
- <html lang="en">
161
- <head><meta charset="utf-8"><title>ait-console error</title>
162
- <style>body{font-family:system-ui,sans-serif;max-width:32rem;margin:4rem auto;padding:0 1rem;color:#222}h1{font-size:1.25rem;color:#b00020}</style>
163
- </head>
164
- <body>
165
- <h1>Login failed</h1>
166
- <p>${escapeHtml(message)}</p>
167
- <p>Return to your terminal for details.</p>
168
- </body></html>`;
169
- }
170
- async function bindServer(server, preferredPort) {
171
- const tryListen = (port) => new Promise((resolve, reject) => {
172
- const onError = (err) => {
173
- server.removeListener("error", onError);
174
- reject(err);
174
+ var CdpClient = class CdpClient {
175
+ socket;
176
+ nextId = 1;
177
+ pending = /* @__PURE__ */ new Map();
178
+ listeners = /* @__PURE__ */ new Set();
179
+ closed = false;
180
+ constructor(socket) {
181
+ this.socket = socket;
182
+ socket.addEventListener("message", (ev) => this.handleMessage(ev));
183
+ socket.addEventListener("close", () => this.handleClose());
184
+ socket.addEventListener("error", () => {});
185
+ }
186
+ static async connect(options) {
187
+ const socket = (options.webSocketFactory ?? ((url) => new WebSocket(url)))(options.url);
188
+ await new Promise((resolve, reject) => {
189
+ const onOpen = () => {
190
+ cleanup();
191
+ resolve();
192
+ };
193
+ const onError = () => {
194
+ cleanup();
195
+ reject(/* @__PURE__ */ new Error(`Failed to open CDP WebSocket at ${options.url}`));
196
+ };
197
+ const onClose = () => {
198
+ cleanup();
199
+ reject(/* @__PURE__ */ new Error(`CDP WebSocket closed before opening (${options.url})`));
200
+ };
201
+ const cleanup = () => {
202
+ socket.removeEventListener("open", onOpen);
203
+ socket.removeEventListener("error", onError);
204
+ socket.removeEventListener("close", onClose);
205
+ };
206
+ socket.addEventListener("open", onOpen);
207
+ socket.addEventListener("error", onError);
208
+ socket.addEventListener("close", onClose);
209
+ });
210
+ return new CdpClient(socket);
211
+ }
212
+ on(listener) {
213
+ this.listeners.add(listener);
214
+ return () => this.listeners.delete(listener);
215
+ }
216
+ async send(method, params, sessionId) {
217
+ if (this.closed) throw new CdpConnectionClosedError();
218
+ const id = this.nextId++;
219
+ const req = {
220
+ id,
221
+ method
175
222
  };
176
- server.once("error", onError);
177
- server.listen(port, "127.0.0.1", () => {
178
- server.removeListener("error", onError);
179
- const addr = server.address();
180
- if (!addr) reject(/* @__PURE__ */ new Error("Failed to bind callback server"));
181
- else resolve(addr.port);
223
+ if (params) req.params = params;
224
+ if (sessionId) req.sessionId = sessionId;
225
+ const waiter = new Promise((resolve, reject) => {
226
+ this.pending.set(id, {
227
+ resolve,
228
+ reject,
229
+ method
230
+ });
182
231
  });
232
+ this.socket.send(JSON.stringify(req));
233
+ return await waiter;
234
+ }
235
+ async close() {
236
+ if (this.closed) return;
237
+ this.closed = true;
238
+ for (const [, pending] of this.pending) pending.reject(new CdpConnectionClosedError());
239
+ this.pending.clear();
240
+ try {
241
+ this.socket.close();
242
+ } catch {}
243
+ }
244
+ handleMessage(ev) {
245
+ let parsed;
246
+ try {
247
+ const raw = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data);
248
+ parsed = JSON.parse(raw);
249
+ } catch {
250
+ return;
251
+ }
252
+ if (isResponse(parsed)) {
253
+ const pending = this.pending.get(parsed.id);
254
+ if (!pending) return;
255
+ this.pending.delete(parsed.id);
256
+ if ("error" in parsed) pending.reject(new CdpProtocolError(pending.method, parsed.error.code, parsed.error.message));
257
+ else pending.resolve(parsed.result);
258
+ return;
259
+ }
260
+ for (const listener of this.listeners) try {
261
+ listener(parsed);
262
+ } catch {}
263
+ }
264
+ handleClose() {
265
+ if (this.closed) return;
266
+ this.closed = true;
267
+ for (const [, pending] of this.pending) pending.reject(new CdpConnectionClosedError());
268
+ this.pending.clear();
269
+ }
270
+ };
271
+ /**
272
+ * Attach to the first "page" target exposed by the browser. Chrome always
273
+ * opens at least one page target when launched with an initial URL, so this
274
+ * is a reliable way to grab a session without guessing target IDs.
275
+ */
276
+ async function attachToFirstPage(client) {
277
+ const { targetInfos } = await client.send("Target.getTargets");
278
+ const page = targetInfos.find((t) => t.type === "page");
279
+ if (!page) throw new Error("No page target found; Chrome launched without an initial tab.");
280
+ const { sessionId } = await client.send("Target.attachToTarget", {
281
+ targetId: page.targetId,
282
+ flatten: true
183
283
  });
184
- if (preferredPort && preferredPort !== 0) try {
185
- return await tryListen(preferredPort);
186
- } catch (err) {
187
- if (err.code !== "EADDRINUSE") throw err;
188
- }
189
- return tryListen(0);
190
- }
191
- async function startCallbackServer(options = {}) {
192
- const timeoutMs = options.timeoutMs ?? 300 * 1e3;
193
- const expectedState = randomState();
194
- const server = createServer();
195
- const boundPort = await bindServer(server, options.preferredPort);
196
- const redirectUri = `http://127.0.0.1:${boundPort}/callback`;
197
- let settled = false;
198
- let closed = false;
199
- let resolveCb;
200
- let rejectCb;
201
- const waiter = new Promise((resolve, reject) => {
202
- resolveCb = resolve;
203
- rejectCb = reject;
284
+ return {
285
+ sessionId,
286
+ targetId: page.targetId
287
+ };
288
+ }
289
+ /**
290
+ * Subscribe to main-frame navigations on the attached page session. Returns
291
+ * an unsubscribe function.
292
+ *
293
+ * Chrome emits `Page.frameNavigated` for every frame — we filter to the main
294
+ * frame (top-level document) since auxiliary iframes (analytics, chat
295
+ * widgets) would otherwise trigger false matches.
296
+ */
297
+ async function watchMainFrameNavigations(client, sessionId, onNavigate) {
298
+ await client.send("Page.enable", {}, sessionId);
299
+ return client.on((event) => {
300
+ if (event.sessionId !== sessionId) return;
301
+ if (event.method !== "Page.frameNavigated") return;
302
+ const frame = event.params.frame;
303
+ if (!frame?.url || !frame.id) return;
304
+ onNavigate({
305
+ url: frame.url,
306
+ frameId: frame.id,
307
+ isMainFrame: frame.parentId === void 0
308
+ });
204
309
  });
205
- waiter.catch(() => {});
206
- const finish = (outcome) => {
207
- if (settled) return;
208
- settled = true;
209
- if (outcome.kind === "ok") resolveCb(outcome.q);
210
- else rejectCb(outcome.e);
310
+ }
311
+ /**
312
+ * `Network.getAllCookies` is scoped to a target session — Chrome rejects it
313
+ * on the browser-level endpoint with `method not found`. Requiring sessionId
314
+ * here surfaces that constraint at compile time.
315
+ *
316
+ * The response shape is fixed in the CDP spec, but we still validate every
317
+ * cookie's required string/number fields at runtime so a malformed entry
318
+ * (from a future Chrome change, say) fails loud instead of propagating
319
+ * `undefined` into the Cookie: header or the on-disk session file.
320
+ */
321
+ async function getAllCookies(client, sessionId) {
322
+ const result = await client.send("Network.getAllCookies", {}, sessionId);
323
+ if (!Array.isArray(result.cookies)) throw new Error("Network.getAllCookies returned a non-array payload");
324
+ return result.cookies.map((raw, index) => validateCookie(raw, index));
325
+ }
326
+ function validateCookie(raw, index) {
327
+ if (!raw || typeof raw !== "object") throw new Error(`Cookie #${index} is not an object`);
328
+ const c = raw;
329
+ const str = (field) => {
330
+ const v = c[field];
331
+ if (typeof v !== "string") throw new Error(`Cookie #${index}.${field} is not a string`);
332
+ return v;
211
333
  };
212
- server.on("request", (req, res) => {
213
- if (settled) {
214
- res.statusCode = 410;
215
- res.setHeader("content-type", "text/html; charset=utf-8");
216
- res.end(GONE_HTML);
217
- return;
334
+ const num = (field) => {
335
+ const v = c[field];
336
+ if (typeof v !== "number") throw new Error(`Cookie #${index}.${field} is not a number`);
337
+ return v;
338
+ };
339
+ const bool = (field) => {
340
+ const v = c[field];
341
+ if (typeof v !== "boolean") throw new Error(`Cookie #${index}.${field} is not a boolean`);
342
+ return v;
343
+ };
344
+ const base = {
345
+ name: str("name"),
346
+ value: str("value"),
347
+ domain: str("domain"),
348
+ path: str("path"),
349
+ expires: num("expires"),
350
+ httpOnly: bool("httpOnly"),
351
+ secure: bool("secure"),
352
+ session: bool("session")
353
+ };
354
+ const sameSite = c.sameSite;
355
+ if (sameSite === "Strict" || sameSite === "Lax" || sameSite === "None") return {
356
+ ...base,
357
+ sameSite
358
+ };
359
+ return base;
360
+ }
361
+ //#endregion
362
+ //#region src/chrome.ts
363
+ var ChromeNotFoundError = class extends Error {
364
+ constructor(candidates) {
365
+ super(`Could not find Chrome or a Chromium-family browser. Tried: ${candidates.join(", ")}.\nInstall Chrome, or set AITCC_BROWSER to an executable path.`);
366
+ this.candidates = candidates;
367
+ this.name = "ChromeNotFoundError";
368
+ }
369
+ };
370
+ var ChromeLaunchError = class extends Error {
371
+ constructor(executable, cause) {
372
+ super(`Failed to launch ${executable}: ${cause.message}`);
373
+ this.executable = executable;
374
+ this.name = "ChromeLaunchError";
375
+ this.cause = cause;
376
+ }
377
+ };
378
+ var ChromeEndpointTimeoutError = class extends Error {
379
+ constructor(executable) {
380
+ super(`${executable} did not print a DevTools endpoint within the timeout. It may have been blocked by the OS or launched a GUI-less variant.`);
381
+ this.executable = executable;
382
+ this.name = "ChromeEndpointTimeoutError";
383
+ }
384
+ };
385
+ function chromeCandidates(env = process.env, platform = process.platform) {
386
+ const override = env.AITCC_BROWSER;
387
+ const out = [];
388
+ if (override && override.length > 0) out.push(override);
389
+ if (platform === "darwin") out.push("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta", "/Applications/Google Chrome Canary.app/Contents/MacOS/Google Chrome Canary", "/Applications/Chromium.app/Contents/MacOS/Chromium", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "/Applications/Arc.app/Contents/MacOS/Arc");
390
+ else if (platform === "win32") {
391
+ const pf = env.PROGRAMFILES ?? "C:\\Program Files";
392
+ const pf86 = env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
393
+ const local = env.LOCALAPPDATA ?? win32.join(homedir() || "C:\\", "AppData", "Local");
394
+ out.push(win32.join(pf, "Google", "Chrome", "Application", "chrome.exe"), win32.join(pf86, "Google", "Chrome", "Application", "chrome.exe"), win32.join(local, "Google", "Chrome", "Application", "chrome.exe"), win32.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"), win32.join(pf86, "Microsoft", "Edge", "Application", "msedge.exe"));
395
+ } else out.push("google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "microsoft-edge-stable", "microsoft-edge");
396
+ return { candidates: out };
397
+ }
398
+ function isAbsolutePath(p, platform) {
399
+ if (platform === "win32") return /^[A-Za-z]:\\/.test(p);
400
+ return p.startsWith("/");
401
+ }
402
+ async function resolveOnPath(name, env, platform) {
403
+ const path = env.PATH ?? env.Path ?? env.path ?? "";
404
+ if (path.length === 0) return null;
405
+ const sep = platform === "win32" ? ";" : ":";
406
+ const fs = await import("node:fs/promises");
407
+ const extensions = platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter((e) => e.length > 0)] : [""];
408
+ for (const dir of path.split(sep)) {
409
+ if (dir.length === 0) continue;
410
+ for (const ext of extensions) {
411
+ const candidate = join(dir, name + ext);
412
+ try {
413
+ await fs.access(candidate, constants.X_OK);
414
+ return candidate;
415
+ } catch {}
218
416
  }
219
- const result = parseCallbackUrl(req.url, expectedState);
220
- if (result.kind === "ok") {
221
- res.statusCode = 200;
222
- res.setHeader("content-type", "text/html; charset=utf-8");
223
- res.end(SUCCESS_HTML, () => finish({
224
- kind: "ok",
225
- q: result.query
226
- }));
227
- return;
417
+ }
418
+ return null;
419
+ }
420
+ async function findChrome(env = process.env, platform = process.platform) {
421
+ const { candidates } = chromeCandidates(env, platform);
422
+ const fs = await import("node:fs/promises");
423
+ for (const candidate of candidates) {
424
+ if (isAbsolutePath(candidate, platform)) {
425
+ try {
426
+ await fs.access(candidate, constants.X_OK);
427
+ return candidate;
428
+ } catch {}
429
+ continue;
228
430
  }
229
- const status = ERROR_STATUS[result.kind];
230
- const message = ERROR_MESSAGES[result.kind];
231
- res.statusCode = status;
232
- res.setHeader("content-type", "text/html; charset=utf-8");
233
- const onFlushed = () => {
234
- switch (result.kind) {
235
- case "state-mismatch":
236
- finish({
237
- kind: "err",
238
- e: new CallbackStateMismatchError()
239
- });
240
- return;
241
- case "missing-code":
242
- finish({
243
- kind: "err",
244
- e: new CallbackMissingCodeError()
245
- });
246
- return;
247
- case "malformed":
248
- case "not-found": return;
249
- default: ((_) => {})(result);
431
+ const resolved = await resolveOnPath(candidate, env, platform);
432
+ if (resolved) return resolved;
433
+ }
434
+ throw new ChromeNotFoundError(candidates);
435
+ }
436
+ const DEVTOOLS_BANNER = /^DevTools listening on (ws:\/\/[^\s]+)\s*$/m;
437
+ function consumeDevtoolsEndpoint(buffer) {
438
+ const match = DEVTOOLS_BANNER.exec(buffer);
439
+ return match ? match[1] ?? null : null;
440
+ }
441
+ async function launchChrome(options) {
442
+ const executable = options.executable ?? await findChrome();
443
+ const endpointTimeoutMs = options.endpointTimeoutMs ?? 15e3;
444
+ const userDataDir = await mkdtemp(join(tmpdir(), "aitcc-chrome-"));
445
+ const args = [
446
+ "--remote-debugging-port=0",
447
+ `--user-data-dir=${userDataDir}`,
448
+ "--no-first-run",
449
+ "--no-default-browser-check",
450
+ "--disable-features=Translate,OptimizationHints",
451
+ "--password-store=basic",
452
+ "--use-mock-keychain",
453
+ options.initialUrl
454
+ ];
455
+ const spawnFn = options.spawnOverride ?? ((a) => spawn(executable, [...a]));
456
+ let child;
457
+ try {
458
+ child = spawnFn(args);
459
+ } catch (err) {
460
+ await rm(userDataDir, {
461
+ recursive: true,
462
+ force: true
463
+ }).catch(() => {});
464
+ throw new ChromeLaunchError(executable, err);
465
+ }
466
+ try {
467
+ child.unref();
468
+ } catch {}
469
+ const dispose = async () => {
470
+ try {
471
+ if (!child.killed) child.kill("SIGTERM");
472
+ } catch {}
473
+ await rm(userDataDir, {
474
+ recursive: true,
475
+ force: true
476
+ }).catch(() => {});
477
+ };
478
+ let stderrBuf = "";
479
+ const wsUrl = await new Promise((resolve, reject) => {
480
+ const timer = setTimeout(() => {
481
+ cleanup();
482
+ reject(new ChromeEndpointTimeoutError(executable));
483
+ }, endpointTimeoutMs);
484
+ if (typeof timer.unref === "function") timer.unref();
485
+ const onStderr = (chunk) => {
486
+ stderrBuf += chunk.toString("utf8");
487
+ const found = consumeDevtoolsEndpoint(stderrBuf);
488
+ if (found) {
489
+ cleanup();
490
+ resolve(found);
250
491
  }
251
492
  };
252
- res.end(errorHtml(message), onFlushed);
493
+ const onExit = (code) => {
494
+ cleanup();
495
+ reject(new ChromeLaunchError(executable, /* @__PURE__ */ new Error(`process exited with code ${code ?? "null"} before printing endpoint`)));
496
+ };
497
+ const onError = (err) => {
498
+ cleanup();
499
+ reject(new ChromeLaunchError(executable, err));
500
+ };
501
+ const cleanup = () => {
502
+ clearTimeout(timer);
503
+ child.stderr?.off("data", onStderr);
504
+ child.off("exit", onExit);
505
+ child.off("error", onError);
506
+ };
507
+ child.stderr?.on("data", onStderr);
508
+ child.on("exit", onExit);
509
+ child.on("error", onError);
510
+ }).catch(async (err) => {
511
+ await dispose();
512
+ throw err;
253
513
  });
254
- const timer = setTimeout(() => {
255
- finish({
256
- kind: "err",
257
- e: new CallbackTimeoutError(Math.ceil(timeoutMs / 1e3))
258
- });
259
- }, timeoutMs);
260
- if (typeof timer.unref === "function") timer.unref();
261
- const close = async () => {
262
- if (closed) return;
263
- closed = true;
264
- clearTimeout(timer);
265
- await new Promise((resolve) => {
266
- let done = false;
267
- const finishClose = () => {
268
- if (done) return;
269
- done = true;
270
- resolve();
271
- };
272
- server.close(() => finishClose());
273
- server.closeAllConnections?.();
274
- const fallback = setTimeout(finishClose, 1e3);
275
- if (typeof fallback.unref === "function") fallback.unref();
276
- });
277
- };
278
514
  return {
279
- port: boundPort,
280
- redirectUri,
281
- expectedState,
282
- waitForCallback: () => waiter,
283
- close
515
+ process: child,
516
+ webSocketDebuggerUrl: wsUrl,
517
+ userDataDir,
518
+ dispose
284
519
  };
285
520
  }
286
521
  //#endregion
522
+ //#region src/exit.ts
523
+ const ExitCode = {
524
+ Ok: 0,
525
+ Generic: 1,
526
+ Usage: 2,
527
+ NotAuthenticated: 10,
528
+ NetworkError: 11,
529
+ LoginTimeout: 12,
530
+ LoginStateMismatch: 13,
531
+ LoginBrowserNotFound: 14,
532
+ LoginBrowserFailed: 15,
533
+ LoginCookieCaptureFailed: 16,
534
+ ApiError: 17,
535
+ UpgradeUnavailable: 20,
536
+ UpgradeAlreadyLatest: 21
537
+ };
538
+ //#endregion
539
+ //#region src/flush.ts
540
+ async function exitAfterFlush(code) {
541
+ await new Promise((resolve) => process.stdout.write("", () => resolve()));
542
+ process.exit(code);
543
+ }
544
+ //#endregion
287
545
  //#region src/paths.ts
288
- const APP_NAME = "ait-console";
546
+ const APP_NAME = "aitcc";
289
547
  function configDir() {
290
548
  if (process.platform === "win32") {
291
549
  const appData = process.env.APPDATA;
@@ -301,29 +559,38 @@ function sessionFilePath() {
301
559
  }
302
560
  //#endregion
303
561
  //#region src/session.ts
304
- function summarize(session) {
305
- return {
306
- user: session.user,
307
- capturedAt: session.capturedAt
308
- };
309
- }
310
562
  async function readSession() {
563
+ const path = sessionFilePath();
564
+ let raw;
311
565
  try {
312
- const raw = await readFile(sessionFilePath(), "utf8");
313
- const parsed = JSON.parse(raw);
314
- if (parsed.schemaVersion !== 1) return null;
315
- if (!parsed.user || typeof parsed.user.id !== "string") return null;
316
- if (typeof parsed.user.email !== "string") return null;
317
- if (parsed.user.displayName !== void 0 && typeof parsed.user.displayName !== "string") return null;
318
- return parsed;
566
+ raw = await readFile(path, "utf8");
319
567
  } catch (err) {
320
- if (err.code === "ENOENT") return null;
568
+ const code = err.code;
569
+ if (code === "ENOENT") return null;
570
+ process.stderr.write(`warning: could not read session file at ${path}: ${code ?? "unknown"}\n`);
321
571
  return null;
322
572
  }
573
+ let parsed;
574
+ try {
575
+ parsed = JSON.parse(raw);
576
+ } catch {
577
+ process.stderr.write(`warning: session file at ${path} is corrupt and will be ignored\n`);
578
+ return null;
579
+ }
580
+ const schemaReason = validateSessionShape(parsed);
581
+ if (schemaReason) {
582
+ process.stderr.write(`warning: session file at ${path} ignored (${schemaReason}); re-run \`aitcc login\`\n`);
583
+ return null;
584
+ }
585
+ return parsed;
323
586
  }
324
- async function readSessionSummary() {
325
- const s = await readSession();
326
- return s ? summarize(s) : null;
587
+ function validateSessionShape(parsed) {
588
+ if (parsed.schemaVersion !== 1) return `unknown schemaVersion ${String(parsed.schemaVersion)}`;
589
+ if (!parsed.user || typeof parsed.user.id !== "string") return "missing user.id";
590
+ if (typeof parsed.user.email !== "string") return "missing user.email";
591
+ if (parsed.user.displayName !== void 0 && typeof parsed.user.displayName !== "string") return "user.displayName has wrong type";
592
+ if (!Array.isArray(parsed.cookies)) return "cookies is not an array";
593
+ return null;
327
594
  }
328
595
  async function writeSession(session) {
329
596
  await mkdir(dirname(sessionFilePath()), {
@@ -349,46 +616,28 @@ function sessionPathForDiagnostics() {
349
616
  }
350
617
  //#endregion
351
618
  //#region src/commands/login.ts
352
- const MAX_FIELD_LENGTH = 128;
353
- const PENDING_USER_ID = "pending:oauth-discovery";
354
- function sanitizeField(raw) {
355
- if (typeof raw !== "string") return void 0;
356
- if (raw.length === 0) return void 0;
357
- if (raw.length > MAX_FIELD_LENGTH) return void 0;
358
- if (/[\x00-\x1f\x7f]/.test(raw)) return void 0;
359
- return raw;
360
- }
361
- function buildAuthorizeUrl(params) {
362
- const url = new URL(params.authorizeUrl);
363
- url.searchParams.set("response_type", "code");
364
- url.searchParams.set("redirect_uri", params.redirectUri);
365
- url.searchParams.set("state", params.state);
366
- if (params.clientId) url.searchParams.set("client_id", params.clientId);
367
- if (params.scope) url.searchParams.set("scope", params.scope);
368
- return url.toString();
369
- }
370
- function classifyCallbackError(err) {
371
- if (err instanceof CallbackTimeoutError) return {
372
- reason: "timeout",
373
- exitCode: ExitCode.LoginTimeout
374
- };
375
- if (err instanceof CallbackStateMismatchError) return {
376
- reason: "state-mismatch",
377
- exitCode: ExitCode.LoginStateMismatch
378
- };
379
- if (err instanceof CallbackMissingCodeError) return {
380
- reason: "missing-code",
381
- exitCode: ExitCode.Generic
382
- };
383
- return {
384
- reason: "other",
385
- exitCode: ExitCode.Generic
386
- };
619
+ const DEFAULT_AUTHORIZE_URL = "https://business.toss.im/account/sign-in?client_id=4uktpjgqd0cp9txybqzuxc2y6w0cuupb&redirect_uri=https%3A%2F%2Fapps-in-toss.toss.im%2Fsign-up&state=%2Fworkspace";
620
+ const LOGIN_LANDING_HOST = "apps-in-toss.toss.im";
621
+ const LOGIN_LANDING_PATH_PREFIX = "/workspace";
622
+ const ALLOWED_AUTHORIZE_HOST_SUFFIXES = [".toss.im"];
623
+ function isAllowedAuthorizeHost(host) {
624
+ const lower = host.toLowerCase();
625
+ return ALLOWED_AUTHORIZE_HOST_SUFFIXES.some((suffix) => lower === suffix.slice(1) || lower.endsWith(suffix));
626
+ }
627
+ function isLoginLanding(url) {
628
+ try {
629
+ const u = new URL(url);
630
+ if (u.hostname !== LOGIN_LANDING_HOST) return false;
631
+ if (u.pathname !== LOGIN_LANDING_PATH_PREFIX && !u.pathname.startsWith(`${LOGIN_LANDING_PATH_PREFIX}/`)) return false;
632
+ return true;
633
+ } catch {
634
+ return false;
635
+ }
387
636
  }
388
637
  const loginCommand = defineCommand({
389
638
  meta: {
390
639
  name: "login",
391
- description: "Log in via the browser; starts a localhost callback server."
640
+ description: "Open a browser to sign in, then capture the console session cookies."
392
641
  },
393
642
  args: {
394
643
  json: {
@@ -396,22 +645,13 @@ const loginCommand = defineCommand({
396
645
  description: "Emit machine-readable JSON to stdout.",
397
646
  default: false
398
647
  },
399
- "no-browser": {
400
- type: "boolean",
401
- description: "Don't auto-open the browser; print the URL for manual copy.",
402
- default: false
403
- },
404
648
  timeout: {
405
649
  type: "string",
406
- description: "Abort the login if no callback arrives within N seconds (default 300).",
650
+ description: "Abort if login does not complete within N seconds (default 300).",
407
651
  default: "300"
408
652
  }
409
653
  },
410
654
  async run({ args }) {
411
- const rawOauthUrl = process.env.AIT_CONSOLE_OAUTH_URL;
412
- const authorizeUrl = rawOauthUrl && rawOauthUrl.length > 0 ? rawOauthUrl : null;
413
- const clientId = process.env.AIT_CONSOLE_OAUTH_CLIENT_ID;
414
- const scope = process.env.AIT_CONSOLE_OAUTH_SCOPE;
415
655
  const emitError = (payload, human) => {
416
656
  if (args.json) process.stdout.write(`${JSON.stringify({
417
657
  ok: false,
@@ -419,78 +659,130 @@ const loginCommand = defineCommand({
419
659
  })}\n`);
420
660
  process.stderr.write(`${human}\n`);
421
661
  };
422
- const timeoutNum = Number(args.timeout);
423
- if (!Number.isFinite(timeoutNum) || timeoutNum < 1) {
662
+ const timeoutSec = Number(args.timeout);
663
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 1) {
424
664
  emitError({
425
665
  reason: "invalid-timeout",
426
666
  given: args.timeout
427
667
  }, `Invalid --timeout value: ${args.timeout}`);
428
668
  return exitAfterFlush(ExitCode.Usage);
429
669
  }
430
- const timeoutMs = timeoutNum * 1e3;
431
- if (!authorizeUrl) {
432
- emitError({
433
- reason: "oauth-url-not-configured",
434
- hint: "set AIT_CONSOLE_OAUTH_URL"
435
- }, [
436
- "The Toss developer console OAuth URL is not configured.",
437
- "Discovery is pending — set AIT_CONSOLE_OAUTH_URL to override,",
438
- "or track the TODO in CLAUDE.md § \"Open questions\"."
439
- ].join("\n"));
440
- return exitAfterFlush(ExitCode.Usage);
441
- }
442
- const server = await startCallbackServer({ timeoutMs });
443
- const authUrl = buildAuthorizeUrl({
444
- authorizeUrl,
445
- redirectUri: server.redirectUri,
446
- state: server.expectedState,
447
- clientId,
448
- scope
449
- });
450
- process.stderr.write(`Listening for the OAuth callback on ${server.redirectUri}\n`);
451
- let launched = false;
452
- if (!args["no-browser"]) launched = (await openBrowser(authUrl)).launched;
453
- if (launched) process.stderr.write("Opened your browser. Complete the login there.\n");
454
- else process.stderr.write(`Open this URL in your browser to continue:\n ${authUrl}\n`);
455
- let query;
456
- try {
670
+ const timeoutMs = timeoutSec * 1e3;
671
+ const rawAuthorizeUrl = process.env.AITCC_OAUTH_URL;
672
+ const authorizeUrl = rawAuthorizeUrl ?? DEFAULT_AUTHORIZE_URL;
673
+ if (rawAuthorizeUrl) {
674
+ let parsed = null;
457
675
  try {
458
- query = await server.waitForCallback();
459
- } catch (err) {
460
- const { reason, exitCode } = classifyCallbackError(err);
676
+ parsed = new URL(rawAuthorizeUrl);
677
+ } catch {}
678
+ if (!parsed || parsed.protocol !== "https:" && parsed.protocol !== "http:") {
679
+ emitError({ reason: "invalid-authorize-url" }, `AITCC_OAUTH_URL is not a valid http(s) URL: ${rawAuthorizeUrl}`);
680
+ return exitAfterFlush(ExitCode.Usage);
681
+ }
682
+ if (!isAllowedAuthorizeHost(parsed.hostname)) {
461
683
  emitError({
462
- reason,
463
- message: err.message
464
- }, `Login failed: ${err.message}`);
465
- return exitAfterFlush(exitCode);
684
+ reason: "authorize-host-not-allowed",
685
+ host: parsed.hostname
686
+ }, `Refusing to open ${parsed.hostname}: only *.toss.im hosts are allowed for sign-in.`);
687
+ return exitAfterFlush(ExitCode.Usage);
466
688
  }
467
- } finally {
468
- await server.close();
469
- }
470
- const rawUserId = sanitizeField(query.raw.user_id);
471
- const rawEmail = sanitizeField(query.raw.email);
472
- const displayName = sanitizeField(query.raw.display_name);
473
- if (!rawUserId && !rawEmail) {
474
- emitError({ reason: "oauth-identity-missing" }, [
475
- "The callback did not carry user_id or email.",
476
- "This is expected until Toss console OAuth discovery lands.",
477
- "No session was written."
478
- ].join("\n"));
479
- return exitAfterFlush(ExitCode.Generic);
689
+ process.stderr.write(`Using custom authorize URL from AITCC_OAUTH_URL: ${authorizeUrl}\n`);
690
+ }
691
+ const launched = await launchChrome({
692
+ initialUrl: authorizeUrl,
693
+ endpointTimeoutMs: Math.min(6e4, Math.max(3e4, Math.floor(timeoutMs / 2)))
694
+ }).catch((err) => err);
695
+ if (launched instanceof ChromeNotFoundError) {
696
+ emitError({
697
+ reason: "chrome-not-found",
698
+ candidates: launched.candidates
699
+ }, launched.message);
700
+ return exitAfterFlush(ExitCode.LoginBrowserNotFound);
701
+ }
702
+ if (launched instanceof ChromeLaunchError || launched instanceof ChromeEndpointTimeoutError) {
703
+ emitError({
704
+ reason: "chrome-launch-failed",
705
+ message: launched.message
706
+ }, `Failed to launch browser: ${launched.message}`);
707
+ return exitAfterFlush(ExitCode.LoginBrowserFailed);
708
+ }
709
+ if (launched instanceof Error) {
710
+ emitError({
711
+ reason: "chrome-launch-failed",
712
+ errorName: launched.name,
713
+ message: launched.message
714
+ }, `Failed to launch browser (${launched.name}): ${launched.message}`);
715
+ return exitAfterFlush(ExitCode.LoginBrowserFailed);
716
+ }
717
+ process.stderr.write("Opened a browser window — complete the sign-in there. The CLI will capture the session automatically.\n");
718
+ let client = null;
719
+ const disposeAll = async () => {
720
+ if (client) {
721
+ await client.close().catch(() => {});
722
+ client = null;
723
+ }
724
+ await launched.dispose().catch(() => {});
725
+ };
726
+ const exitWith = async (code) => {
727
+ await disposeAll();
728
+ return exitAfterFlush(code);
729
+ };
730
+ try {
731
+ client = await CdpClient.connect({ url: launched.webSocketDebuggerUrl });
732
+ } catch (err) {
733
+ emitError({
734
+ reason: "cdp-connect-failed",
735
+ message: err.message
736
+ }, `Could not connect to the browser over CDP: ${err.message}`);
737
+ return exitWith(ExitCode.LoginBrowserFailed);
738
+ }
739
+ let attached;
740
+ try {
741
+ attached = await attachToFirstPage(client);
742
+ } catch (err) {
743
+ emitError({
744
+ reason: "cdp-attach-failed",
745
+ message: err.message
746
+ }, `Could not attach to the browser tab: ${err.message}`);
747
+ return exitWith(ExitCode.LoginBrowserFailed);
748
+ }
749
+ const landing = await waitForLanding(client, attached.sessionId, timeoutMs);
750
+ if (landing === "timeout") {
751
+ emitError({
752
+ reason: "login-timeout",
753
+ timeoutSec
754
+ }, `Login timed out after ${timeoutSec}s.`);
755
+ return exitWith(ExitCode.LoginTimeout);
756
+ }
757
+ if (landing === "aborted") {
758
+ emitError({ reason: "login-aborted" }, "Login was aborted (browser closed before reaching the console).");
759
+ return exitWith(ExitCode.LoginBrowserFailed);
760
+ }
761
+ const cookies = await getAllCookies(client, attached.sessionId).catch((err) => err);
762
+ if (cookies instanceof Error) {
763
+ emitError({
764
+ reason: "cookie-capture-failed",
765
+ message: cookies.message
766
+ }, `Failed to capture cookies: ${cookies.message}`);
767
+ return exitWith(ExitCode.LoginCookieCaptureFailed);
768
+ }
769
+ const user = await resolveUserWithRetry(cookies, { onRetry: (ms) => process.stderr.write(`Cookies not yet accepted by the console API — retrying in ${ms}ms...\n`) }).catch((err) => err);
770
+ if (user instanceof Error) {
771
+ const authFailed = user instanceof TossApiError && user.isAuthError;
772
+ emitError({
773
+ reason: authFailed ? "login-auth-not-active" : "member-info-failed",
774
+ message: user.message
775
+ }, authFailed ? "Browser session did not produce valid console cookies. Try again and wait for the workspace page to load." : `Failed to read member info: ${user.message}`);
776
+ return exitWith(authFailed ? ExitCode.LoginCookieCaptureFailed : ExitCode.ApiError);
480
777
  }
481
- const userId = rawUserId ?? PENDING_USER_ID;
482
- const email = rawEmail ?? "";
483
778
  const session = {
484
779
  schemaVersion: 1,
485
- user: displayName ? {
486
- id: userId,
487
- email,
488
- displayName
489
- } : {
490
- id: userId,
491
- email
780
+ user: {
781
+ id: String(user.id),
782
+ email: user.email,
783
+ displayName: user.name
492
784
  },
493
- cookies: [],
785
+ cookies,
494
786
  origins: [],
495
787
  capturedAt: (/* @__PURE__ */ new Date()).toISOString()
496
788
  };
@@ -501,21 +793,76 @@ const loginCommand = defineCommand({
501
793
  reason: "session-write-failed",
502
794
  message: err.message
503
795
  }, `Failed to write session file: ${err.message}`);
504
- return exitAfterFlush(ExitCode.Generic);
796
+ return exitWith(ExitCode.Generic);
505
797
  }
506
798
  if (args.json) process.stdout.write(`${JSON.stringify({
507
799
  ok: true,
508
800
  status: "logged-in",
509
801
  user: session.user,
510
- capturedAt: session.capturedAt
802
+ capturedAt: session.capturedAt,
803
+ cookieCount: cookies.length
511
804
  })}\n`);
512
- else {
513
- const label = displayName ? `${displayName} <${email}>` : email || userId;
514
- process.stdout.write(`Logged in as ${label}\n`);
515
- }
516
- return exitAfterFlush(ExitCode.Ok);
805
+ else process.stdout.write(`Logged in as ${user.name} <${user.email}>\n`);
806
+ return exitWith(ExitCode.Ok);
517
807
  }
518
808
  });
809
+ async function waitForLanding(client, sessionId, timeoutMs) {
810
+ return await new Promise((resolve) => {
811
+ let settled = false;
812
+ const stops = [];
813
+ const settle = (outcome) => {
814
+ if (settled) return;
815
+ settled = true;
816
+ clearTimeout(timer);
817
+ clearInterval(pollTimer);
818
+ for (const s of stops) try {
819
+ s();
820
+ } catch {}
821
+ resolve(outcome);
822
+ };
823
+ const timer = setTimeout(() => settle("timeout"), timeoutMs);
824
+ if (typeof timer.unref === "function") timer.unref();
825
+ stops.push(client.on((event) => {
826
+ if (event.method === "Target.targetDestroyed") settle("aborted");
827
+ }));
828
+ watchMainFrameNavigations(client, sessionId, (ev) => {
829
+ if (!ev.isMainFrame) return;
830
+ if (isLoginLanding(ev.url)) settle("ok");
831
+ }).then((off) => {
832
+ if (settled) off();
833
+ else stops.push(off);
834
+ }).catch((err) => {
835
+ if (settled) return;
836
+ process.stderr.write(`Could not watch for navigation: ${err.message}\n`);
837
+ });
838
+ const checkCurrent = async () => {
839
+ if (settled) return;
840
+ const url = (await client.send("Page.getFrameTree", {}, sessionId).catch(() => null))?.frameTree.frame?.url;
841
+ if (url && isLoginLanding(url)) settle("ok");
842
+ };
843
+ checkCurrent();
844
+ const pollTimer = setInterval(() => {
845
+ checkCurrent();
846
+ }, 1e3);
847
+ if (typeof pollTimer.unref === "function") pollTimer.unref();
848
+ });
849
+ }
850
+ async function resolveUserWithRetry(cookies, opts = {}) {
851
+ const callArgs = opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {};
852
+ try {
853
+ return await fetchConsoleMemberUserInfo(cookies, callArgs);
854
+ } catch (err) {
855
+ if (err instanceof TossApiError && err.isAuthError) {
856
+ opts.onRetry?.(750);
857
+ await new Promise((r) => {
858
+ const t = setTimeout(r, 750);
859
+ if (typeof t.unref === "function") t.unref();
860
+ });
861
+ return await fetchConsoleMemberUserInfo(cookies, callArgs);
862
+ }
863
+ throw err;
864
+ }
865
+ }
519
866
  //#endregion
520
867
  //#region src/commands/logout.ts
521
868
  const logoutCommand = defineCommand({
@@ -561,7 +908,7 @@ const REPO_NAME = "console-cli";
561
908
  function defaultHeaders() {
562
909
  const headers = {
563
910
  Accept: "application/vnd.github+json",
564
- "User-Agent": "ait-console",
911
+ "User-Agent": "aitcc",
565
912
  "X-GitHub-Api-Version": "2022-11-28"
566
913
  };
567
914
  const token = process.env.GITHUB_TOKEN;
@@ -609,7 +956,7 @@ function detectPlatform() {
609
956
  return {
610
957
  os,
611
958
  arch,
612
- assetName: `ait-console-${os}-${arch}${os === "windows" ? ".exe" : ""}`
959
+ assetName: `aitcc-${os}-${arch}${os === "windows" ? ".exe" : ""}`
613
960
  };
614
961
  }
615
962
  //#endregion
@@ -640,11 +987,11 @@ function compareSemver(a, b) {
640
987
  //#region src/version.ts
641
988
  function resolveVersion() {
642
989
  try {
643
- const injected = globalThis.AIT_CONSOLE_VERSION;
990
+ const injected = globalThis.AITCC_VERSION;
644
991
  if (typeof injected === "string" && injected.length > 0) return injected;
645
992
  } catch {}
646
993
  try {
647
- return "0.1.2";
994
+ return "0.1.4";
648
995
  } catch {}
649
996
  return "0.0.0-dev";
650
997
  }
@@ -652,7 +999,7 @@ const VERSION = resolveVersion();
652
999
  //#endregion
653
1000
  //#region src/commands/upgrade.ts
654
1001
  function isStandaloneBinary() {
655
- return basename(process.execPath).toLowerCase().startsWith("ait-console");
1002
+ return basename(process.execPath).toLowerCase().startsWith("aitcc");
656
1003
  }
657
1004
  const upgradeCommand = defineCommand({
658
1005
  meta: {
@@ -786,7 +1133,7 @@ const upgradeCommand = defineCommand({
786
1133
  to: latest,
787
1134
  installedAt: exePath,
788
1135
  installedIn: dirname(exePath)
789
- }, `Upgraded ait-console: ${current} → ${latest}`);
1136
+ }, `Upgraded aitcc: ${current} → ${latest}`);
790
1137
  }
791
1138
  });
792
1139
  //#endregion
@@ -794,43 +1141,115 @@ const upgradeCommand = defineCommand({
794
1141
  const whoamiCommand = defineCommand({
795
1142
  meta: {
796
1143
  name: "whoami",
797
- description: "Show the currently authenticated user from the local session."
1144
+ description: "Show the currently authenticated user (live from the console API by default)."
1145
+ },
1146
+ args: {
1147
+ json: {
1148
+ type: "boolean",
1149
+ description: "Emit machine-readable JSON to stdout.",
1150
+ default: false
1151
+ },
1152
+ offline: {
1153
+ type: "boolean",
1154
+ description: "Skip the live API call and read only the cached session summary.",
1155
+ default: false
1156
+ }
798
1157
  },
799
- args: { json: {
800
- type: "boolean",
801
- description: "Emit machine-readable JSON to stdout.",
802
- default: false
803
- } },
804
1158
  async run({ args }) {
805
- const summary = await readSessionSummary();
806
- if (!summary) {
807
- if (args.json) process.stdout.write(`${JSON.stringify({ authenticated: false })}\n`);
1159
+ const session = await readSession();
1160
+ if (!session) {
1161
+ if (args.json) process.stdout.write(`${JSON.stringify({
1162
+ ok: true,
1163
+ authenticated: false
1164
+ })}\n`);
808
1165
  else {
809
- process.stderr.write("Not logged in. Run `ait-console login` to start a session.\n");
1166
+ process.stderr.write("Not logged in. Run `aitcc login` to start a session.\n");
810
1167
  process.stderr.write(`Session file checked: ${sessionPathForDiagnostics()}\n`);
811
1168
  }
812
- process.exit(ExitCode.NotAuthenticated);
1169
+ return exitAfterFlush(ExitCode.NotAuthenticated);
1170
+ }
1171
+ if (args.offline) {
1172
+ if (args.json) {
1173
+ process.stdout.write(`${JSON.stringify({
1174
+ ok: true,
1175
+ authenticated: true,
1176
+ source: "cache",
1177
+ user: session.user,
1178
+ capturedAt: session.capturedAt
1179
+ })}\n`);
1180
+ return exitAfterFlush(ExitCode.Ok);
1181
+ }
1182
+ const label = session.user.displayName ? `${session.user.displayName} <${session.user.email}>` : session.user.email;
1183
+ process.stdout.write(`Logged in as ${label} (cached)\n`);
1184
+ process.stdout.write(`Session captured: ${session.capturedAt}\n`);
1185
+ return exitAfterFlush(ExitCode.Ok);
813
1186
  }
814
- if (args.json) {
815
- process.stdout.write(`${JSON.stringify({
816
- authenticated: true,
817
- user: summary.user,
818
- capturedAt: summary.capturedAt
1187
+ try {
1188
+ const info = await fetchConsoleMemberUserInfo(session.cookies);
1189
+ if (args.json) {
1190
+ process.stdout.write(`${JSON.stringify({
1191
+ ok: true,
1192
+ authenticated: true,
1193
+ source: "live",
1194
+ user: {
1195
+ id: String(info.id),
1196
+ bizUserNo: info.bizUserNo,
1197
+ name: info.name,
1198
+ email: info.email,
1199
+ role: info.role
1200
+ },
1201
+ workspaces: info.workspaces.map((w) => ({
1202
+ workspaceId: w.workspaceId,
1203
+ workspaceName: w.workspaceName,
1204
+ role: w.role
1205
+ })),
1206
+ capturedAt: session.capturedAt
1207
+ })}\n`);
1208
+ return exitAfterFlush(ExitCode.Ok);
1209
+ }
1210
+ process.stdout.write(`Logged in as ${info.name} <${info.email}> (${info.role})\n`);
1211
+ if (info.workspaces.length > 0) {
1212
+ process.stdout.write("Workspaces:\n");
1213
+ for (const w of info.workspaces) process.stdout.write(` - ${w.workspaceName} (id ${w.workspaceId}, ${w.role})\n`);
1214
+ }
1215
+ return exitAfterFlush(ExitCode.Ok);
1216
+ } catch (err) {
1217
+ if (err instanceof TossApiError && err.isAuthError) {
1218
+ if (args.json) process.stdout.write(`${JSON.stringify({
1219
+ ok: true,
1220
+ authenticated: false,
1221
+ reason: "session-expired",
1222
+ errorCode: err.errorCode
1223
+ })}\n`);
1224
+ else process.stderr.write("Session is no longer valid. Run `aitcc login` again.\n");
1225
+ return exitAfterFlush(ExitCode.NotAuthenticated);
1226
+ }
1227
+ if (err instanceof NetworkError) {
1228
+ if (args.json) process.stdout.write(`${JSON.stringify({
1229
+ ok: false,
1230
+ reason: "network-error",
1231
+ message: err.message
1232
+ })}\n`);
1233
+ else process.stderr.write(`Network error reaching the console API: ${err.message}. Use \`aitcc whoami --offline\` for the cached identity.\n`);
1234
+ return exitAfterFlush(ExitCode.NetworkError);
1235
+ }
1236
+ if (args.json) process.stdout.write(`${JSON.stringify({
1237
+ ok: false,
1238
+ reason: "api-error",
1239
+ message: err.message
819
1240
  })}\n`);
820
- return;
1241
+ else process.stderr.write(`Unexpected error: ${err.message}\n`);
1242
+ return exitAfterFlush(ExitCode.ApiError);
821
1243
  }
822
- const label = summary.user.displayName ? `${summary.user.displayName} <${summary.user.email}>` : summary.user.email;
823
- process.stdout.write(`Logged in as ${label}\n`);
824
- process.stdout.write(`Session captured: ${summary.capturedAt}\n`);
825
1244
  }
826
1245
  });
827
1246
  //#endregion
828
1247
  //#region src/cli.ts
829
1248
  runMain(defineCommand({
830
1249
  meta: {
831
- name: "ait-console",
1250
+ name: "aitcc",
832
1251
  version: VERSION,
833
- description: "Community CLI for the Apps in Toss developer console (unofficial; not affiliated with Toss)."
1252
+ description: "aitcc Apps in Toss Community Console CLI. Unofficial, not affiliated with Toss."
834
1253
  },
835
1254
  subCommands: {
836
1255
  whoami: whoamiCommand,