@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/README.md +31 -20
- package/dist/cli.mjs +1683 -415
- package/dist/cli.mjs.map +1 -1
- package/package.json +2 -2
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'").replace(/\//g, "/").replace(/`/g, "`");
|
|
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 = "
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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/
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
if (
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
reason: "
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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
|
-
|
|
385
|
-
|
|
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: "
|
|
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
|
|
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
|
|
423
|
-
if (!Number.isFinite(
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
459
|
-
} catch
|
|
460
|
-
|
|
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
|
-
|
|
464
|
-
}, `
|
|
465
|
-
return exitAfterFlush(
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
if (
|
|
474
|
-
emitError({
|
|
475
|
-
"
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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:
|
|
485
|
-
user:
|
|
486
|
-
id:
|
|
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
|
|
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
|
-
|
|
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": "
|
|
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: `
|
|
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.
|
|
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.
|
|
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("
|
|
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
|
|
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
|
|
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
|
|
806
|
-
if (!
|
|
807
|
-
if (args.json) process.stdout.write(`${JSON.stringify({
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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
|
-
|
|
1852
|
+
else process.stderr.write(`Unexpected error: ${err.message}\n`);
|
|
1853
|
+
return exitAfterFlush(ExitCode.ApiError);
|
|
821
1854
|
}
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
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: "
|
|
2095
|
+
name: "aitcc",
|
|
832
2096
|
version: VERSION,
|
|
833
|
-
description: "
|
|
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
|