@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/README.md +22 -22
- package/dist/cli.mjs +831 -412
- package/dist/cli.mjs.map +1 -1
- package/package.json +2 -2
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { basename, dirname, join } from "node:path";
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
81
|
-
constructor() {
|
|
82
|
-
super(
|
|
83
|
-
this.
|
|
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
|
|
87
|
-
constructor() {
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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
|
|
96
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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 =
|
|
109
|
-
} catch {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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: "
|
|
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
|
|
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
|
|
423
|
-
if (!Number.isFinite(
|
|
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 =
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
459
|
-
} catch
|
|
460
|
-
|
|
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
|
-
|
|
464
|
-
}, `
|
|
465
|
-
return exitAfterFlush(
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (
|
|
474
|
-
emitError({
|
|
475
|
-
"
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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:
|
|
486
|
-
id:
|
|
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
|
|
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
|
-
|
|
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": "
|
|
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: `
|
|
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.
|
|
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.
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
806
|
-
if (!
|
|
807
|
-
if (args.json) process.stdout.write(`${JSON.stringify({
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
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: "
|
|
1250
|
+
name: "aitcc",
|
|
832
1251
|
version: VERSION,
|
|
833
|
-
description: "
|
|
1252
|
+
description: "aitcc — Apps in Toss Community Console CLI. Unofficial, not affiliated with Toss."
|
|
834
1253
|
},
|
|
835
1254
|
subCommands: {
|
|
836
1255
|
whoami: whoamiCommand,
|