@ait-co/console-cli 0.1.3 → 0.1.5

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,54 +1,191 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from "citty";
3
+ import { chmod, mkdir, mkdtemp, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join, win32 } from "node:path";
5
+ import { homedir, tmpdir } from "node:os";
3
6
  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 });
7
+ import { constants } from "node:fs";
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
+ }
18
+ /** Cookie-based auth rejected — session missing/expired/invalidated. */
19
+ get isAuthError() {
20
+ return this.status === 401 || this.errorCode === "4010";
21
+ }
22
+ };
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;
29
+ }
30
+ };
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";
39
+ }
40
+ };
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) === "/";
68
+ }
69
+ function isSafeCookiePart(s) {
70
+ return !/[\x00-\x1f;\x7f]/.test(s);
71
+ }
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("; ");
97
+ }
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;
12
124
  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 });
125
+ res = await fetchImpl(url, init);
126
+ } catch (err) {
127
+ throw new NetworkError(url.toString(), err);
17
128
  }
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
- }
129
+ let text;
130
+ try {
131
+ text = await res.text();
132
+ } catch (err) {
133
+ throw new MalformedResponseError(url.toString(), res.status, err.message);
134
+ }
135
+ let parsed;
136
+ try {
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/mini-apps.ts
147
+ const BASE$2 = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole";
148
+ async function fetchMiniApps(workspaceId, cookies, opts = {}) {
149
+ const raw = await requestConsoleApi({
150
+ url: `${BASE$2}/workspaces/${workspaceId}/mini-app`,
151
+ cookies,
152
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
51
153
  });
154
+ if (!Array.isArray(raw)) throw new Error(`Unexpected mini-app list shape for workspace=${workspaceId}`);
155
+ return raw.map((item, index) => normalizeMiniApp(item, workspaceId, index));
156
+ }
157
+ function normalizeMiniApp(item, workspaceId, index) {
158
+ if (item === null || typeof item !== "object") throw new Error(`Unexpected mini-app entry at index ${index} for workspace=${workspaceId}: not an object`);
159
+ const rec = item;
160
+ const rawId = rec.id ?? rec.miniAppId ?? rec.appId;
161
+ if (typeof rawId !== "string" && typeof rawId !== "number") throw new Error(`Unexpected mini-app entry at index ${index} for workspace=${workspaceId}: missing id`);
162
+ const rawName = rec.name ?? rec.miniAppName ?? rec.appName;
163
+ const name = typeof rawName === "string" ? rawName : void 0;
164
+ const { id: _id, miniAppId: _mid, appId: _aid, name: _n, miniAppName: _mn, appName: _an, ...extra } = rec;
165
+ return {
166
+ id: rawId,
167
+ name,
168
+ extra
169
+ };
170
+ }
171
+ async function fetchReviewStatus(workspaceId, cookies, opts = {}) {
172
+ const raw = await requestConsoleApi({
173
+ url: `${BASE$2}/workspaces/${workspaceId}/mini-apps/review-status`,
174
+ cookies,
175
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
176
+ });
177
+ if (raw === null || typeof raw !== "object") throw new Error(`Unexpected review-status shape for workspace=${workspaceId}`);
178
+ const rec = raw;
179
+ const hasPolicyViolation = Boolean(rec.hasPolicyViolation);
180
+ const miniAppsRaw = rec.miniApps;
181
+ if (!Array.isArray(miniAppsRaw)) throw new Error(`Unexpected review-status shape for workspace=${workspaceId}: miniApps is not an array`);
182
+ return {
183
+ hasPolicyViolation,
184
+ miniApps: miniAppsRaw.map((m) => {
185
+ if (m === null || typeof m !== "object") return {};
186
+ return m;
187
+ })
188
+ };
52
189
  }
53
190
  //#endregion
54
191
  //#region src/exit.ts
@@ -60,6 +197,10 @@ const ExitCode = {
60
197
  NetworkError: 11,
61
198
  LoginTimeout: 12,
62
199
  LoginStateMismatch: 13,
200
+ LoginBrowserNotFound: 14,
201
+ LoginBrowserFailed: 15,
202
+ LoginCookieCaptureFailed: 16,
203
+ ApiError: 17,
63
204
  UpgradeUnavailable: 20,
64
205
  UpgradeAlreadyLatest: 21
65
206
  };
@@ -70,222 +211,8 @@ async function exitAfterFlush(code) {
70
211
  process.exit(code);
71
212
  }
72
213
  //#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";
78
- }
79
- };
80
- var CallbackStateMismatchError = class extends Error {
81
- constructor() {
82
- super("Invalid or missing state parameter");
83
- this.name = "CallbackStateMismatchError";
84
- }
85
- };
86
- var CallbackMissingCodeError = class extends Error {
87
- constructor() {
88
- super("Missing code parameter");
89
- this.name = "CallbackMissingCodeError";
90
- }
91
- };
92
- function randomState() {
93
- return randomBytes(32).toString("base64url");
94
- }
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);
100
- }
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;");
103
- }
104
- function parseCallbackUrl(reqUrl, expectedState) {
105
- if (!reqUrl) return { kind: "malformed" };
106
- let parsed;
107
- 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
- };
127
- }
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"
133
- };
134
- const ERROR_STATUS = {
135
- "state-mismatch": 400,
136
- "missing-code": 400,
137
- malformed: 400,
138
- "not-found": 404
139
- };
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);
175
- };
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);
182
- });
183
- });
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;
204
- });
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);
211
- };
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;
218
- }
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;
228
- }
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);
250
- }
251
- };
252
- res.end(errorHtml(message), onFlushed);
253
- });
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
- return {
279
- port: boundPort,
280
- redirectUri,
281
- expectedState,
282
- waitForCallback: () => waiter,
283
- close
284
- };
285
- }
286
- //#endregion
287
214
  //#region src/paths.ts
288
- const APP_NAME = "ait-console";
215
+ const APP_NAME = "aitcc";
289
216
  function configDir() {
290
217
  if (process.platform === "win32") {
291
218
  const appData = process.env.APPDATA;
@@ -299,31 +226,91 @@ function configDir() {
299
226
  function sessionFilePath() {
300
227
  return join(configDir(), "session.json");
301
228
  }
229
+ function cacheDir() {
230
+ if (process.platform === "win32") {
231
+ const localAppData = process.env.LOCALAPPDATA;
232
+ if (localAppData && localAppData.length > 0) return join(localAppData, APP_NAME, "Cache");
233
+ return join(homedir() || ".", "AppData", "Local", APP_NAME, "Cache");
234
+ }
235
+ const xdg = process.env.XDG_CACHE_HOME;
236
+ if (xdg && xdg.length > 0) return join(xdg, APP_NAME);
237
+ return join(homedir() || ".", ".cache", APP_NAME);
238
+ }
239
+ function upgradeCheckPath() {
240
+ return join(cacheDir(), "upgrade-check.json");
241
+ }
302
242
  //#endregion
303
243
  //#region src/session.ts
304
- function summarize(session) {
305
- return {
306
- user: session.user,
307
- capturedAt: session.capturedAt
308
- };
309
- }
244
+ /**
245
+ * Read the persisted session. Returns `null` when no session exists, when
246
+ * the file is corrupt, or when the shape fails validation — each of those
247
+ * emits a one-line warning on stderr for diagnostics.
248
+ *
249
+ * **Side effect**: a v1 session file is transparently rewritten to v2 on
250
+ * the first successful read of this process. This keeps read-only callers
251
+ * (`whoami`, `workspace ls`) from stranding users on an old schema. If the
252
+ * rewrite fails, we warn once per process and continue with the in-memory
253
+ * v2 value so the calling command still succeeds.
254
+ */
310
255
  async function readSession() {
256
+ const path = sessionFilePath();
257
+ let raw;
311
258
  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;
259
+ raw = await readFile(path, "utf8");
319
260
  } catch (err) {
320
- if (err.code === "ENOENT") return null;
261
+ const code = err.code;
262
+ if (code === "ENOENT") return null;
263
+ process.stderr.write(`warning: could not read session file at ${path}: ${code ?? "unknown"}\n`);
321
264
  return null;
322
265
  }
266
+ let rawParsed;
267
+ try {
268
+ rawParsed = JSON.parse(raw);
269
+ } catch {
270
+ process.stderr.write(`warning: session file at ${path} is corrupt and will be ignored\n`);
271
+ return null;
272
+ }
273
+ const schemaReason = validateSessionShape(rawParsed);
274
+ if (schemaReason) {
275
+ process.stderr.write(`warning: session file at ${path} ignored (${schemaReason}); re-run \`aitcc login\`\n`);
276
+ return null;
277
+ }
278
+ const validated = rawParsed;
279
+ if (validated.schemaVersion === 1) {
280
+ const upgraded = {
281
+ ...validated,
282
+ schemaVersion: 2
283
+ };
284
+ try {
285
+ await writeSession(upgraded);
286
+ } catch (err) {
287
+ warnMigrationOnce(path, err.code);
288
+ }
289
+ return upgraded;
290
+ }
291
+ return validated;
323
292
  }
324
- async function readSessionSummary() {
325
- const s = await readSession();
326
- return s ? summarize(s) : null;
293
+ let migrationWarned = false;
294
+ function warnMigrationOnce(path, code) {
295
+ if (migrationWarned) return;
296
+ migrationWarned = true;
297
+ process.stderr.write(`warning: could not migrate session file at ${path} to schemaVersion 2: ${code ?? "unknown"}\n`);
298
+ }
299
+ function validateSessionShape(input) {
300
+ if (input === null || typeof input !== "object") return "root is not an object";
301
+ const parsed = input;
302
+ if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) return `unknown schemaVersion ${String(parsed.schemaVersion)}`;
303
+ if (!parsed.user || typeof parsed.user.id !== "string") return "missing user.id";
304
+ if (typeof parsed.user.email !== "string") return "missing user.email";
305
+ if (parsed.user.displayName !== void 0 && typeof parsed.user.displayName !== "string") return "user.displayName has wrong type";
306
+ if (!Array.isArray(parsed.cookies)) return "cookies is not an array";
307
+ if (parsed.origins !== void 0 && !Array.isArray(parsed.origins)) return "origins is not an array";
308
+ if (parsed.capturedAt !== void 0 && typeof parsed.capturedAt !== "string") return "capturedAt has wrong type";
309
+ if (parsed.currentWorkspaceId !== void 0) {
310
+ const wid = parsed.currentWorkspaceId;
311
+ if (typeof wid !== "number" || !Number.isInteger(wid) || wid <= 0) return "currentWorkspaceId has wrong type";
312
+ }
313
+ return null;
327
314
  }
328
315
  async function writeSession(session) {
329
316
  await mkdir(dirname(sessionFilePath()), {
@@ -335,6 +322,21 @@ async function writeSession(session) {
335
322
  await chmod(sessionFilePath(), 384);
336
323
  } catch {}
337
324
  }
325
+ /**
326
+ * Persist a new `currentWorkspaceId` on an existing session. Returns the
327
+ * updated session, or `null` if there is no session to update (callers
328
+ * should surface "not logged in" in that case).
329
+ */
330
+ async function setCurrentWorkspaceId(workspaceId) {
331
+ const session = await readSession();
332
+ if (!session) return null;
333
+ const updated = {
334
+ ...session,
335
+ currentWorkspaceId: workspaceId
336
+ };
337
+ await writeSession(updated);
338
+ return updated;
339
+ }
338
340
  async function clearSession() {
339
341
  try {
340
342
  await unlink(sessionFilePath());
@@ -348,47 +350,675 @@ function sessionPathForDiagnostics() {
348
350
  return sessionFilePath();
349
351
  }
350
352
  //#endregion
351
- //#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
353
+ //#region src/commands/_shared.ts
354
+ function emitJson(payload) {
355
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
356
+ }
357
+ function emitNotAuthenticated(json, reason) {
358
+ if (json) emitJson(reason ? {
359
+ ok: true,
360
+ authenticated: false,
361
+ reason
362
+ } : {
363
+ ok: true,
364
+ authenticated: false
365
+ });
366
+ else {
367
+ process.stderr.write(reason === "session-expired" ? "Session is no longer valid. Run `aitcc login` again.\n" : "Not logged in. Run `aitcc login` to start a session.\n");
368
+ process.stderr.write(`Session file checked: ${sessionPathForDiagnostics()}\n`);
369
+ }
370
+ }
371
+ function emitNetworkError(json, message) {
372
+ if (json) emitJson({
373
+ ok: false,
374
+ reason: "network-error",
375
+ message
376
+ });
377
+ else process.stderr.write(`Network error reaching the console API: ${message}.\n`);
378
+ }
379
+ function emitApiError(json, message) {
380
+ if (json) emitJson({
381
+ ok: false,
382
+ reason: "api-error",
383
+ message
384
+ });
385
+ else process.stderr.write(`Unexpected error: ${message}\n`);
386
+ }
387
+ function parsePositiveInt(raw) {
388
+ if (!/^[1-9]\d*$/.test(raw)) return null;
389
+ const n = Number.parseInt(raw, 10);
390
+ return Number.isSafeInteger(n) ? n : null;
391
+ }
392
+ /**
393
+ * Boilerplate wrapper for any workspace-scoped command (`app ls`,
394
+ * `members ls`, `keys ls`, ...). Loads the session, resolves the workspace
395
+ * id from `--workspace <id>` or the persisted selection, and handles the
396
+ * three common failure branches (`no session`, `invalid id`, `no workspace
397
+ * selected`). On success, the caller gets the session + resolved id back.
398
+ *
399
+ * The return type is `Promise<... | null>` but the `null` branch is never
400
+ * observed at runtime: every failure path `await`s `exitAfterFlush` which
401
+ * calls `process.exit(...)` and doesn't return. The `| null` is a type-
402
+ * level handshake that forces callers to add `if (!ctx) return;`, keeping
403
+ * the bail-out readable.
404
+ */
405
+ async function resolveWorkspaceContext(args) {
406
+ const session = await readSession();
407
+ if (!session) {
408
+ emitNotAuthenticated(args.json);
409
+ await exitAfterFlush(ExitCode.NotAuthenticated);
410
+ return null;
411
+ }
412
+ let workspaceId;
413
+ if (args.workspace) {
414
+ const raw = String(args.workspace);
415
+ const parsed = parsePositiveInt(raw);
416
+ if (parsed === null) {
417
+ const message = `--workspace must be a positive integer (got ${raw})`;
418
+ if (args.json) emitJson({
419
+ ok: false,
420
+ reason: "invalid-id",
421
+ message
422
+ });
423
+ else process.stderr.write(`${message}\n`);
424
+ await exitAfterFlush(ExitCode.Usage);
425
+ return null;
426
+ }
427
+ workspaceId = parsed;
428
+ } else workspaceId = session.currentWorkspaceId;
429
+ if (workspaceId === void 0) {
430
+ if (args.json) emitJson({
431
+ ok: false,
432
+ reason: "no-workspace-selected"
433
+ });
434
+ else process.stderr.write("No workspace selected. Pass `--workspace <id>` or run `aitcc workspace use <id>`.\n");
435
+ await exitAfterFlush(ExitCode.Usage);
436
+ return null;
437
+ }
438
+ return {
439
+ session,
440
+ workspaceId
441
+ };
442
+ }
443
+ //#endregion
444
+ //#region src/commands/app.ts
445
+ function findReviewEntry(reviewEntries, appId) {
446
+ const target = String(appId);
447
+ for (const entry of reviewEntries) {
448
+ const candidate = entry.id ?? entry.miniAppId ?? entry.appId;
449
+ if (candidate !== void 0 && String(candidate) === target) return entry;
450
+ }
451
+ return null;
452
+ }
453
+ function reviewStateFor(entry) {
454
+ if (!entry) return void 0;
455
+ const raw = entry.reviewState ?? entry.status;
456
+ return typeof raw === "string" ? raw : void 0;
457
+ }
458
+ const appCommand = defineCommand({
459
+ meta: {
460
+ name: "app",
461
+ description: "Inspect mini-apps in a workspace."
462
+ },
463
+ subCommands: { ls: defineCommand({
464
+ meta: {
465
+ name: "ls",
466
+ description: "List mini-apps in the selected workspace."
467
+ },
468
+ args: {
469
+ workspace: {
470
+ type: "string",
471
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
472
+ },
473
+ json: {
474
+ type: "boolean",
475
+ description: "Emit machine-readable JSON to stdout.",
476
+ default: false
477
+ }
478
+ },
479
+ async run({ args }) {
480
+ const ctx = await resolveWorkspaceContext(args);
481
+ if (!ctx) return;
482
+ const { session, workspaceId } = ctx;
483
+ try {
484
+ const [apps, review] = await Promise.all([fetchMiniApps(workspaceId, session.cookies), fetchReviewStatus(workspaceId, session.cookies)]);
485
+ if (args.json) {
486
+ const joined = apps.map((app) => {
487
+ const reviewState = reviewStateFor(findReviewEntry(review.miniApps, app.id));
488
+ return {
489
+ id: app.id,
490
+ name: app.name ?? null,
491
+ ...reviewState !== void 0 ? { reviewState } : {},
492
+ extra: app.extra
493
+ };
494
+ });
495
+ emitJson({
496
+ ok: true,
497
+ workspaceId,
498
+ hasPolicyViolation: review.hasPolicyViolation,
499
+ apps: joined
500
+ });
501
+ return exitAfterFlush(ExitCode.Ok);
502
+ }
503
+ if (apps.length === 0) {
504
+ process.stdout.write(`No apps in workspace ${workspaceId}.\n`);
505
+ if (review.hasPolicyViolation) process.stderr.write("Note: workspace-wide policy violation flag is set.\n");
506
+ return exitAfterFlush(ExitCode.Ok);
507
+ }
508
+ for (const app of apps) {
509
+ const reviewState = reviewStateFor(findReviewEntry(review.miniApps, app.id)) ?? "-";
510
+ const name = app.name ?? "(unnamed)";
511
+ process.stdout.write(`${app.id}\t${name}\t${reviewState}\n`);
512
+ }
513
+ if (review.hasPolicyViolation) process.stderr.write("Note: workspace-wide policy violation flag is set.\n");
514
+ return exitAfterFlush(ExitCode.Ok);
515
+ } catch (err) {
516
+ if (err instanceof TossApiError && err.isAuthError) {
517
+ emitNotAuthenticated(args.json, "session-expired");
518
+ return exitAfterFlush(ExitCode.NotAuthenticated);
519
+ }
520
+ if (err instanceof NetworkError) {
521
+ emitNetworkError(args.json, err.message);
522
+ return exitAfterFlush(ExitCode.NetworkError);
523
+ }
524
+ emitApiError(args.json, err.message);
525
+ return exitAfterFlush(ExitCode.ApiError);
526
+ }
527
+ }
528
+ }) }
529
+ });
530
+ //#endregion
531
+ //#region src/api/api-keys.ts
532
+ const BASE$1 = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole";
533
+ async function fetchApiKeys(workspaceId, cookies, opts = {}) {
534
+ const raw = await requestConsoleApi({
535
+ url: `${BASE$1}/workspaces/${workspaceId}/api-keys`,
536
+ cookies,
537
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
538
+ });
539
+ if (!Array.isArray(raw)) throw new Error(`Unexpected api-keys shape for workspace=${workspaceId}: not an array`);
540
+ return raw.map((entry, index) => normalizeKey(entry, workspaceId, index));
541
+ }
542
+ function normalizeKey(raw, workspaceId, index) {
543
+ if (raw === null || typeof raw !== "object") throw new Error(`Unexpected api-key entry at index ${index} for workspace=${workspaceId}: not an object`);
544
+ const rec = raw;
545
+ const rawId = rec.id ?? rec.apiKeyId ?? rec.keyId;
546
+ if (typeof rawId !== "string" && typeof rawId !== "number") throw new Error(`Unexpected api-key entry at index ${index} for workspace=${workspaceId}: missing id`);
547
+ const rawName = rec.name ?? rec.apiKeyName ?? rec.keyName ?? rec.description;
548
+ const name = typeof rawName === "string" ? rawName : void 0;
549
+ const { id: _id, apiKeyId: _aid, keyId: _kid, name: _n, apiKeyName: _an, keyName: _kn, description: _d, ...extra } = rec;
550
+ return {
551
+ id: rawId,
552
+ name,
553
+ extra
554
+ };
555
+ }
556
+ const keysCommand = defineCommand({
557
+ meta: {
558
+ name: "keys",
559
+ description: "Inspect console API keys used for deploy automation."
560
+ },
561
+ subCommands: { ls: defineCommand({
562
+ meta: {
563
+ name: "ls",
564
+ description: "List console API keys in the selected workspace."
565
+ },
566
+ args: {
567
+ workspace: {
568
+ type: "string",
569
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
570
+ },
571
+ json: {
572
+ type: "boolean",
573
+ description: "Emit machine-readable JSON to stdout.",
574
+ default: false
575
+ }
576
+ },
577
+ async run({ args }) {
578
+ const ctx = await resolveWorkspaceContext(args);
579
+ if (!ctx) return;
580
+ const { session, workspaceId } = ctx;
581
+ try {
582
+ const keys = await fetchApiKeys(workspaceId, session.cookies);
583
+ if (args.json) {
584
+ emitJson({
585
+ ok: true,
586
+ workspaceId,
587
+ keys: keys.map((k) => ({
588
+ id: k.id,
589
+ name: k.name ?? null,
590
+ extra: k.extra
591
+ })),
592
+ ...keys.length === 0 ? { needsKey: true } : {}
593
+ });
594
+ return exitAfterFlush(ExitCode.Ok);
595
+ }
596
+ if (keys.length === 0) {
597
+ process.stdout.write(`No API keys in workspace ${workspaceId}.\n`);
598
+ process.stderr.write("Hint: issue a key from the console UI (API 키 → 발급받기) to enable deploy automation.\n");
599
+ return exitAfterFlush(ExitCode.Ok);
600
+ }
601
+ process.stdout.write(`${keys.length} API key(s) in workspace ${workspaceId}:\n`);
602
+ for (const k of keys) {
603
+ const name = k.name ?? "(unnamed)";
604
+ process.stdout.write(`${k.id}\t${name}\n`);
605
+ }
606
+ return exitAfterFlush(ExitCode.Ok);
607
+ } catch (err) {
608
+ if (err instanceof TossApiError && err.isAuthError) {
609
+ emitNotAuthenticated(args.json, "session-expired");
610
+ return exitAfterFlush(ExitCode.NotAuthenticated);
611
+ }
612
+ if (err instanceof NetworkError) {
613
+ emitNetworkError(args.json, err.message);
614
+ return exitAfterFlush(ExitCode.NetworkError);
615
+ }
616
+ emitApiError(args.json, err.message);
617
+ return exitAfterFlush(ExitCode.ApiError);
618
+ }
619
+ }
620
+ }) }
621
+ });
622
+ //#endregion
623
+ //#region src/api/me.ts
624
+ const MEMBER_USER_INFO_URL = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole/members/me/user-info";
625
+ async function fetchConsoleMemberUserInfo(cookies, opts = {}) {
626
+ return requestConsoleApi({
627
+ url: MEMBER_USER_INFO_URL,
628
+ cookies,
629
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
630
+ });
631
+ }
632
+ //#endregion
633
+ //#region src/cdp.ts
634
+ function isResponse(m) {
635
+ return "id" in m;
636
+ }
637
+ var CdpProtocolError = class extends Error {
638
+ constructor(method, code, message) {
639
+ super(`CDP error for ${method}: ${message} (code=${code})`);
640
+ this.method = method;
641
+ this.code = code;
642
+ this.name = "CdpProtocolError";
643
+ }
644
+ };
645
+ var CdpConnectionClosedError = class extends Error {
646
+ constructor() {
647
+ super("CDP connection closed before the response arrived.");
648
+ this.name = "CdpConnectionClosedError";
649
+ }
650
+ };
651
+ var CdpClient = class CdpClient {
652
+ socket;
653
+ nextId = 1;
654
+ pending = /* @__PURE__ */ new Map();
655
+ listeners = /* @__PURE__ */ new Set();
656
+ closed = false;
657
+ constructor(socket) {
658
+ this.socket = socket;
659
+ socket.addEventListener("message", (ev) => this.handleMessage(ev));
660
+ socket.addEventListener("close", () => this.handleClose());
661
+ socket.addEventListener("error", () => {});
662
+ }
663
+ static async connect(options) {
664
+ const socket = (options.webSocketFactory ?? ((url) => new WebSocket(url)))(options.url);
665
+ await new Promise((resolve, reject) => {
666
+ const onOpen = () => {
667
+ cleanup();
668
+ resolve();
669
+ };
670
+ const onError = () => {
671
+ cleanup();
672
+ reject(/* @__PURE__ */ new Error(`Failed to open CDP WebSocket at ${options.url}`));
673
+ };
674
+ const onClose = () => {
675
+ cleanup();
676
+ reject(/* @__PURE__ */ new Error(`CDP WebSocket closed before opening (${options.url})`));
677
+ };
678
+ const cleanup = () => {
679
+ socket.removeEventListener("open", onOpen);
680
+ socket.removeEventListener("error", onError);
681
+ socket.removeEventListener("close", onClose);
682
+ };
683
+ socket.addEventListener("open", onOpen);
684
+ socket.addEventListener("error", onError);
685
+ socket.addEventListener("close", onClose);
686
+ });
687
+ return new CdpClient(socket);
688
+ }
689
+ on(listener) {
690
+ this.listeners.add(listener);
691
+ return () => this.listeners.delete(listener);
692
+ }
693
+ async send(method, params, sessionId) {
694
+ if (this.closed) throw new CdpConnectionClosedError();
695
+ const id = this.nextId++;
696
+ const req = {
697
+ id,
698
+ method
699
+ };
700
+ if (params) req.params = params;
701
+ if (sessionId) req.sessionId = sessionId;
702
+ const waiter = new Promise((resolve, reject) => {
703
+ this.pending.set(id, {
704
+ resolve,
705
+ reject,
706
+ method
707
+ });
708
+ });
709
+ this.socket.send(JSON.stringify(req));
710
+ return await waiter;
711
+ }
712
+ async close() {
713
+ if (this.closed) return;
714
+ this.closed = true;
715
+ for (const [, pending] of this.pending) pending.reject(new CdpConnectionClosedError());
716
+ this.pending.clear();
717
+ try {
718
+ this.socket.close();
719
+ } catch {}
720
+ }
721
+ handleMessage(ev) {
722
+ let parsed;
723
+ try {
724
+ const raw = typeof ev.data === "string" ? ev.data : new TextDecoder().decode(ev.data);
725
+ parsed = JSON.parse(raw);
726
+ } catch {
727
+ return;
728
+ }
729
+ if (isResponse(parsed)) {
730
+ const pending = this.pending.get(parsed.id);
731
+ if (!pending) return;
732
+ this.pending.delete(parsed.id);
733
+ if ("error" in parsed) pending.reject(new CdpProtocolError(pending.method, parsed.error.code, parsed.error.message));
734
+ else pending.resolve(parsed.result);
735
+ return;
736
+ }
737
+ for (const listener of this.listeners) try {
738
+ listener(parsed);
739
+ } catch {}
740
+ }
741
+ handleClose() {
742
+ if (this.closed) return;
743
+ this.closed = true;
744
+ for (const [, pending] of this.pending) pending.reject(new CdpConnectionClosedError());
745
+ this.pending.clear();
746
+ }
747
+ };
748
+ /**
749
+ * Attach to the first "page" target exposed by the browser. Chrome always
750
+ * opens at least one page target when launched with an initial URL, so this
751
+ * is a reliable way to grab a session without guessing target IDs.
752
+ */
753
+ async function attachToFirstPage(client) {
754
+ const { targetInfos } = await client.send("Target.getTargets");
755
+ const page = targetInfos.find((t) => t.type === "page");
756
+ if (!page) throw new Error("No page target found; Chrome launched without an initial tab.");
757
+ const { sessionId } = await client.send("Target.attachToTarget", {
758
+ targetId: page.targetId,
759
+ flatten: true
760
+ });
761
+ return {
762
+ sessionId,
763
+ targetId: page.targetId
764
+ };
765
+ }
766
+ /**
767
+ * Subscribe to main-frame navigations on the attached page session. Returns
768
+ * an unsubscribe function.
769
+ *
770
+ * Chrome emits `Page.frameNavigated` for every frame — we filter to the main
771
+ * frame (top-level document) since auxiliary iframes (analytics, chat
772
+ * widgets) would otherwise trigger false matches.
773
+ */
774
+ async function watchMainFrameNavigations(client, sessionId, onNavigate) {
775
+ await client.send("Page.enable", {}, sessionId);
776
+ return client.on((event) => {
777
+ if (event.sessionId !== sessionId) return;
778
+ if (event.method !== "Page.frameNavigated") return;
779
+ const frame = event.params.frame;
780
+ if (!frame?.url || !frame.id) return;
781
+ onNavigate({
782
+ url: frame.url,
783
+ frameId: frame.id,
784
+ isMainFrame: frame.parentId === void 0
785
+ });
786
+ });
787
+ }
788
+ /**
789
+ * `Network.getAllCookies` is scoped to a target session — Chrome rejects it
790
+ * on the browser-level endpoint with `method not found`. Requiring sessionId
791
+ * here surfaces that constraint at compile time.
792
+ *
793
+ * The response shape is fixed in the CDP spec, but we still validate every
794
+ * cookie's required string/number fields at runtime so a malformed entry
795
+ * (from a future Chrome change, say) fails loud instead of propagating
796
+ * `undefined` into the Cookie: header or the on-disk session file.
797
+ */
798
+ async function getAllCookies(client, sessionId) {
799
+ const result = await client.send("Network.getAllCookies", {}, sessionId);
800
+ if (!Array.isArray(result.cookies)) throw new Error("Network.getAllCookies returned a non-array payload");
801
+ return result.cookies.map((raw, index) => validateCookie(raw, index));
802
+ }
803
+ function validateCookie(raw, index) {
804
+ if (!raw || typeof raw !== "object") throw new Error(`Cookie #${index} is not an object`);
805
+ const c = raw;
806
+ const str = (field) => {
807
+ const v = c[field];
808
+ if (typeof v !== "string") throw new Error(`Cookie #${index}.${field} is not a string`);
809
+ return v;
374
810
  };
375
- if (err instanceof CallbackStateMismatchError) return {
376
- reason: "state-mismatch",
377
- exitCode: ExitCode.LoginStateMismatch
811
+ const num = (field) => {
812
+ const v = c[field];
813
+ if (typeof v !== "number") throw new Error(`Cookie #${index}.${field} is not a number`);
814
+ return v;
378
815
  };
379
- if (err instanceof CallbackMissingCodeError) return {
380
- reason: "missing-code",
381
- exitCode: ExitCode.Generic
816
+ const bool = (field) => {
817
+ const v = c[field];
818
+ if (typeof v !== "boolean") throw new Error(`Cookie #${index}.${field} is not a boolean`);
819
+ return v;
382
820
  };
821
+ const base = {
822
+ name: str("name"),
823
+ value: str("value"),
824
+ domain: str("domain"),
825
+ path: str("path"),
826
+ expires: num("expires"),
827
+ httpOnly: bool("httpOnly"),
828
+ secure: bool("secure"),
829
+ session: bool("session")
830
+ };
831
+ const sameSite = c.sameSite;
832
+ if (sameSite === "Strict" || sameSite === "Lax" || sameSite === "None") return {
833
+ ...base,
834
+ sameSite
835
+ };
836
+ return base;
837
+ }
838
+ //#endregion
839
+ //#region src/chrome.ts
840
+ var ChromeNotFoundError = class extends Error {
841
+ constructor(candidates) {
842
+ super(`Could not find Chrome or a Chromium-family browser. Tried: ${candidates.join(", ")}.\nInstall Chrome, or set AITCC_BROWSER to an executable path.`);
843
+ this.candidates = candidates;
844
+ this.name = "ChromeNotFoundError";
845
+ }
846
+ };
847
+ var ChromeLaunchError = class extends Error {
848
+ constructor(executable, cause) {
849
+ super(`Failed to launch ${executable}: ${cause.message}`);
850
+ this.executable = executable;
851
+ this.name = "ChromeLaunchError";
852
+ this.cause = cause;
853
+ }
854
+ };
855
+ var ChromeEndpointTimeoutError = class extends Error {
856
+ constructor(executable) {
857
+ 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.`);
858
+ this.executable = executable;
859
+ this.name = "ChromeEndpointTimeoutError";
860
+ }
861
+ };
862
+ function chromeCandidates(env = process.env, platform = process.platform) {
863
+ const override = env.AITCC_BROWSER;
864
+ const out = [];
865
+ if (override && override.length > 0) out.push(override);
866
+ 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");
867
+ else if (platform === "win32") {
868
+ const pf = env.PROGRAMFILES ?? "C:\\Program Files";
869
+ const pf86 = env["PROGRAMFILES(X86)"] ?? "C:\\Program Files (x86)";
870
+ const local = env.LOCALAPPDATA ?? win32.join(homedir() || "C:\\", "AppData", "Local");
871
+ 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"));
872
+ } else out.push("google-chrome-stable", "google-chrome", "chromium-browser", "chromium", "microsoft-edge-stable", "microsoft-edge");
873
+ return { candidates: out };
874
+ }
875
+ function isAbsolutePath(p, platform) {
876
+ if (platform === "win32") return /^[A-Za-z]:\\/.test(p);
877
+ return p.startsWith("/");
878
+ }
879
+ async function resolveOnPath(name, env, platform) {
880
+ const path = env.PATH ?? env.Path ?? env.path ?? "";
881
+ if (path.length === 0) return null;
882
+ const sep = platform === "win32" ? ";" : ":";
883
+ const fs = await import("node:fs/promises");
884
+ const extensions = platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter((e) => e.length > 0)] : [""];
885
+ for (const dir of path.split(sep)) {
886
+ if (dir.length === 0) continue;
887
+ for (const ext of extensions) {
888
+ const candidate = join(dir, name + ext);
889
+ try {
890
+ await fs.access(candidate, constants.X_OK);
891
+ return candidate;
892
+ } catch {}
893
+ }
894
+ }
895
+ return null;
896
+ }
897
+ async function findChrome(env = process.env, platform = process.platform) {
898
+ const { candidates } = chromeCandidates(env, platform);
899
+ const fs = await import("node:fs/promises");
900
+ for (const candidate of candidates) {
901
+ if (isAbsolutePath(candidate, platform)) {
902
+ try {
903
+ await fs.access(candidate, constants.X_OK);
904
+ return candidate;
905
+ } catch {}
906
+ continue;
907
+ }
908
+ const resolved = await resolveOnPath(candidate, env, platform);
909
+ if (resolved) return resolved;
910
+ }
911
+ throw new ChromeNotFoundError(candidates);
912
+ }
913
+ const DEVTOOLS_BANNER = /^DevTools listening on (ws:\/\/[^\s]+)\s*$/m;
914
+ function consumeDevtoolsEndpoint(buffer) {
915
+ const match = DEVTOOLS_BANNER.exec(buffer);
916
+ return match ? match[1] ?? null : null;
917
+ }
918
+ async function launchChrome(options) {
919
+ const executable = options.executable ?? await findChrome();
920
+ const endpointTimeoutMs = options.endpointTimeoutMs ?? 15e3;
921
+ const userDataDir = await mkdtemp(join(tmpdir(), "aitcc-chrome-"));
922
+ const args = [
923
+ "--remote-debugging-port=0",
924
+ `--user-data-dir=${userDataDir}`,
925
+ "--no-first-run",
926
+ "--no-default-browser-check",
927
+ "--disable-features=Translate,OptimizationHints",
928
+ "--password-store=basic",
929
+ "--use-mock-keychain",
930
+ options.initialUrl
931
+ ];
932
+ const spawnFn = options.spawnOverride ?? ((a) => spawn(executable, [...a]));
933
+ let child;
934
+ try {
935
+ child = spawnFn(args);
936
+ } catch (err) {
937
+ await rm(userDataDir, {
938
+ recursive: true,
939
+ force: true
940
+ }).catch(() => {});
941
+ throw new ChromeLaunchError(executable, err);
942
+ }
943
+ try {
944
+ child.unref();
945
+ } catch {}
946
+ const dispose = async () => {
947
+ try {
948
+ if (!child.killed) child.kill("SIGTERM");
949
+ } catch {}
950
+ await rm(userDataDir, {
951
+ recursive: true,
952
+ force: true
953
+ }).catch(() => {});
954
+ };
955
+ let stderrBuf = "";
956
+ const wsUrl = await new Promise((resolve, reject) => {
957
+ const timer = setTimeout(() => {
958
+ cleanup();
959
+ reject(new ChromeEndpointTimeoutError(executable));
960
+ }, endpointTimeoutMs);
961
+ if (typeof timer.unref === "function") timer.unref();
962
+ const onStderr = (chunk) => {
963
+ stderrBuf += chunk.toString("utf8");
964
+ const found = consumeDevtoolsEndpoint(stderrBuf);
965
+ if (found) {
966
+ cleanup();
967
+ resolve(found);
968
+ }
969
+ };
970
+ const onExit = (code) => {
971
+ cleanup();
972
+ reject(new ChromeLaunchError(executable, /* @__PURE__ */ new Error(`process exited with code ${code ?? "null"} before printing endpoint`)));
973
+ };
974
+ const onError = (err) => {
975
+ cleanup();
976
+ reject(new ChromeLaunchError(executable, err));
977
+ };
978
+ const cleanup = () => {
979
+ clearTimeout(timer);
980
+ child.stderr?.off("data", onStderr);
981
+ child.off("exit", onExit);
982
+ child.off("error", onError);
983
+ };
984
+ child.stderr?.on("data", onStderr);
985
+ child.on("exit", onExit);
986
+ child.on("error", onError);
987
+ }).catch(async (err) => {
988
+ await dispose();
989
+ throw err;
990
+ });
383
991
  return {
384
- reason: "other",
385
- exitCode: ExitCode.Generic
992
+ process: child,
993
+ webSocketDebuggerUrl: wsUrl,
994
+ userDataDir,
995
+ dispose
386
996
  };
387
997
  }
998
+ //#endregion
999
+ //#region src/commands/login.ts
1000
+ 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";
1001
+ const LOGIN_LANDING_HOST = "apps-in-toss.toss.im";
1002
+ const LOGIN_LANDING_PATH_PREFIX = "/workspace";
1003
+ const ALLOWED_AUTHORIZE_HOST_SUFFIXES = [".toss.im"];
1004
+ function isAllowedAuthorizeHost(host) {
1005
+ const lower = host.toLowerCase();
1006
+ return ALLOWED_AUTHORIZE_HOST_SUFFIXES.some((suffix) => lower === suffix.slice(1) || lower.endsWith(suffix));
1007
+ }
1008
+ function isLoginLanding(url) {
1009
+ try {
1010
+ const u = new URL(url);
1011
+ if (u.hostname !== LOGIN_LANDING_HOST) return false;
1012
+ if (u.pathname !== LOGIN_LANDING_PATH_PREFIX && !u.pathname.startsWith(`${LOGIN_LANDING_PATH_PREFIX}/`)) return false;
1013
+ return true;
1014
+ } catch {
1015
+ return false;
1016
+ }
1017
+ }
388
1018
  const loginCommand = defineCommand({
389
1019
  meta: {
390
1020
  name: "login",
391
- description: "Log in via the browser; starts a localhost callback server."
1021
+ description: "Open a browser to sign in, then capture the console session cookies."
392
1022
  },
393
1023
  args: {
394
1024
  json: {
@@ -396,22 +1026,13 @@ const loginCommand = defineCommand({
396
1026
  description: "Emit machine-readable JSON to stdout.",
397
1027
  default: false
398
1028
  },
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
1029
  timeout: {
405
1030
  type: "string",
406
- description: "Abort the login if no callback arrives within N seconds (default 300).",
1031
+ description: "Abort if login does not complete within N seconds (default 300).",
407
1032
  default: "300"
408
1033
  }
409
1034
  },
410
1035
  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
1036
  const emitError = (payload, human) => {
416
1037
  if (args.json) process.stdout.write(`${JSON.stringify({
417
1038
  ok: false,
@@ -419,78 +1040,130 @@ const loginCommand = defineCommand({
419
1040
  })}\n`);
420
1041
  process.stderr.write(`${human}\n`);
421
1042
  };
422
- const timeoutNum = Number(args.timeout);
423
- if (!Number.isFinite(timeoutNum) || timeoutNum < 1) {
1043
+ const timeoutSec = Number(args.timeout);
1044
+ if (!Number.isFinite(timeoutSec) || timeoutSec < 1) {
424
1045
  emitError({
425
1046
  reason: "invalid-timeout",
426
1047
  given: args.timeout
427
1048
  }, `Invalid --timeout value: ${args.timeout}`);
428
1049
  return exitAfterFlush(ExitCode.Usage);
429
1050
  }
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 {
1051
+ const timeoutMs = timeoutSec * 1e3;
1052
+ const rawAuthorizeUrl = process.env.AITCC_OAUTH_URL;
1053
+ const authorizeUrl = rawAuthorizeUrl ?? DEFAULT_AUTHORIZE_URL;
1054
+ if (rawAuthorizeUrl) {
1055
+ let parsed = null;
457
1056
  try {
458
- query = await server.waitForCallback();
459
- } catch (err) {
460
- const { reason, exitCode } = classifyCallbackError(err);
1057
+ parsed = new URL(rawAuthorizeUrl);
1058
+ } catch {}
1059
+ if (!parsed || parsed.protocol !== "https:" && parsed.protocol !== "http:") {
1060
+ emitError({ reason: "invalid-authorize-url" }, `AITCC_OAUTH_URL is not a valid http(s) URL: ${rawAuthorizeUrl}`);
1061
+ return exitAfterFlush(ExitCode.Usage);
1062
+ }
1063
+ if (!isAllowedAuthorizeHost(parsed.hostname)) {
461
1064
  emitError({
462
- reason,
463
- message: err.message
464
- }, `Login failed: ${err.message}`);
465
- return exitAfterFlush(exitCode);
1065
+ reason: "authorize-host-not-allowed",
1066
+ host: parsed.hostname
1067
+ }, `Refusing to open ${parsed.hostname}: only *.toss.im hosts are allowed for sign-in.`);
1068
+ return exitAfterFlush(ExitCode.Usage);
466
1069
  }
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);
1070
+ process.stderr.write(`Using custom authorize URL from AITCC_OAUTH_URL: ${authorizeUrl}\n`);
1071
+ }
1072
+ const launched = await launchChrome({
1073
+ initialUrl: authorizeUrl,
1074
+ endpointTimeoutMs: Math.min(6e4, Math.max(3e4, Math.floor(timeoutMs / 2)))
1075
+ }).catch((err) => err);
1076
+ if (launched instanceof ChromeNotFoundError) {
1077
+ emitError({
1078
+ reason: "chrome-not-found",
1079
+ candidates: launched.candidates
1080
+ }, launched.message);
1081
+ return exitAfterFlush(ExitCode.LoginBrowserNotFound);
1082
+ }
1083
+ if (launched instanceof ChromeLaunchError || launched instanceof ChromeEndpointTimeoutError) {
1084
+ emitError({
1085
+ reason: "chrome-launch-failed",
1086
+ message: launched.message
1087
+ }, `Failed to launch browser: ${launched.message}`);
1088
+ return exitAfterFlush(ExitCode.LoginBrowserFailed);
1089
+ }
1090
+ if (launched instanceof Error) {
1091
+ emitError({
1092
+ reason: "chrome-launch-failed",
1093
+ errorName: launched.name,
1094
+ message: launched.message
1095
+ }, `Failed to launch browser (${launched.name}): ${launched.message}`);
1096
+ return exitAfterFlush(ExitCode.LoginBrowserFailed);
1097
+ }
1098
+ process.stderr.write("Opened a browser window — complete the sign-in there. The CLI will capture the session automatically.\n");
1099
+ let client = null;
1100
+ const disposeAll = async () => {
1101
+ if (client) {
1102
+ await client.close().catch(() => {});
1103
+ client = null;
1104
+ }
1105
+ await launched.dispose().catch(() => {});
1106
+ };
1107
+ const exitWith = async (code) => {
1108
+ await disposeAll();
1109
+ return exitAfterFlush(code);
1110
+ };
1111
+ try {
1112
+ client = await CdpClient.connect({ url: launched.webSocketDebuggerUrl });
1113
+ } catch (err) {
1114
+ emitError({
1115
+ reason: "cdp-connect-failed",
1116
+ message: err.message
1117
+ }, `Could not connect to the browser over CDP: ${err.message}`);
1118
+ return exitWith(ExitCode.LoginBrowserFailed);
1119
+ }
1120
+ let attached;
1121
+ try {
1122
+ attached = await attachToFirstPage(client);
1123
+ } catch (err) {
1124
+ emitError({
1125
+ reason: "cdp-attach-failed",
1126
+ message: err.message
1127
+ }, `Could not attach to the browser tab: ${err.message}`);
1128
+ return exitWith(ExitCode.LoginBrowserFailed);
1129
+ }
1130
+ const landing = await waitForLanding(client, attached.sessionId, timeoutMs);
1131
+ if (landing === "timeout") {
1132
+ emitError({
1133
+ reason: "login-timeout",
1134
+ timeoutSec
1135
+ }, `Login timed out after ${timeoutSec}s.`);
1136
+ return exitWith(ExitCode.LoginTimeout);
1137
+ }
1138
+ if (landing === "aborted") {
1139
+ emitError({ reason: "login-aborted" }, "Login was aborted (browser closed before reaching the console).");
1140
+ return exitWith(ExitCode.LoginBrowserFailed);
1141
+ }
1142
+ const cookies = await getAllCookies(client, attached.sessionId).catch((err) => err);
1143
+ if (cookies instanceof Error) {
1144
+ emitError({
1145
+ reason: "cookie-capture-failed",
1146
+ message: cookies.message
1147
+ }, `Failed to capture cookies: ${cookies.message}`);
1148
+ return exitWith(ExitCode.LoginCookieCaptureFailed);
1149
+ }
1150
+ 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);
1151
+ if (user instanceof Error) {
1152
+ const authFailed = user instanceof TossApiError && user.isAuthError;
1153
+ emitError({
1154
+ reason: authFailed ? "login-auth-not-active" : "member-info-failed",
1155
+ message: user.message
1156
+ }, 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}`);
1157
+ return exitWith(authFailed ? ExitCode.LoginCookieCaptureFailed : ExitCode.ApiError);
480
1158
  }
481
- const userId = rawUserId ?? PENDING_USER_ID;
482
- const email = rawEmail ?? "";
483
1159
  const session = {
484
- schemaVersion: 1,
485
- user: displayName ? {
486
- id: userId,
487
- email,
488
- displayName
489
- } : {
490
- id: userId,
491
- email
1160
+ schemaVersion: 2,
1161
+ user: {
1162
+ id: String(user.id),
1163
+ email: user.email,
1164
+ displayName: user.name
492
1165
  },
493
- cookies: [],
1166
+ cookies,
494
1167
  origins: [],
495
1168
  capturedAt: (/* @__PURE__ */ new Date()).toISOString()
496
1169
  };
@@ -501,21 +1174,76 @@ const loginCommand = defineCommand({
501
1174
  reason: "session-write-failed",
502
1175
  message: err.message
503
1176
  }, `Failed to write session file: ${err.message}`);
504
- return exitAfterFlush(ExitCode.Generic);
1177
+ return exitWith(ExitCode.Generic);
505
1178
  }
506
1179
  if (args.json) process.stdout.write(`${JSON.stringify({
507
1180
  ok: true,
508
1181
  status: "logged-in",
509
1182
  user: session.user,
510
- capturedAt: session.capturedAt
1183
+ capturedAt: session.capturedAt,
1184
+ cookieCount: cookies.length
511
1185
  })}\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);
1186
+ else process.stdout.write(`Logged in as ${user.name} <${user.email}>\n`);
1187
+ return exitWith(ExitCode.Ok);
517
1188
  }
518
1189
  });
1190
+ async function waitForLanding(client, sessionId, timeoutMs) {
1191
+ return await new Promise((resolve) => {
1192
+ let settled = false;
1193
+ const stops = [];
1194
+ const settle = (outcome) => {
1195
+ if (settled) return;
1196
+ settled = true;
1197
+ clearTimeout(timer);
1198
+ clearInterval(pollTimer);
1199
+ for (const s of stops) try {
1200
+ s();
1201
+ } catch {}
1202
+ resolve(outcome);
1203
+ };
1204
+ const timer = setTimeout(() => settle("timeout"), timeoutMs);
1205
+ if (typeof timer.unref === "function") timer.unref();
1206
+ stops.push(client.on((event) => {
1207
+ if (event.method === "Target.targetDestroyed") settle("aborted");
1208
+ }));
1209
+ watchMainFrameNavigations(client, sessionId, (ev) => {
1210
+ if (!ev.isMainFrame) return;
1211
+ if (isLoginLanding(ev.url)) settle("ok");
1212
+ }).then((off) => {
1213
+ if (settled) off();
1214
+ else stops.push(off);
1215
+ }).catch((err) => {
1216
+ if (settled) return;
1217
+ process.stderr.write(`Could not watch for navigation: ${err.message}\n`);
1218
+ });
1219
+ const checkCurrent = async () => {
1220
+ if (settled) return;
1221
+ const url = (await client.send("Page.getFrameTree", {}, sessionId).catch(() => null))?.frameTree.frame?.url;
1222
+ if (url && isLoginLanding(url)) settle("ok");
1223
+ };
1224
+ checkCurrent();
1225
+ const pollTimer = setInterval(() => {
1226
+ checkCurrent();
1227
+ }, 1e3);
1228
+ if (typeof pollTimer.unref === "function") pollTimer.unref();
1229
+ });
1230
+ }
1231
+ async function resolveUserWithRetry(cookies, opts = {}) {
1232
+ const callArgs = opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {};
1233
+ try {
1234
+ return await fetchConsoleMemberUserInfo(cookies, callArgs);
1235
+ } catch (err) {
1236
+ if (err instanceof TossApiError && err.isAuthError) {
1237
+ opts.onRetry?.(750);
1238
+ await new Promise((r) => {
1239
+ const t = setTimeout(r, 750);
1240
+ if (typeof t.unref === "function") t.unref();
1241
+ });
1242
+ return await fetchConsoleMemberUserInfo(cookies, callArgs);
1243
+ }
1244
+ throw err;
1245
+ }
1246
+ }
519
1247
  //#endregion
520
1248
  //#region src/commands/logout.ts
521
1249
  const logoutCommand = defineCommand({
@@ -555,13 +1283,112 @@ const logoutCommand = defineCommand({
555
1283
  }
556
1284
  });
557
1285
  //#endregion
1286
+ //#region src/api/members.ts
1287
+ const BASE = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole";
1288
+ async function fetchWorkspaceMembers(workspaceId, cookies, opts = {}) {
1289
+ const raw = await requestConsoleApi({
1290
+ url: `${BASE}/workspaces/${workspaceId}/members`,
1291
+ cookies,
1292
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
1293
+ });
1294
+ if (!Array.isArray(raw)) throw new Error(`Unexpected members shape for workspace=${workspaceId}: not an array`);
1295
+ return raw.map((entry, index) => normalizeMember(entry, workspaceId, index));
1296
+ }
1297
+ function normalizeMember(raw, workspaceId, index) {
1298
+ if (raw === null || typeof raw !== "object") throw new Error(`Unexpected member entry at index ${index} for workspace=${workspaceId}: not an object`);
1299
+ const rec = raw;
1300
+ const stringField = (k) => {
1301
+ const v = rec[k];
1302
+ if (typeof v !== "string") throw new Error(`Unexpected member entry at index ${index} for workspace=${workspaceId}: missing ${k}`);
1303
+ return v;
1304
+ };
1305
+ const numField = (k) => {
1306
+ const v = rec[k];
1307
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`Unexpected member entry at index ${index} for workspace=${workspaceId}: missing ${k}`);
1308
+ return v;
1309
+ };
1310
+ return {
1311
+ workspaceId: numField("workspaceId"),
1312
+ bizUserNo: numField("bizUserNo"),
1313
+ name: stringField("name"),
1314
+ email: stringField("email"),
1315
+ status: stringField("status"),
1316
+ role: stringField("role"),
1317
+ isOwnerDelegationRequested: Boolean(rec.isOwnerDelegationRequested),
1318
+ isAdult: Boolean(rec.isAdult)
1319
+ };
1320
+ }
1321
+ const membersCommand = defineCommand({
1322
+ meta: {
1323
+ name: "members",
1324
+ description: "Inspect workspace members."
1325
+ },
1326
+ subCommands: { ls: defineCommand({
1327
+ meta: {
1328
+ name: "ls",
1329
+ description: "List members of the selected workspace."
1330
+ },
1331
+ args: {
1332
+ workspace: {
1333
+ type: "string",
1334
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
1335
+ },
1336
+ json: {
1337
+ type: "boolean",
1338
+ description: "Emit machine-readable JSON to stdout.",
1339
+ default: false
1340
+ }
1341
+ },
1342
+ async run({ args }) {
1343
+ const ctx = await resolveWorkspaceContext(args);
1344
+ if (!ctx) return;
1345
+ const { session, workspaceId } = ctx;
1346
+ try {
1347
+ const members = await fetchWorkspaceMembers(workspaceId, session.cookies);
1348
+ if (args.json) {
1349
+ emitJson({
1350
+ ok: true,
1351
+ workspaceId,
1352
+ members: members.map((m) => ({
1353
+ bizUserNo: m.bizUserNo,
1354
+ name: m.name,
1355
+ email: m.email,
1356
+ status: m.status,
1357
+ role: m.role,
1358
+ isOwnerDelegationRequested: m.isOwnerDelegationRequested
1359
+ }))
1360
+ });
1361
+ return exitAfterFlush(ExitCode.Ok);
1362
+ }
1363
+ if (members.length === 0) {
1364
+ process.stdout.write(`No members in workspace ${workspaceId}.\n`);
1365
+ return exitAfterFlush(ExitCode.Ok);
1366
+ }
1367
+ for (const m of members) process.stdout.write(`${m.bizUserNo}\t${m.name}\t${m.email}\t${m.role}\t${m.status}\n`);
1368
+ return exitAfterFlush(ExitCode.Ok);
1369
+ } catch (err) {
1370
+ if (err instanceof TossApiError && err.isAuthError) {
1371
+ emitNotAuthenticated(args.json, "session-expired");
1372
+ return exitAfterFlush(ExitCode.NotAuthenticated);
1373
+ }
1374
+ if (err instanceof NetworkError) {
1375
+ emitNetworkError(args.json, err.message);
1376
+ return exitAfterFlush(ExitCode.NetworkError);
1377
+ }
1378
+ emitApiError(args.json, err.message);
1379
+ return exitAfterFlush(ExitCode.ApiError);
1380
+ }
1381
+ }
1382
+ }) }
1383
+ });
1384
+ //#endregion
558
1385
  //#region src/github.ts
559
1386
  const REPO_OWNER = "apps-in-toss-community";
560
1387
  const REPO_NAME = "console-cli";
561
1388
  function defaultHeaders() {
562
1389
  const headers = {
563
1390
  Accept: "application/vnd.github+json",
564
- "User-Agent": "ait-console",
1391
+ "User-Agent": "aitcc",
565
1392
  "X-GitHub-Api-Version": "2022-11-28"
566
1393
  };
567
1394
  const token = process.env.GITHUB_TOKEN;
@@ -574,6 +1401,29 @@ async function fetchLatestRelease() {
574
1401
  if (!res.ok) throw new Error(`GitHub releases/latest returned ${res.status} ${res.statusText}`);
575
1402
  return await res.json();
576
1403
  }
1404
+ /**
1405
+ * Conditional GET against `releases/latest`. If the server returns 304 we
1406
+ * learn "no change" without consuming a core rate-limit slot. Intended for
1407
+ * the background update check, which re-runs often; `fetchLatestRelease()`
1408
+ * remains the right call when the upgrade command actually needs the body.
1409
+ */
1410
+ async function fetchLatestReleaseConditional(previousEtag) {
1411
+ const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
1412
+ const headers = defaultHeaders();
1413
+ if (previousEtag && previousEtag.length > 0) headers["If-None-Match"] = previousEtag;
1414
+ const res = await fetch(url, { headers });
1415
+ const etag = res.headers.get("etag") ?? void 0;
1416
+ if (res.status === 304) return {
1417
+ status: "not-modified",
1418
+ etag
1419
+ };
1420
+ if (!res.ok) throw new Error(`GitHub releases/latest returned ${res.status} ${res.statusText}`);
1421
+ return {
1422
+ status: "updated",
1423
+ release: await res.json(),
1424
+ etag
1425
+ };
1426
+ }
577
1427
  function versionFromTag(tag) {
578
1428
  const at = tag.lastIndexOf("@");
579
1429
  const candidate = at >= 0 ? tag.slice(at + 1) : tag;
@@ -609,7 +1459,7 @@ function detectPlatform() {
609
1459
  return {
610
1460
  os,
611
1461
  arch,
612
- assetName: `ait-console-${os}-${arch}${os === "windows" ? ".exe" : ""}`
1462
+ assetName: `aitcc-${os}-${arch}${os === "windows" ? ".exe" : ""}`
613
1463
  };
614
1464
  }
615
1465
  //#endregion
@@ -640,11 +1490,11 @@ function compareSemver(a, b) {
640
1490
  //#region src/version.ts
641
1491
  function resolveVersion() {
642
1492
  try {
643
- const injected = globalThis.AIT_CONSOLE_VERSION;
1493
+ const injected = globalThis.AITCC_VERSION;
644
1494
  if (typeof injected === "string" && injected.length > 0) return injected;
645
1495
  } catch {}
646
1496
  try {
647
- return "0.1.3";
1497
+ return "0.1.5";
648
1498
  } catch {}
649
1499
  return "0.0.0-dev";
650
1500
  }
@@ -652,7 +1502,7 @@ const VERSION = resolveVersion();
652
1502
  //#endregion
653
1503
  //#region src/commands/upgrade.ts
654
1504
  function isStandaloneBinary() {
655
- return basename(process.execPath).toLowerCase().startsWith("ait-console");
1505
+ return basename(process.execPath).toLowerCase().startsWith("aitcc");
656
1506
  }
657
1507
  const upgradeCommand = defineCommand({
658
1508
  meta: {
@@ -786,57 +1636,475 @@ const upgradeCommand = defineCommand({
786
1636
  to: latest,
787
1637
  installedAt: exePath,
788
1638
  installedIn: dirname(exePath)
789
- }, `Upgraded ait-console: ${current} → ${latest}`);
1639
+ }, `Upgraded aitcc: ${current} → ${latest}`);
790
1640
  }
791
1641
  });
792
1642
  //#endregion
1643
+ //#region src/update-check.ts
1644
+ const UPDATE_CHECK_INTERVAL_MS = 1440 * 60 * 1e3;
1645
+ async function readCache() {
1646
+ let raw;
1647
+ try {
1648
+ raw = await readFile(upgradeCheckPath(), "utf8");
1649
+ } catch {
1650
+ return null;
1651
+ }
1652
+ let parsed;
1653
+ try {
1654
+ parsed = JSON.parse(raw);
1655
+ } catch {
1656
+ return null;
1657
+ }
1658
+ if (!parsed || typeof parsed !== "object") return null;
1659
+ const obj = parsed;
1660
+ if (typeof obj.lastCheckedAt !== "string") return null;
1661
+ if (obj.latestTag !== void 0 && typeof obj.latestTag !== "string") return null;
1662
+ if (obj.etag !== void 0 && typeof obj.etag !== "string") return null;
1663
+ return {
1664
+ lastCheckedAt: obj.lastCheckedAt,
1665
+ ...obj.latestTag !== void 0 ? { latestTag: obj.latestTag } : {},
1666
+ ...obj.etag !== void 0 ? { etag: obj.etag } : {}
1667
+ };
1668
+ }
1669
+ async function writeCache(entry) {
1670
+ const path = upgradeCheckPath();
1671
+ await mkdir(dirname(path), { recursive: true });
1672
+ const tmp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
1673
+ try {
1674
+ await writeFile(tmp, JSON.stringify(entry, null, 2), { mode: 384 });
1675
+ await rename(tmp, path);
1676
+ } catch (err) {
1677
+ await unlink(tmp).catch(() => {});
1678
+ throw err;
1679
+ }
1680
+ }
1681
+ /** Has the throttle window elapsed since the last recorded check? */
1682
+ function isDueForCheck(cache, now = Date.now(), intervalMs = UPDATE_CHECK_INTERVAL_MS) {
1683
+ if (!cache) return true;
1684
+ const last = Date.parse(cache.lastCheckedAt);
1685
+ if (!Number.isFinite(last)) return true;
1686
+ if (now < last) return true;
1687
+ return now - last >= intervalMs;
1688
+ }
1689
+ /**
1690
+ * Perform the throttled update check. Returns the final cache entry (for
1691
+ * testing) or null when skipped. Never throws — network errors are
1692
+ * intentionally swallowed so they never interrupt the foreground command.
1693
+ */
1694
+ async function maybeCheckForUpdate(opts = {}) {
1695
+ const env = opts.env ?? process.env;
1696
+ const isTTY = opts.isTTY ?? Boolean(process.stderr.isTTY);
1697
+ const now = opts.now ?? Date.now();
1698
+ const intervalMs = opts.intervalMs ?? 864e5;
1699
+ const optOut = env.AITCC_NO_UPDATE_CHECK;
1700
+ if (optOut && optOut !== "0" && optOut.toLowerCase() !== "false") return null;
1701
+ if (!isTTY) return null;
1702
+ const cache = await readCache();
1703
+ if (!isDueForCheck(cache, now, intervalMs)) return null;
1704
+ const nowIso = new Date(now).toISOString();
1705
+ const placeholder = {
1706
+ lastCheckedAt: nowIso,
1707
+ ...cache?.latestTag !== void 0 ? { latestTag: cache.latestTag } : {},
1708
+ ...cache?.etag !== void 0 ? { etag: cache.etag } : {}
1709
+ };
1710
+ await writeCache(placeholder).catch(() => {});
1711
+ const previousEtag = cache?.etag;
1712
+ let entry = placeholder;
1713
+ try {
1714
+ const result = await fetchLatestReleaseConditional(previousEtag);
1715
+ if (result.status === "not-modified") entry = {
1716
+ lastCheckedAt: nowIso,
1717
+ ...cache?.latestTag !== void 0 ? { latestTag: cache.latestTag } : {},
1718
+ ...result.etag !== void 0 ? { etag: result.etag } : cache?.etag !== void 0 ? { etag: cache.etag } : {}
1719
+ };
1720
+ else entry = {
1721
+ lastCheckedAt: nowIso,
1722
+ latestTag: result.release.tag_name,
1723
+ ...result.etag !== void 0 ? { etag: result.etag } : {}
1724
+ };
1725
+ await writeCache(entry).catch(() => {});
1726
+ } catch {}
1727
+ maybeEmitNotice(entry, env);
1728
+ return entry;
1729
+ }
1730
+ function maybeEmitNotice(entry, env) {
1731
+ if (!entry.latestTag) return;
1732
+ if (VERSION.startsWith("0.0.0-dev")) return;
1733
+ const latest = versionFromTag(entry.latestTag);
1734
+ if (!latest) return;
1735
+ if (compareSemver(latest, VERSION) <= 0) return;
1736
+ const dim = env.NO_COLOR ? "" : "\x1B[2m";
1737
+ const reset = env.NO_COLOR ? "" : "\x1B[0m";
1738
+ process.stderr.write(`\n${dim}(aitcc ${latest} is available — run \`aitcc upgrade\` to install)${reset}\n`);
1739
+ }
1740
+ //#endregion
793
1741
  //#region src/commands/whoami.ts
1742
+ async function runBackgroundUpdateCheck(json) {
1743
+ if (json) return;
1744
+ const timeoutMs = 500;
1745
+ await Promise.race([maybeCheckForUpdate().catch(() => null), new Promise((resolve) => {
1746
+ const t = setTimeout(() => resolve(null), timeoutMs);
1747
+ if (typeof t.unref === "function") t.unref();
1748
+ })]);
1749
+ }
794
1750
  const whoamiCommand = defineCommand({
795
1751
  meta: {
796
1752
  name: "whoami",
797
- description: "Show the currently authenticated user from the local session."
1753
+ description: "Show the currently authenticated user (live from the console API by default)."
1754
+ },
1755
+ args: {
1756
+ json: {
1757
+ type: "boolean",
1758
+ description: "Emit machine-readable JSON to stdout.",
1759
+ default: false
1760
+ },
1761
+ offline: {
1762
+ type: "boolean",
1763
+ description: "Skip the live API call and read only the cached session summary.",
1764
+ default: false
1765
+ }
798
1766
  },
799
- args: { json: {
800
- type: "boolean",
801
- description: "Emit machine-readable JSON to stdout.",
802
- default: false
803
- } },
804
1767
  async run({ args }) {
805
- const summary = await readSessionSummary();
806
- if (!summary) {
807
- if (args.json) process.stdout.write(`${JSON.stringify({ authenticated: false })}\n`);
1768
+ const session = await readSession();
1769
+ if (!session) {
1770
+ if (args.json) process.stdout.write(`${JSON.stringify({
1771
+ ok: true,
1772
+ authenticated: false
1773
+ })}\n`);
808
1774
  else {
809
- process.stderr.write("Not logged in. Run `ait-console login` to start a session.\n");
1775
+ process.stderr.write("Not logged in. Run `aitcc login` to start a session.\n");
810
1776
  process.stderr.write(`Session file checked: ${sessionPathForDiagnostics()}\n`);
811
1777
  }
812
- process.exit(ExitCode.NotAuthenticated);
1778
+ return exitAfterFlush(ExitCode.NotAuthenticated);
1779
+ }
1780
+ if (args.offline) {
1781
+ if (args.json) {
1782
+ process.stdout.write(`${JSON.stringify({
1783
+ ok: true,
1784
+ authenticated: true,
1785
+ source: "cache",
1786
+ user: session.user,
1787
+ capturedAt: session.capturedAt
1788
+ })}\n`);
1789
+ return exitAfterFlush(ExitCode.Ok);
1790
+ }
1791
+ const label = session.user.displayName ? `${session.user.displayName} <${session.user.email}>` : session.user.email;
1792
+ process.stdout.write(`Logged in as ${label} (cached)\n`);
1793
+ process.stdout.write(`Session captured: ${session.capturedAt}\n`);
1794
+ await runBackgroundUpdateCheck(args.json);
1795
+ return exitAfterFlush(ExitCode.Ok);
813
1796
  }
814
- if (args.json) {
815
- process.stdout.write(`${JSON.stringify({
816
- authenticated: true,
817
- user: summary.user,
818
- capturedAt: summary.capturedAt
1797
+ try {
1798
+ const info = await fetchConsoleMemberUserInfo(session.cookies);
1799
+ if (args.json) {
1800
+ process.stdout.write(`${JSON.stringify({
1801
+ ok: true,
1802
+ authenticated: true,
1803
+ source: "live",
1804
+ user: {
1805
+ id: String(info.id),
1806
+ bizUserNo: info.bizUserNo,
1807
+ name: info.name,
1808
+ email: info.email,
1809
+ role: info.role
1810
+ },
1811
+ workspaces: info.workspaces.map((w) => ({
1812
+ workspaceId: w.workspaceId,
1813
+ workspaceName: w.workspaceName,
1814
+ role: w.role
1815
+ })),
1816
+ capturedAt: session.capturedAt
1817
+ })}\n`);
1818
+ return exitAfterFlush(ExitCode.Ok);
1819
+ }
1820
+ process.stdout.write(`Logged in as ${info.name} <${info.email}> (${info.role})\n`);
1821
+ if (info.workspaces.length > 0) {
1822
+ process.stdout.write("Workspaces:\n");
1823
+ for (const w of info.workspaces) process.stdout.write(` - ${w.workspaceName} (id ${w.workspaceId}, ${w.role})\n`);
1824
+ }
1825
+ await runBackgroundUpdateCheck(args.json);
1826
+ return exitAfterFlush(ExitCode.Ok);
1827
+ } catch (err) {
1828
+ if (err instanceof TossApiError && err.isAuthError) {
1829
+ if (args.json) process.stdout.write(`${JSON.stringify({
1830
+ ok: true,
1831
+ authenticated: false,
1832
+ reason: "session-expired",
1833
+ errorCode: err.errorCode
1834
+ })}\n`);
1835
+ else process.stderr.write("Session is no longer valid. Run `aitcc login` again.\n");
1836
+ return exitAfterFlush(ExitCode.NotAuthenticated);
1837
+ }
1838
+ if (err instanceof NetworkError) {
1839
+ if (args.json) process.stdout.write(`${JSON.stringify({
1840
+ ok: false,
1841
+ reason: "network-error",
1842
+ message: err.message
1843
+ })}\n`);
1844
+ else process.stderr.write(`Network error reaching the console API: ${err.message}. Use \`aitcc whoami --offline\` for the cached identity.\n`);
1845
+ return exitAfterFlush(ExitCode.NetworkError);
1846
+ }
1847
+ if (args.json) process.stdout.write(`${JSON.stringify({
1848
+ ok: false,
1849
+ reason: "api-error",
1850
+ message: err.message
819
1851
  })}\n`);
820
- return;
1852
+ else process.stderr.write(`Unexpected error: ${err.message}\n`);
1853
+ return exitAfterFlush(ExitCode.ApiError);
821
1854
  }
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`);
1855
+ }
1856
+ });
1857
+ //#endregion
1858
+ //#region src/api/workspaces.ts
1859
+ const WORKSPACES_BASE = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole";
1860
+ async function fetchWorkspaceDetail(workspaceId, cookies, opts = {}) {
1861
+ const raw = await requestConsoleApi({
1862
+ url: `${WORKSPACES_BASE}/workspaces/${workspaceId}`,
1863
+ cookies,
1864
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
1865
+ });
1866
+ const id = raw.id;
1867
+ const name = raw.name;
1868
+ if (typeof id !== "number" || !Number.isInteger(id) || id <= 0 || typeof name !== "string") throw new Error(`Unexpected workspace detail shape for id=${workspaceId}`);
1869
+ const { id: _id, name: _name, ...extra } = raw;
1870
+ return {
1871
+ workspaceId: id,
1872
+ workspaceName: name,
1873
+ extra
1874
+ };
1875
+ }
1876
+ //#endregion
1877
+ //#region src/commands/workspace.ts
1878
+ function formatScalar(v) {
1879
+ if (v === null) return "null";
1880
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
1881
+ return JSON.stringify(v);
1882
+ }
1883
+ const workspaceCommand = defineCommand({
1884
+ meta: {
1885
+ name: "workspace",
1886
+ description: "Inspect and switch between the workspaces this account can access."
1887
+ },
1888
+ subCommands: {
1889
+ ls: defineCommand({
1890
+ meta: {
1891
+ name: "ls",
1892
+ description: "List workspaces the current user has access to."
1893
+ },
1894
+ args: { json: {
1895
+ type: "boolean",
1896
+ description: "Emit machine-readable JSON to stdout.",
1897
+ default: false
1898
+ } },
1899
+ async run({ args }) {
1900
+ const session = await readSession();
1901
+ if (!session) {
1902
+ emitNotAuthenticated(args.json);
1903
+ return exitAfterFlush(ExitCode.NotAuthenticated);
1904
+ }
1905
+ try {
1906
+ const info = await fetchConsoleMemberUserInfo(session.cookies);
1907
+ const current = session.currentWorkspaceId;
1908
+ if (args.json) {
1909
+ emitJson({
1910
+ ok: true,
1911
+ workspaces: info.workspaces.map((w) => ({
1912
+ workspaceId: w.workspaceId,
1913
+ workspaceName: w.workspaceName,
1914
+ role: w.role,
1915
+ current: w.workspaceId === current
1916
+ }))
1917
+ });
1918
+ return exitAfterFlush(ExitCode.Ok);
1919
+ }
1920
+ if (info.workspaces.length === 0) {
1921
+ process.stdout.write("No workspaces.\n");
1922
+ return exitAfterFlush(ExitCode.Ok);
1923
+ }
1924
+ for (const w of info.workspaces) {
1925
+ const marker = w.workspaceId === current ? "* " : " ";
1926
+ process.stdout.write(`${marker}${w.workspaceId} ${w.workspaceName} (${w.role})\n`);
1927
+ }
1928
+ if (current === void 0) process.stderr.write("No workspace selected. Run `aitcc workspace use <id>`.\n");
1929
+ return exitAfterFlush(ExitCode.Ok);
1930
+ } catch (err) {
1931
+ if (err instanceof TossApiError && err.isAuthError) {
1932
+ emitNotAuthenticated(args.json, "session-expired");
1933
+ return exitAfterFlush(ExitCode.NotAuthenticated);
1934
+ }
1935
+ if (err instanceof NetworkError) {
1936
+ emitNetworkError(args.json, err.message);
1937
+ return exitAfterFlush(ExitCode.NetworkError);
1938
+ }
1939
+ emitApiError(args.json, err.message);
1940
+ return exitAfterFlush(ExitCode.ApiError);
1941
+ }
1942
+ }
1943
+ }),
1944
+ use: defineCommand({
1945
+ meta: {
1946
+ name: "use",
1947
+ description: "Select the current workspace by ID. Subsequent commands use this."
1948
+ },
1949
+ args: {
1950
+ id: {
1951
+ type: "positional",
1952
+ description: "Workspace ID",
1953
+ required: true
1954
+ },
1955
+ json: {
1956
+ type: "boolean",
1957
+ description: "Emit machine-readable JSON to stdout.",
1958
+ default: false
1959
+ }
1960
+ },
1961
+ async run({ args }) {
1962
+ const raw = String(args.id);
1963
+ const parsed = parsePositiveInt(raw);
1964
+ if (parsed === null) {
1965
+ const message = `workspace id must be a positive integer (got ${raw})`;
1966
+ if (args.json) emitJson({
1967
+ ok: false,
1968
+ reason: "invalid-id",
1969
+ message
1970
+ });
1971
+ else process.stderr.write(`${message}\n`);
1972
+ return exitAfterFlush(ExitCode.Usage);
1973
+ }
1974
+ const session = await readSession();
1975
+ if (!session) {
1976
+ emitNotAuthenticated(args.json);
1977
+ return exitAfterFlush(ExitCode.NotAuthenticated);
1978
+ }
1979
+ try {
1980
+ const match = (await fetchConsoleMemberUserInfo(session.cookies)).workspaces.find((w) => w.workspaceId === parsed);
1981
+ if (!match) {
1982
+ if (args.json) emitJson({
1983
+ ok: false,
1984
+ reason: "not-found",
1985
+ workspaceId: parsed
1986
+ });
1987
+ else process.stderr.write(`Workspace ${parsed} is not accessible from this account. Run \`aitcc workspace ls\` to see available workspaces.\n`);
1988
+ return exitAfterFlush(ExitCode.Usage);
1989
+ }
1990
+ if (await setCurrentWorkspaceId(parsed) === null) {
1991
+ emitNotAuthenticated(args.json);
1992
+ return exitAfterFlush(ExitCode.NotAuthenticated);
1993
+ }
1994
+ if (args.json) emitJson({
1995
+ ok: true,
1996
+ workspaceId: match.workspaceId,
1997
+ workspaceName: match.workspaceName
1998
+ });
1999
+ else process.stdout.write(`Using workspace ${match.workspaceId} (${match.workspaceName}).\n`);
2000
+ return exitAfterFlush(ExitCode.Ok);
2001
+ } catch (err) {
2002
+ if (err instanceof TossApiError && err.isAuthError) {
2003
+ emitNotAuthenticated(args.json, "session-expired");
2004
+ return exitAfterFlush(ExitCode.NotAuthenticated);
2005
+ }
2006
+ if (err instanceof NetworkError) {
2007
+ emitNetworkError(args.json, err.message);
2008
+ return exitAfterFlush(ExitCode.NetworkError);
2009
+ }
2010
+ emitApiError(args.json, err.message);
2011
+ return exitAfterFlush(ExitCode.ApiError);
2012
+ }
2013
+ }
2014
+ }),
2015
+ show: defineCommand({
2016
+ meta: {
2017
+ name: "show",
2018
+ description: "Show details of the selected workspace (or the one passed with --workspace)."
2019
+ },
2020
+ args: {
2021
+ workspace: {
2022
+ type: "string",
2023
+ description: "Workspace ID to inspect. Defaults to the selected workspace."
2024
+ },
2025
+ json: {
2026
+ type: "boolean",
2027
+ description: "Emit machine-readable JSON to stdout.",
2028
+ default: false
2029
+ }
2030
+ },
2031
+ async run({ args }) {
2032
+ const session = await readSession();
2033
+ if (!session) {
2034
+ emitNotAuthenticated(args.json);
2035
+ return exitAfterFlush(ExitCode.NotAuthenticated);
2036
+ }
2037
+ let workspaceId;
2038
+ if (args.workspace) {
2039
+ const raw = String(args.workspace);
2040
+ const parsed = parsePositiveInt(raw);
2041
+ if (parsed === null) {
2042
+ const message = `--workspace must be a positive integer (got ${raw})`;
2043
+ if (args.json) emitJson({
2044
+ ok: false,
2045
+ reason: "invalid-id",
2046
+ message
2047
+ });
2048
+ else process.stderr.write(`${message}\n`);
2049
+ return exitAfterFlush(ExitCode.Usage);
2050
+ }
2051
+ workspaceId = parsed;
2052
+ } else workspaceId = session.currentWorkspaceId;
2053
+ if (workspaceId === void 0) {
2054
+ if (args.json) emitJson({
2055
+ ok: false,
2056
+ reason: "no-workspace-selected"
2057
+ });
2058
+ else process.stderr.write("No workspace selected. Pass `--workspace <id>` or run `aitcc workspace use <id>`.\n");
2059
+ return exitAfterFlush(ExitCode.Usage);
2060
+ }
2061
+ try {
2062
+ const detail = await fetchWorkspaceDetail(workspaceId, session.cookies);
2063
+ if (args.json) {
2064
+ emitJson({
2065
+ ok: true,
2066
+ workspaceId: detail.workspaceId,
2067
+ workspaceName: detail.workspaceName,
2068
+ extra: detail.extra ?? {}
2069
+ });
2070
+ return exitAfterFlush(ExitCode.Ok);
2071
+ }
2072
+ process.stdout.write(`Workspace ${detail.workspaceId}: ${detail.workspaceName}\n`);
2073
+ if (detail.extra) for (const [k, v] of Object.entries(detail.extra)) process.stdout.write(` ${k}: ${formatScalar(v)}\n`);
2074
+ return exitAfterFlush(ExitCode.Ok);
2075
+ } catch (err) {
2076
+ if (err instanceof TossApiError && err.isAuthError) {
2077
+ emitNotAuthenticated(args.json, "session-expired");
2078
+ return exitAfterFlush(ExitCode.NotAuthenticated);
2079
+ }
2080
+ if (err instanceof NetworkError) {
2081
+ emitNetworkError(args.json, err.message);
2082
+ return exitAfterFlush(ExitCode.NetworkError);
2083
+ }
2084
+ emitApiError(args.json, err.message);
2085
+ return exitAfterFlush(ExitCode.ApiError);
2086
+ }
2087
+ }
2088
+ })
825
2089
  }
826
2090
  });
827
2091
  //#endregion
828
2092
  //#region src/cli.ts
829
2093
  runMain(defineCommand({
830
2094
  meta: {
831
- name: "ait-console",
2095
+ name: "aitcc",
832
2096
  version: VERSION,
833
- description: "Community CLI for the Apps in Toss developer console (unofficial; not affiliated with Toss)."
2097
+ description: "aitcc Apps in Toss Community Console CLI. Unofficial, not affiliated with Toss."
834
2098
  },
835
2099
  subCommands: {
836
2100
  whoami: whoamiCommand,
837
2101
  login: loginCommand,
838
2102
  logout: logoutCommand,
839
- upgrade: upgradeCommand
2103
+ upgrade: upgradeCommand,
2104
+ workspace: workspaceCommand,
2105
+ app: appCommand,
2106
+ members: membersCommand,
2107
+ keys: keysCommand
840
2108
  }
841
2109
  }));
842
2110
  //#endregion