@ait-co/console-cli 0.1.4 → 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 +11 -0
- package/dist/cli.mjs +951 -102
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
package/dist/cli.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { defineCommand, runMain } from "citty";
|
|
3
|
-
import { spawn } from "node:child_process";
|
|
4
|
-
import { constants } from "node:fs";
|
|
5
3
|
import { chmod, mkdir, mkdtemp, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
|
|
6
|
-
import { homedir, tmpdir } from "node:os";
|
|
7
4
|
import { basename, dirname, join, win32 } from "node:path";
|
|
5
|
+
import { homedir, tmpdir } from "node:os";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { constants } from "node:fs";
|
|
8
8
|
//#region src/api/http.ts
|
|
9
9
|
var TossApiError = class extends Error {
|
|
10
10
|
constructor(status, errorCode, reason, errorType) {
|
|
@@ -143,6 +143,483 @@ async function requestConsoleApi(options) {
|
|
|
143
143
|
throw new TossApiError(res.status, parsed.error.errorCode, parsed.error.reason, parsed.error.errorType);
|
|
144
144
|
}
|
|
145
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 } : {}
|
|
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
|
+
};
|
|
189
|
+
}
|
|
190
|
+
//#endregion
|
|
191
|
+
//#region src/exit.ts
|
|
192
|
+
const ExitCode = {
|
|
193
|
+
Ok: 0,
|
|
194
|
+
Generic: 1,
|
|
195
|
+
Usage: 2,
|
|
196
|
+
NotAuthenticated: 10,
|
|
197
|
+
NetworkError: 11,
|
|
198
|
+
LoginTimeout: 12,
|
|
199
|
+
LoginStateMismatch: 13,
|
|
200
|
+
LoginBrowserNotFound: 14,
|
|
201
|
+
LoginBrowserFailed: 15,
|
|
202
|
+
LoginCookieCaptureFailed: 16,
|
|
203
|
+
ApiError: 17,
|
|
204
|
+
UpgradeUnavailable: 20,
|
|
205
|
+
UpgradeAlreadyLatest: 21
|
|
206
|
+
};
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region src/flush.ts
|
|
209
|
+
async function exitAfterFlush(code) {
|
|
210
|
+
await new Promise((resolve) => process.stdout.write("", () => resolve()));
|
|
211
|
+
process.exit(code);
|
|
212
|
+
}
|
|
213
|
+
//#endregion
|
|
214
|
+
//#region src/paths.ts
|
|
215
|
+
const APP_NAME = "aitcc";
|
|
216
|
+
function configDir() {
|
|
217
|
+
if (process.platform === "win32") {
|
|
218
|
+
const appData = process.env.APPDATA;
|
|
219
|
+
if (appData && appData.length > 0) return join(appData, APP_NAME);
|
|
220
|
+
return join(homedir() || ".", "AppData", "Roaming", APP_NAME);
|
|
221
|
+
}
|
|
222
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
223
|
+
if (xdg && xdg.length > 0) return join(xdg, APP_NAME);
|
|
224
|
+
return join(homedir() || ".", ".config", APP_NAME);
|
|
225
|
+
}
|
|
226
|
+
function sessionFilePath() {
|
|
227
|
+
return join(configDir(), "session.json");
|
|
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
|
+
}
|
|
242
|
+
//#endregion
|
|
243
|
+
//#region src/session.ts
|
|
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
|
+
*/
|
|
255
|
+
async function readSession() {
|
|
256
|
+
const path = sessionFilePath();
|
|
257
|
+
let raw;
|
|
258
|
+
try {
|
|
259
|
+
raw = await readFile(path, "utf8");
|
|
260
|
+
} catch (err) {
|
|
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`);
|
|
264
|
+
return null;
|
|
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;
|
|
292
|
+
}
|
|
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;
|
|
314
|
+
}
|
|
315
|
+
async function writeSession(session) {
|
|
316
|
+
await mkdir(dirname(sessionFilePath()), {
|
|
317
|
+
recursive: true,
|
|
318
|
+
mode: 448
|
|
319
|
+
});
|
|
320
|
+
await writeFile(sessionFilePath(), JSON.stringify(session, null, 2), { mode: 384 });
|
|
321
|
+
try {
|
|
322
|
+
await chmod(sessionFilePath(), 384);
|
|
323
|
+
} catch {}
|
|
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
|
+
}
|
|
340
|
+
async function clearSession() {
|
|
341
|
+
try {
|
|
342
|
+
await unlink(sessionFilePath());
|
|
343
|
+
return { existed: true };
|
|
344
|
+
} catch (err) {
|
|
345
|
+
if (err.code === "ENOENT") return { existed: false };
|
|
346
|
+
throw err;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
function sessionPathForDiagnostics() {
|
|
350
|
+
return sessionFilePath();
|
|
351
|
+
}
|
|
352
|
+
//#endregion
|
|
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
|
|
146
623
|
//#region src/api/me.ts
|
|
147
624
|
const MEMBER_USER_INFO_URL = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole/members/me/user-info";
|
|
148
625
|
async function fetchConsoleMemberUserInfo(cookies, opts = {}) {
|
|
@@ -519,102 +996,6 @@ async function launchChrome(options) {
|
|
|
519
996
|
};
|
|
520
997
|
}
|
|
521
998
|
//#endregion
|
|
522
|
-
//#region src/exit.ts
|
|
523
|
-
const ExitCode = {
|
|
524
|
-
Ok: 0,
|
|
525
|
-
Generic: 1,
|
|
526
|
-
Usage: 2,
|
|
527
|
-
NotAuthenticated: 10,
|
|
528
|
-
NetworkError: 11,
|
|
529
|
-
LoginTimeout: 12,
|
|
530
|
-
LoginStateMismatch: 13,
|
|
531
|
-
LoginBrowserNotFound: 14,
|
|
532
|
-
LoginBrowserFailed: 15,
|
|
533
|
-
LoginCookieCaptureFailed: 16,
|
|
534
|
-
ApiError: 17,
|
|
535
|
-
UpgradeUnavailable: 20,
|
|
536
|
-
UpgradeAlreadyLatest: 21
|
|
537
|
-
};
|
|
538
|
-
//#endregion
|
|
539
|
-
//#region src/flush.ts
|
|
540
|
-
async function exitAfterFlush(code) {
|
|
541
|
-
await new Promise((resolve) => process.stdout.write("", () => resolve()));
|
|
542
|
-
process.exit(code);
|
|
543
|
-
}
|
|
544
|
-
//#endregion
|
|
545
|
-
//#region src/paths.ts
|
|
546
|
-
const APP_NAME = "aitcc";
|
|
547
|
-
function configDir() {
|
|
548
|
-
if (process.platform === "win32") {
|
|
549
|
-
const appData = process.env.APPDATA;
|
|
550
|
-
if (appData && appData.length > 0) return join(appData, APP_NAME);
|
|
551
|
-
return join(homedir() || ".", "AppData", "Roaming", APP_NAME);
|
|
552
|
-
}
|
|
553
|
-
const xdg = process.env.XDG_CONFIG_HOME;
|
|
554
|
-
if (xdg && xdg.length > 0) return join(xdg, APP_NAME);
|
|
555
|
-
return join(homedir() || ".", ".config", APP_NAME);
|
|
556
|
-
}
|
|
557
|
-
function sessionFilePath() {
|
|
558
|
-
return join(configDir(), "session.json");
|
|
559
|
-
}
|
|
560
|
-
//#endregion
|
|
561
|
-
//#region src/session.ts
|
|
562
|
-
async function readSession() {
|
|
563
|
-
const path = sessionFilePath();
|
|
564
|
-
let raw;
|
|
565
|
-
try {
|
|
566
|
-
raw = await readFile(path, "utf8");
|
|
567
|
-
} catch (err) {
|
|
568
|
-
const code = err.code;
|
|
569
|
-
if (code === "ENOENT") return null;
|
|
570
|
-
process.stderr.write(`warning: could not read session file at ${path}: ${code ?? "unknown"}\n`);
|
|
571
|
-
return null;
|
|
572
|
-
}
|
|
573
|
-
let parsed;
|
|
574
|
-
try {
|
|
575
|
-
parsed = JSON.parse(raw);
|
|
576
|
-
} catch {
|
|
577
|
-
process.stderr.write(`warning: session file at ${path} is corrupt and will be ignored\n`);
|
|
578
|
-
return null;
|
|
579
|
-
}
|
|
580
|
-
const schemaReason = validateSessionShape(parsed);
|
|
581
|
-
if (schemaReason) {
|
|
582
|
-
process.stderr.write(`warning: session file at ${path} ignored (${schemaReason}); re-run \`aitcc login\`\n`);
|
|
583
|
-
return null;
|
|
584
|
-
}
|
|
585
|
-
return parsed;
|
|
586
|
-
}
|
|
587
|
-
function validateSessionShape(parsed) {
|
|
588
|
-
if (parsed.schemaVersion !== 1) return `unknown schemaVersion ${String(parsed.schemaVersion)}`;
|
|
589
|
-
if (!parsed.user || typeof parsed.user.id !== "string") return "missing user.id";
|
|
590
|
-
if (typeof parsed.user.email !== "string") return "missing user.email";
|
|
591
|
-
if (parsed.user.displayName !== void 0 && typeof parsed.user.displayName !== "string") return "user.displayName has wrong type";
|
|
592
|
-
if (!Array.isArray(parsed.cookies)) return "cookies is not an array";
|
|
593
|
-
return null;
|
|
594
|
-
}
|
|
595
|
-
async function writeSession(session) {
|
|
596
|
-
await mkdir(dirname(sessionFilePath()), {
|
|
597
|
-
recursive: true,
|
|
598
|
-
mode: 448
|
|
599
|
-
});
|
|
600
|
-
await writeFile(sessionFilePath(), JSON.stringify(session, null, 2), { mode: 384 });
|
|
601
|
-
try {
|
|
602
|
-
await chmod(sessionFilePath(), 384);
|
|
603
|
-
} catch {}
|
|
604
|
-
}
|
|
605
|
-
async function clearSession() {
|
|
606
|
-
try {
|
|
607
|
-
await unlink(sessionFilePath());
|
|
608
|
-
return { existed: true };
|
|
609
|
-
} catch (err) {
|
|
610
|
-
if (err.code === "ENOENT") return { existed: false };
|
|
611
|
-
throw err;
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
function sessionPathForDiagnostics() {
|
|
615
|
-
return sessionFilePath();
|
|
616
|
-
}
|
|
617
|
-
//#endregion
|
|
618
999
|
//#region src/commands/login.ts
|
|
619
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";
|
|
620
1001
|
const LOGIN_LANDING_HOST = "apps-in-toss.toss.im";
|
|
@@ -776,7 +1157,7 @@ const loginCommand = defineCommand({
|
|
|
776
1157
|
return exitWith(authFailed ? ExitCode.LoginCookieCaptureFailed : ExitCode.ApiError);
|
|
777
1158
|
}
|
|
778
1159
|
const session = {
|
|
779
|
-
schemaVersion:
|
|
1160
|
+
schemaVersion: 2,
|
|
780
1161
|
user: {
|
|
781
1162
|
id: String(user.id),
|
|
782
1163
|
email: user.email,
|
|
@@ -902,6 +1283,105 @@ const logoutCommand = defineCommand({
|
|
|
902
1283
|
}
|
|
903
1284
|
});
|
|
904
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
|
|
905
1385
|
//#region src/github.ts
|
|
906
1386
|
const REPO_OWNER = "apps-in-toss-community";
|
|
907
1387
|
const REPO_NAME = "console-cli";
|
|
@@ -921,6 +1401,29 @@ async function fetchLatestRelease() {
|
|
|
921
1401
|
if (!res.ok) throw new Error(`GitHub releases/latest returned ${res.status} ${res.statusText}`);
|
|
922
1402
|
return await res.json();
|
|
923
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
|
+
}
|
|
924
1427
|
function versionFromTag(tag) {
|
|
925
1428
|
const at = tag.lastIndexOf("@");
|
|
926
1429
|
const candidate = at >= 0 ? tag.slice(at + 1) : tag;
|
|
@@ -991,7 +1494,7 @@ function resolveVersion() {
|
|
|
991
1494
|
if (typeof injected === "string" && injected.length > 0) return injected;
|
|
992
1495
|
} catch {}
|
|
993
1496
|
try {
|
|
994
|
-
return "0.1.
|
|
1497
|
+
return "0.1.5";
|
|
995
1498
|
} catch {}
|
|
996
1499
|
return "0.0.0-dev";
|
|
997
1500
|
}
|
|
@@ -1137,7 +1640,113 @@ const upgradeCommand = defineCommand({
|
|
|
1137
1640
|
}
|
|
1138
1641
|
});
|
|
1139
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
|
|
1140
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
|
+
}
|
|
1141
1750
|
const whoamiCommand = defineCommand({
|
|
1142
1751
|
meta: {
|
|
1143
1752
|
name: "whoami",
|
|
@@ -1182,6 +1791,7 @@ const whoamiCommand = defineCommand({
|
|
|
1182
1791
|
const label = session.user.displayName ? `${session.user.displayName} <${session.user.email}>` : session.user.email;
|
|
1183
1792
|
process.stdout.write(`Logged in as ${label} (cached)\n`);
|
|
1184
1793
|
process.stdout.write(`Session captured: ${session.capturedAt}\n`);
|
|
1794
|
+
await runBackgroundUpdateCheck(args.json);
|
|
1185
1795
|
return exitAfterFlush(ExitCode.Ok);
|
|
1186
1796
|
}
|
|
1187
1797
|
try {
|
|
@@ -1212,6 +1822,7 @@ const whoamiCommand = defineCommand({
|
|
|
1212
1822
|
process.stdout.write("Workspaces:\n");
|
|
1213
1823
|
for (const w of info.workspaces) process.stdout.write(` - ${w.workspaceName} (id ${w.workspaceId}, ${w.role})\n`);
|
|
1214
1824
|
}
|
|
1825
|
+
await runBackgroundUpdateCheck(args.json);
|
|
1215
1826
|
return exitAfterFlush(ExitCode.Ok);
|
|
1216
1827
|
} catch (err) {
|
|
1217
1828
|
if (err instanceof TossApiError && err.isAuthError) {
|
|
@@ -1244,6 +1855,240 @@ const whoamiCommand = defineCommand({
|
|
|
1244
1855
|
}
|
|
1245
1856
|
});
|
|
1246
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
|
+
})
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
//#endregion
|
|
1247
2092
|
//#region src/cli.ts
|
|
1248
2093
|
runMain(defineCommand({
|
|
1249
2094
|
meta: {
|
|
@@ -1255,7 +2100,11 @@ runMain(defineCommand({
|
|
|
1255
2100
|
whoami: whoamiCommand,
|
|
1256
2101
|
login: loginCommand,
|
|
1257
2102
|
logout: logoutCommand,
|
|
1258
|
-
upgrade: upgradeCommand
|
|
2103
|
+
upgrade: upgradeCommand,
|
|
2104
|
+
workspace: workspaceCommand,
|
|
2105
|
+
app: appCommand,
|
|
2106
|
+
members: membersCommand,
|
|
2107
|
+
keys: keysCommand
|
|
1259
2108
|
}
|
|
1260
2109
|
}));
|
|
1261
2110
|
//#endregion
|