@ait-co/console-cli 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { defineCommand, runMain } from "citty";
3
+ import { access, chmod, mkdir, mkdtemp, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, isAbsolute, join, resolve, win32 } from "node:path";
5
+ import { homedir, tmpdir } from "node:os";
6
+ import { parse } from "yaml";
7
+ import { imageSize } from "image-size";
3
8
  import { spawn } from "node:child_process";
4
9
  import { constants } from "node:fs";
5
- import { chmod, mkdir, mkdtemp, readFile, rename, rm, unlink, writeFile } from "node:fs/promises";
6
- import { homedir, tmpdir } from "node:os";
7
- import { basename, dirname, join, win32 } from "node:path";
8
10
  //#region src/api/http.ts
9
11
  var TossApiError = class extends Error {
10
12
  constructor(status, errorCode, reason, errorType) {
@@ -119,10 +121,25 @@ async function requestConsoleApi(options) {
119
121
  headers["Content-Type"] = "application/json";
120
122
  init.body = JSON.stringify(options.body);
121
123
  }
122
- const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
124
+ return executeAndUnwrap(url, init, options.fetchImpl);
125
+ }
126
+ /**
127
+ * Send a pre-built `RequestInit` against the console API and unwrap the
128
+ * Toss envelope. Use this when the caller needs to build the body itself
129
+ * (multipart uploads, binary requests, anything that can't live under
130
+ * `requestConsoleApi`'s JSON-body assumption). Cookie header composition
131
+ * and any additional headers remain the caller's responsibility.
132
+ *
133
+ * Exists so `uploadMiniAppResource` doesn't have to re-implement the
134
+ * text→JSON→envelope→error branch in `requestConsoleApi`; drift between
135
+ * the two paths has bitten us once (cf. the refactor in the
136
+ * `app register` review).
137
+ */
138
+ async function executeAndUnwrap(url, init, fetchImpl) {
139
+ const impl = fetchImpl ?? ((input, i) => fetch(input, i));
123
140
  let res;
124
141
  try {
125
- res = await fetchImpl(url, init);
142
+ res = await impl(url, init);
126
143
  } catch (err) {
127
144
  throw new NetworkError(url.toString(), err);
128
145
  }
@@ -143,6 +160,1058 @@ async function requestConsoleApi(options) {
143
160
  throw new TossApiError(res.status, parsed.error.errorCode, parsed.error.reason, parsed.error.errorType);
144
161
  }
145
162
  //#endregion
163
+ //#region src/api/mini-apps.ts
164
+ const BASE$2 = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole";
165
+ async function fetchMiniApps(workspaceId, cookies, opts = {}) {
166
+ const raw = await requestConsoleApi({
167
+ url: `${BASE$2}/workspaces/${workspaceId}/mini-app`,
168
+ cookies,
169
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
170
+ });
171
+ if (!Array.isArray(raw)) throw new Error(`Unexpected mini-app list shape for workspace=${workspaceId}`);
172
+ return raw.map((item, index) => normalizeMiniApp(item, workspaceId, index));
173
+ }
174
+ function normalizeMiniApp(item, workspaceId, index) {
175
+ if (item === null || typeof item !== "object") throw new Error(`Unexpected mini-app entry at index ${index} for workspace=${workspaceId}: not an object`);
176
+ const rec = item;
177
+ const rawId = rec.id ?? rec.miniAppId ?? rec.appId;
178
+ if (typeof rawId !== "string" && typeof rawId !== "number") throw new Error(`Unexpected mini-app entry at index ${index} for workspace=${workspaceId}: missing id`);
179
+ const rawName = rec.name ?? rec.miniAppName ?? rec.appName;
180
+ const name = typeof rawName === "string" ? rawName : void 0;
181
+ const { id: _id, miniAppId: _mid, appId: _aid, name: _n, miniAppName: _mn, appName: _an, ...extra } = rec;
182
+ return {
183
+ id: rawId,
184
+ name,
185
+ extra
186
+ };
187
+ }
188
+ async function fetchReviewStatus(workspaceId, cookies, opts = {}) {
189
+ const raw = await requestConsoleApi({
190
+ url: `${BASE$2}/workspaces/${workspaceId}/mini-apps/review-status`,
191
+ cookies,
192
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
193
+ });
194
+ if (raw === null || typeof raw !== "object") throw new Error(`Unexpected review-status shape for workspace=${workspaceId}`);
195
+ const rec = raw;
196
+ const hasPolicyViolation = Boolean(rec.hasPolicyViolation);
197
+ const miniAppsRaw = rec.miniApps;
198
+ if (!Array.isArray(miniAppsRaw)) throw new Error(`Unexpected review-status shape for workspace=${workspaceId}: miniApps is not an array`);
199
+ return {
200
+ hasPolicyViolation,
201
+ miniApps: miniAppsRaw.map((m) => {
202
+ if (m === null || typeof m !== "object") return {};
203
+ return m;
204
+ })
205
+ };
206
+ }
207
+ async function createMiniApp(workspaceId, payload, cookies, opts = {}) {
208
+ return normalizeCreateResult(await requestConsoleApi({
209
+ url: `${BASE$2}/workspaces/${workspaceId}/mini-app/review`,
210
+ method: "POST",
211
+ cookies,
212
+ body: payload,
213
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
214
+ }));
215
+ }
216
+ function normalizeCreateResult(raw) {
217
+ if (raw === null || typeof raw !== "object") return {
218
+ miniAppId: void 0,
219
+ reviewState: void 0,
220
+ extra: {}
221
+ };
222
+ const rec = raw;
223
+ const rawId = rec.miniAppId ?? rec.id ?? rec.appId;
224
+ const miniAppId = typeof rawId === "string" || typeof rawId === "number" ? rawId : void 0;
225
+ const rawState = rec.reviewState ?? rec.status;
226
+ return {
227
+ miniAppId,
228
+ reviewState: typeof rawState === "string" ? rawState : void 0,
229
+ extra: rec
230
+ };
231
+ }
232
+ /**
233
+ * Upload an image to `/resource/:wid/upload?validWidth=W&validHeight=H`
234
+ * and return the CDN URL the server hands back. The endpoint is a
235
+ * multipart/form-data POST; we build a FormData with a single `resource`
236
+ * field because that matches the bundle analysis for the console's
237
+ * uploader, which pairs a `fileName` string field with a `resource`
238
+ * Blob (see VALIDATION-RULES.md → iconUri). Dog-food #23 may reveal that
239
+ * the field name is actually `file` — if so, swap it in one place here.
240
+ */
241
+ async function uploadMiniAppResource(params, opts = {}) {
242
+ const url = new URL(`${BASE$2}/resource/${params.workspaceId}/upload`);
243
+ url.searchParams.set("validWidth", String(params.validWidth));
244
+ url.searchParams.set("validHeight", String(params.validHeight));
245
+ const form = new FormData();
246
+ const view = new Uint8Array(params.file.buffer.buffer, params.file.buffer.byteOffset, params.file.buffer.byteLength);
247
+ const blob = new Blob([view], { type: params.file.contentType });
248
+ form.append("resource", blob, params.file.fileName);
249
+ form.append("fileName", params.file.fileName);
250
+ const cookieHeader = cookieHeaderFor(url, params.cookies);
251
+ const headers = { Accept: "application/json, text/plain, */*" };
252
+ if (cookieHeader) headers.Cookie = cookieHeader;
253
+ const imageUrl = await executeAndUnwrap(url, {
254
+ method: "POST",
255
+ headers,
256
+ body: form
257
+ }, opts.fetchImpl);
258
+ if (typeof imageUrl !== "string") throw new MalformedResponseError(url.toString(), 200, `expected string imageUrl, got ${typeof imageUrl}`);
259
+ return imageUrl;
260
+ }
261
+ //#endregion
262
+ //#region src/exit.ts
263
+ const ExitCode = {
264
+ Ok: 0,
265
+ Generic: 1,
266
+ Usage: 2,
267
+ NotAuthenticated: 10,
268
+ NetworkError: 11,
269
+ LoginTimeout: 12,
270
+ LoginStateMismatch: 13,
271
+ LoginBrowserNotFound: 14,
272
+ LoginBrowserFailed: 15,
273
+ LoginCookieCaptureFailed: 16,
274
+ ApiError: 17,
275
+ UpgradeUnavailable: 20,
276
+ UpgradeAlreadyLatest: 21
277
+ };
278
+ //#endregion
279
+ //#region src/flush.ts
280
+ async function exitAfterFlush(code) {
281
+ await new Promise((resolve) => process.stdout.write("", () => resolve()));
282
+ process.exit(code);
283
+ }
284
+ //#endregion
285
+ //#region src/paths.ts
286
+ const APP_NAME = "aitcc";
287
+ function configDir() {
288
+ if (process.platform === "win32") {
289
+ const appData = process.env.APPDATA;
290
+ if (appData && appData.length > 0) return join(appData, APP_NAME);
291
+ return join(homedir() || ".", "AppData", "Roaming", APP_NAME);
292
+ }
293
+ const xdg = process.env.XDG_CONFIG_HOME;
294
+ if (xdg && xdg.length > 0) return join(xdg, APP_NAME);
295
+ return join(homedir() || ".", ".config", APP_NAME);
296
+ }
297
+ function sessionFilePath() {
298
+ return join(configDir(), "session.json");
299
+ }
300
+ function cacheDir() {
301
+ if (process.platform === "win32") {
302
+ const localAppData = process.env.LOCALAPPDATA;
303
+ if (localAppData && localAppData.length > 0) return join(localAppData, APP_NAME, "Cache");
304
+ return join(homedir() || ".", "AppData", "Local", APP_NAME, "Cache");
305
+ }
306
+ const xdg = process.env.XDG_CACHE_HOME;
307
+ if (xdg && xdg.length > 0) return join(xdg, APP_NAME);
308
+ return join(homedir() || ".", ".cache", APP_NAME);
309
+ }
310
+ function upgradeCheckPath() {
311
+ return join(cacheDir(), "upgrade-check.json");
312
+ }
313
+ //#endregion
314
+ //#region src/session.ts
315
+ /**
316
+ * Read the persisted session. Returns `null` when no session exists, when
317
+ * the file is corrupt, or when the shape fails validation — each of those
318
+ * emits a one-line warning on stderr for diagnostics.
319
+ *
320
+ * **Side effect**: a v1 session file is transparently rewritten to v2 on
321
+ * the first successful read of this process. This keeps read-only callers
322
+ * (`whoami`, `workspace ls`) from stranding users on an old schema. If the
323
+ * rewrite fails, we warn once per process and continue with the in-memory
324
+ * v2 value so the calling command still succeeds.
325
+ */
326
+ async function readSession() {
327
+ const path = sessionFilePath();
328
+ let raw;
329
+ try {
330
+ raw = await readFile(path, "utf8");
331
+ } catch (err) {
332
+ const code = err.code;
333
+ if (code === "ENOENT") return null;
334
+ process.stderr.write(`warning: could not read session file at ${path}: ${code ?? "unknown"}\n`);
335
+ return null;
336
+ }
337
+ let rawParsed;
338
+ try {
339
+ rawParsed = JSON.parse(raw);
340
+ } catch {
341
+ process.stderr.write(`warning: session file at ${path} is corrupt and will be ignored\n`);
342
+ return null;
343
+ }
344
+ const schemaReason = validateSessionShape(rawParsed);
345
+ if (schemaReason) {
346
+ process.stderr.write(`warning: session file at ${path} ignored (${schemaReason}); re-run \`aitcc login\`\n`);
347
+ return null;
348
+ }
349
+ const validated = rawParsed;
350
+ if (validated.schemaVersion === 1) {
351
+ const upgraded = {
352
+ ...validated,
353
+ schemaVersion: 2
354
+ };
355
+ try {
356
+ await writeSession(upgraded);
357
+ } catch (err) {
358
+ warnMigrationOnce(path, err.code);
359
+ }
360
+ return upgraded;
361
+ }
362
+ return validated;
363
+ }
364
+ let migrationWarned = false;
365
+ function warnMigrationOnce(path, code) {
366
+ if (migrationWarned) return;
367
+ migrationWarned = true;
368
+ process.stderr.write(`warning: could not migrate session file at ${path} to schemaVersion 2: ${code ?? "unknown"}\n`);
369
+ }
370
+ function validateSessionShape(input) {
371
+ if (input === null || typeof input !== "object") return "root is not an object";
372
+ const parsed = input;
373
+ if (parsed.schemaVersion !== 1 && parsed.schemaVersion !== 2) return `unknown schemaVersion ${String(parsed.schemaVersion)}`;
374
+ if (!parsed.user || typeof parsed.user.id !== "string") return "missing user.id";
375
+ if (typeof parsed.user.email !== "string") return "missing user.email";
376
+ if (parsed.user.displayName !== void 0 && typeof parsed.user.displayName !== "string") return "user.displayName has wrong type";
377
+ if (!Array.isArray(parsed.cookies)) return "cookies is not an array";
378
+ if (parsed.origins !== void 0 && !Array.isArray(parsed.origins)) return "origins is not an array";
379
+ if (parsed.capturedAt !== void 0 && typeof parsed.capturedAt !== "string") return "capturedAt has wrong type";
380
+ if (parsed.currentWorkspaceId !== void 0) {
381
+ const wid = parsed.currentWorkspaceId;
382
+ if (typeof wid !== "number" || !Number.isInteger(wid) || wid <= 0) return "currentWorkspaceId has wrong type";
383
+ }
384
+ return null;
385
+ }
386
+ async function writeSession(session) {
387
+ await mkdir(dirname(sessionFilePath()), {
388
+ recursive: true,
389
+ mode: 448
390
+ });
391
+ await writeFile(sessionFilePath(), JSON.stringify(session, null, 2), { mode: 384 });
392
+ try {
393
+ await chmod(sessionFilePath(), 384);
394
+ } catch {}
395
+ }
396
+ /**
397
+ * Persist a new `currentWorkspaceId` on an existing session. Returns the
398
+ * updated session, or `null` if there is no session to update (callers
399
+ * should surface "not logged in" in that case).
400
+ */
401
+ async function setCurrentWorkspaceId(workspaceId) {
402
+ const session = await readSession();
403
+ if (!session) return null;
404
+ const updated = {
405
+ ...session,
406
+ currentWorkspaceId: workspaceId
407
+ };
408
+ await writeSession(updated);
409
+ return updated;
410
+ }
411
+ async function clearSession() {
412
+ try {
413
+ await unlink(sessionFilePath());
414
+ return { existed: true };
415
+ } catch (err) {
416
+ if (err.code === "ENOENT") return { existed: false };
417
+ throw err;
418
+ }
419
+ }
420
+ function sessionPathForDiagnostics() {
421
+ return sessionFilePath();
422
+ }
423
+ //#endregion
424
+ //#region src/commands/_shared.ts
425
+ function emitJson(payload) {
426
+ process.stdout.write(`${JSON.stringify(payload)}\n`);
427
+ }
428
+ function emitNotAuthenticated(json, reason) {
429
+ if (json) emitJson(reason ? {
430
+ ok: true,
431
+ authenticated: false,
432
+ reason
433
+ } : {
434
+ ok: true,
435
+ authenticated: false
436
+ });
437
+ else {
438
+ 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");
439
+ process.stderr.write(`Session file checked: ${sessionPathForDiagnostics()}\n`);
440
+ }
441
+ }
442
+ function emitNetworkError(json, message) {
443
+ if (json) emitJson({
444
+ ok: false,
445
+ reason: "network-error",
446
+ message
447
+ });
448
+ else process.stderr.write(`Network error reaching the console API: ${message}.\n`);
449
+ }
450
+ function emitApiError(json, message, details) {
451
+ if (json) emitJson({
452
+ ok: false,
453
+ reason: "api-error",
454
+ ...details?.status !== void 0 ? { status: details.status } : {},
455
+ ...details?.errorCode !== void 0 ? { errorCode: details.errorCode } : {},
456
+ message
457
+ });
458
+ else process.stderr.write(`Unexpected error: ${message}\n`);
459
+ }
460
+ /**
461
+ * Shared auth/network/api dispatch. Every session-scoped command's
462
+ * `catch (err)` block boils down to the same sequence: TossApiError
463
+ * (auth → exit 10, otherwise → exit 17 with status + errorCode),
464
+ * NetworkError (exit 11), fallback (exit 17 with just a message).
465
+ * Exists so we get a single source of truth for the api-error JSON
466
+ * shape — previously each command duplicated the if/else ladder and
467
+ * `register` diverged (it exposed `status`/`errorCode` that the others
468
+ * didn't) until this extraction lined them up.
469
+ *
470
+ * Returns `Promise<void>` but never returns at runtime: every branch
471
+ * awaits `exitAfterFlush` which calls `process.exit`.
472
+ */
473
+ async function emitFailureFromError(json, err) {
474
+ if (err instanceof TossApiError && err.isAuthError) {
475
+ emitNotAuthenticated(json, "session-expired");
476
+ return exitAfterFlush(ExitCode.NotAuthenticated);
477
+ }
478
+ if (err instanceof TossApiError) {
479
+ emitApiError(json, err.message, {
480
+ status: err.status,
481
+ errorCode: err.errorCode
482
+ });
483
+ return exitAfterFlush(ExitCode.ApiError);
484
+ }
485
+ if (err instanceof NetworkError) {
486
+ emitNetworkError(json, err.message);
487
+ return exitAfterFlush(ExitCode.NetworkError);
488
+ }
489
+ emitApiError(json, err.message);
490
+ return exitAfterFlush(ExitCode.ApiError);
491
+ }
492
+ function parsePositiveInt(raw) {
493
+ if (!/^[1-9]\d*$/.test(raw)) return null;
494
+ const n = Number.parseInt(raw, 10);
495
+ return Number.isSafeInteger(n) ? n : null;
496
+ }
497
+ /**
498
+ * Boilerplate wrapper for any workspace-scoped command (`app ls`,
499
+ * `members ls`, `keys ls`, ...). Loads the session, resolves the workspace
500
+ * id from `--workspace <id>` or the persisted selection, and handles the
501
+ * three common failure branches (`no session`, `invalid id`, `no workspace
502
+ * selected`). On success, the caller gets the session + resolved id back.
503
+ *
504
+ * The return type is `Promise<... | null>` but the `null` branch is never
505
+ * observed at runtime: every failure path `await`s `exitAfterFlush` which
506
+ * calls `process.exit(...)` and doesn't return. The `| null` is a type-
507
+ * level handshake that forces callers to add `if (!ctx) return;`, keeping
508
+ * the bail-out readable.
509
+ */
510
+ async function resolveWorkspaceContext(args) {
511
+ const session = await readSession();
512
+ if (!session) {
513
+ emitNotAuthenticated(args.json);
514
+ await exitAfterFlush(ExitCode.NotAuthenticated);
515
+ return null;
516
+ }
517
+ let workspaceId;
518
+ if (args.workspace) {
519
+ const raw = String(args.workspace);
520
+ const parsed = parsePositiveInt(raw);
521
+ if (parsed === null) {
522
+ const message = `--workspace must be a positive integer (got ${raw})`;
523
+ if (args.json) emitJson({
524
+ ok: false,
525
+ reason: "invalid-id",
526
+ message
527
+ });
528
+ else process.stderr.write(`${message}\n`);
529
+ await exitAfterFlush(ExitCode.Usage);
530
+ return null;
531
+ }
532
+ workspaceId = parsed;
533
+ } else workspaceId = session.currentWorkspaceId;
534
+ if (workspaceId === void 0) {
535
+ if (args.json) emitJson({
536
+ ok: false,
537
+ reason: "no-workspace-selected"
538
+ });
539
+ else process.stderr.write("No workspace selected. Pass `--workspace <id>` or run `aitcc workspace use <id>`.\n");
540
+ await exitAfterFlush(ExitCode.Usage);
541
+ return null;
542
+ }
543
+ return {
544
+ session,
545
+ workspaceId
546
+ };
547
+ }
548
+ //#endregion
549
+ //#region src/config/app-manifest.ts
550
+ var ManifestError = class extends Error {
551
+ kind;
552
+ field;
553
+ constructor(kind, message, field) {
554
+ super(message);
555
+ this.name = "ManifestError";
556
+ this.kind = kind;
557
+ this.field = field;
558
+ }
559
+ };
560
+ const DEFAULT_NAMES = ["aitcc.app.yaml", "aitcc.app.json"];
561
+ async function fileExists(path) {
562
+ try {
563
+ await access(path);
564
+ return true;
565
+ } catch {
566
+ return false;
567
+ }
568
+ }
569
+ /**
570
+ * Resolve the manifest file path. When `explicit` is provided, we use it
571
+ * verbatim (resolved against `cwd`) and require it to exist. Otherwise we
572
+ * auto-detect `aitcc.app.yaml` then `aitcc.app.json` under `cwd`.
573
+ */
574
+ async function resolveManifestPath(explicit, cwd) {
575
+ if (explicit) {
576
+ const abs = isAbsolute(explicit) ? explicit : resolve(cwd, explicit);
577
+ if (!await fileExists(abs)) throw new ManifestError("invalid-config", `manifest file not found at ${abs}`);
578
+ return abs;
579
+ }
580
+ for (const name of DEFAULT_NAMES) {
581
+ const abs = resolve(cwd, name);
582
+ if (await fileExists(abs)) return abs;
583
+ }
584
+ throw new ManifestError("invalid-config", `no app manifest found (looked for ${DEFAULT_NAMES.join(", ")} in ${cwd})`);
585
+ }
586
+ async function loadAppManifest(path) {
587
+ return validateManifest(parseManifestFile(path, await readFile(path, "utf8")), dirname(path));
588
+ }
589
+ function parseManifestFile(path, raw) {
590
+ const isJson = path.toLowerCase().endsWith(".json");
591
+ try {
592
+ const out = isJson ? JSON.parse(raw) : parse(raw);
593
+ if (out === null || typeof out !== "object" || Array.isArray(out)) throw new ManifestError("invalid-config", `manifest at ${path} is not a mapping`);
594
+ return out;
595
+ } catch (err) {
596
+ if (err instanceof ManifestError) throw err;
597
+ const msg = err.message;
598
+ throw new ManifestError("invalid-config", `failed to parse manifest at ${path}: ${msg}`);
599
+ }
600
+ }
601
+ function requireString(input, key) {
602
+ const v = input[key];
603
+ if (v === void 0 || v === null) throw new ManifestError("missing-required-field", `${key} is required`, key);
604
+ if (typeof v !== "string") throw new ManifestError("invalid-config", `${key} must be a string`, key);
605
+ if (v.trim().length === 0) throw new ManifestError("missing-required-field", `${key} is required`, key);
606
+ return v;
607
+ }
608
+ function optionalString(input, key) {
609
+ const v = input[key];
610
+ if (v === void 0 || v === null) return void 0;
611
+ if (typeof v !== "string") throw new ManifestError("invalid-config", `${key} must be a string when provided`, key);
612
+ return v;
613
+ }
614
+ function requirePath(input, key, configDir) {
615
+ const rel = requireString(input, key);
616
+ return isAbsolute(rel) ? rel : resolve(configDir, rel);
617
+ }
618
+ function optionalPath(input, key, configDir) {
619
+ const rel = optionalString(input, key);
620
+ if (rel === void 0) return void 0;
621
+ return isAbsolute(rel) ? rel : resolve(configDir, rel);
622
+ }
623
+ function requireNumberArray(input, key, { min }) {
624
+ const v = input[key];
625
+ if (!Array.isArray(v)) throw new ManifestError("invalid-config", `${key} must be an array of numbers`, key);
626
+ if (v.length < min) throw new ManifestError("invalid-config", `${key} must contain at least ${min} item(s)`, key);
627
+ return v.map((item, idx) => {
628
+ if (typeof item !== "number" || !Number.isInteger(item)) throw new ManifestError("invalid-config", `${key}[${idx}] must be an integer`, key);
629
+ return item;
630
+ });
631
+ }
632
+ function optionalStringArray(input, key, { max } = {}) {
633
+ const v = input[key];
634
+ if (v === void 0 || v === null) return [];
635
+ if (!Array.isArray(v)) throw new ManifestError("invalid-config", `${key} must be an array of strings`, key);
636
+ if (max !== void 0 && v.length > max) throw new ManifestError("invalid-config", `${key} accepts at most ${max} entries (got ${v.length})`, key);
637
+ return v.map((item, idx) => {
638
+ if (typeof item !== "string") throw new ManifestError("invalid-config", `${key}[${idx}] must be a string`, key);
639
+ return item;
640
+ });
641
+ }
642
+ function requirePathArray(input, key, configDir, { min }) {
643
+ const v = input[key];
644
+ if (!Array.isArray(v)) throw new ManifestError("invalid-config", `${key} must be an array of paths`, key);
645
+ if (v.length < min) throw new ManifestError("invalid-config", `${key} must contain at least ${min} item(s)`, key);
646
+ return v.map((item, idx) => {
647
+ if (typeof item !== "string" || item.trim().length === 0) throw new ManifestError("invalid-config", `${key}[${idx}] must be a non-empty string`, key);
648
+ return isAbsolute(item) ? item : resolve(configDir, item);
649
+ });
650
+ }
651
+ function optionalPathArray(input, key, configDir) {
652
+ const v = input[key];
653
+ if (v === void 0 || v === null) return [];
654
+ if (!Array.isArray(v)) throw new ManifestError("invalid-config", `${key} must be an array of paths`, key);
655
+ return v.map((item, idx) => {
656
+ if (typeof item !== "string" || item.trim().length === 0) throw new ManifestError("invalid-config", `${key}[${idx}] must be a non-empty string`, key);
657
+ return isAbsolute(item) ? item : resolve(configDir, item);
658
+ });
659
+ }
660
+ const EMAIL_REGEX = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
661
+ function isValidEmail(v) {
662
+ return EMAIL_REGEX.test(v.toLowerCase());
663
+ }
664
+ function isValidHttpUrl(v) {
665
+ try {
666
+ const parsed = new URL(v);
667
+ return parsed.protocol === "http:" || parsed.protocol === "https:";
668
+ } catch {
669
+ return false;
670
+ }
671
+ }
672
+ function validateManifest(raw, configDir) {
673
+ const titleKo = requireString(raw, "titleKo");
674
+ const titleEn = requireString(raw, "titleEn");
675
+ const appName = requireString(raw, "appName");
676
+ const csEmail = requireString(raw, "csEmail");
677
+ if (!isValidEmail(csEmail)) throw new ManifestError("invalid-config", `csEmail is not a valid email address (got ${csEmail})`, "csEmail");
678
+ const subtitle = requireString(raw, "subtitle");
679
+ if (subtitle.length > 20) throw new ManifestError("invalid-config", `subtitle must be 20 characters or fewer (got ${subtitle.length})`, "subtitle");
680
+ const description = requireString(raw, "description");
681
+ const homePageUri = optionalString(raw, "homePageUri");
682
+ if (homePageUri !== void 0 && !isValidHttpUrl(homePageUri)) throw new ManifestError("invalid-config", `homePageUri must be a http(s) URL (got ${homePageUri})`, "homePageUri");
683
+ return {
684
+ titleKo,
685
+ titleEn,
686
+ appName,
687
+ homePageUri,
688
+ csEmail,
689
+ logo: requirePath(raw, "logo", configDir),
690
+ logoDarkMode: optionalPath(raw, "logoDarkMode", configDir),
691
+ horizontalThumbnail: requirePath(raw, "horizontalThumbnail", configDir),
692
+ categoryIds: requireNumberArray(raw, "categoryIds", { min: 1 }),
693
+ subtitle,
694
+ description,
695
+ keywords: optionalStringArray(raw, "keywords", { max: 10 }),
696
+ verticalScreenshots: requirePathArray(raw, "verticalScreenshots", configDir, { min: 3 }),
697
+ horizontalScreenshots: optionalPathArray(raw, "horizontalScreenshots", configDir)
698
+ };
699
+ }
700
+ //#endregion
701
+ //#region src/config/image-validator.ts
702
+ var ImageDimensionError = class extends Error {
703
+ path;
704
+ expected;
705
+ actual;
706
+ reason;
707
+ constructor(args) {
708
+ super(args.message);
709
+ this.name = "ImageDimensionError";
710
+ this.path = args.path;
711
+ this.expected = args.expected;
712
+ this.actual = args.actual;
713
+ this.reason = args.reason;
714
+ }
715
+ };
716
+ function format(dim) {
717
+ return `${dim.width}x${dim.height}`;
718
+ }
719
+ async function validateImageDimensions(path, expected) {
720
+ let buffer;
721
+ try {
722
+ buffer = await readFile(path);
723
+ } catch (err) {
724
+ throw new ImageDimensionError({
725
+ path,
726
+ expected: format(expected),
727
+ actual: void 0,
728
+ reason: "unreadable",
729
+ message: `could not read image at ${path}: ${err.message}`
730
+ });
731
+ }
732
+ let dims;
733
+ try {
734
+ dims = imageSize(buffer);
735
+ } catch (err) {
736
+ throw new ImageDimensionError({
737
+ path,
738
+ expected: format(expected),
739
+ actual: void 0,
740
+ reason: "unreadable",
741
+ message: `could not decode image header at ${path}: ${err.message}`
742
+ });
743
+ }
744
+ if (typeof dims.width !== "number" || typeof dims.height !== "number") throw new ImageDimensionError({
745
+ path,
746
+ expected: format(expected),
747
+ actual: void 0,
748
+ reason: "unreadable",
749
+ message: `image header at ${path} did not expose width/height`
750
+ });
751
+ if (dims.width !== expected.width || dims.height !== expected.height) {
752
+ const actual = `${dims.width}x${dims.height}`;
753
+ throw new ImageDimensionError({
754
+ path,
755
+ expected: format(expected),
756
+ actual,
757
+ reason: "mismatch",
758
+ message: `image ${path} has dimensions ${actual}; expected ${format(expected)}`
759
+ });
760
+ }
761
+ }
762
+ const DIMENSIONS = {
763
+ logo: {
764
+ width: 600,
765
+ height: 600
766
+ },
767
+ horizontalThumbnail: {
768
+ width: 1932,
769
+ height: 828
770
+ },
771
+ verticalScreenshot: {
772
+ width: 636,
773
+ height: 1048
774
+ },
775
+ horizontalScreenshot: {
776
+ width: 1504,
777
+ height: 741
778
+ }
779
+ };
780
+ //#endregion
781
+ //#region src/commands/register-payload.ts
782
+ function buildSubmitPayload(manifest, urls) {
783
+ const images = [
784
+ {
785
+ imageUrl: urls.horizontalThumbnail,
786
+ imageType: "THUMBNAIL",
787
+ orientation: "HORIZONTAL"
788
+ },
789
+ ...urls.verticalScreenshots.map((u) => ({
790
+ imageUrl: u,
791
+ imageType: "PREVIEW",
792
+ orientation: "VERTICAL"
793
+ })),
794
+ ...urls.horizontalScreenshots.map((u) => ({
795
+ imageUrl: u,
796
+ imageType: "PREVIEW",
797
+ orientation: "HORIZONTAL"
798
+ }))
799
+ ];
800
+ return {
801
+ miniApp: {
802
+ title: manifest.titleKo,
803
+ titleEn: manifest.titleEn,
804
+ appName: manifest.appName,
805
+ iconUri: urls.logo,
806
+ status: "PREPARE",
807
+ csEmail: manifest.csEmail,
808
+ description: manifest.subtitle,
809
+ detailDescription: manifest.description,
810
+ images,
811
+ ...urls.logoDarkMode !== void 0 ? { darkModeIconUri: urls.logoDarkMode } : {},
812
+ ...manifest.homePageUri !== void 0 ? { homePageUri: manifest.homePageUri } : {}
813
+ },
814
+ impression: {
815
+ keywordList: manifest.keywords,
816
+ categoryIds: manifest.categoryIds
817
+ }
818
+ };
819
+ }
820
+ //#endregion
821
+ //#region src/commands/register.ts
822
+ async function runRegister(args, deps = {}) {
823
+ const ctx = await resolveWorkspaceContext({
824
+ json: args.json,
825
+ ...args.workspace !== void 0 ? { workspace: args.workspace } : {}
826
+ });
827
+ if (!ctx) return;
828
+ const { session, workspaceId } = ctx;
829
+ const manifest = await loadAndValidateManifest(args, deps);
830
+ if (!manifest) return;
831
+ if (!args.dryRun && !args.acceptTerms) {
832
+ emitTermsNotAccepted(args.json);
833
+ await exitAfterFlush(ExitCode.Usage);
834
+ return;
835
+ }
836
+ try {
837
+ if (args.dryRun) {
838
+ const payload = buildSubmitPayload(manifest, {
839
+ logo: "<dry-run:logo>",
840
+ logoDarkMode: manifest.logoDarkMode !== void 0 ? "<dry-run:logoDarkMode>" : void 0,
841
+ horizontalThumbnail: "<dry-run:horizontalThumbnail>",
842
+ verticalScreenshots: manifest.verticalScreenshots.map((_, i) => `<dry-run:verticalScreenshots[${i}]>`),
843
+ horizontalScreenshots: manifest.horizontalScreenshots.map((_, i) => `<dry-run:horizontalScreenshots[${i}]>`)
844
+ });
845
+ emitDryRun(args.json, workspaceId, payload);
846
+ return exitAfterFlush(ExitCode.Ok);
847
+ }
848
+ const payload = buildSubmitPayload(manifest, await uploadAllImages(workspaceId, manifest, session.cookies, deps));
849
+ const result = await (deps.submitImpl ?? ((wid, p, c) => createMiniApp(wid, p, c)))(workspaceId, payload, session.cookies);
850
+ emitSuccess(args.json, workspaceId, result);
851
+ return exitAfterFlush(ExitCode.Ok);
852
+ } catch (err) {
853
+ return emitFailureAndExit(args.json, err);
854
+ }
855
+ }
856
+ async function loadAndValidateManifest(args, deps) {
857
+ const cwd = deps.cwd ?? process.cwd();
858
+ let manifest;
859
+ try {
860
+ manifest = await loadAppManifest(await resolveManifestPath(args.config, cwd));
861
+ } catch (err) {
862
+ if (err instanceof ManifestError) {
863
+ emitManifestError(args.json, err);
864
+ await exitAfterFlush(ExitCode.Usage);
865
+ return null;
866
+ }
867
+ throw err;
868
+ }
869
+ try {
870
+ await validateImageDimensions(manifest.logo, DIMENSIONS.logo);
871
+ if (manifest.logoDarkMode !== void 0) await validateImageDimensions(manifest.logoDarkMode, DIMENSIONS.logo);
872
+ await validateImageDimensions(manifest.horizontalThumbnail, DIMENSIONS.horizontalThumbnail);
873
+ for (const p of manifest.verticalScreenshots) await validateImageDimensions(p, DIMENSIONS.verticalScreenshot);
874
+ for (const p of manifest.horizontalScreenshots) await validateImageDimensions(p, DIMENSIONS.horizontalScreenshot);
875
+ } catch (err) {
876
+ if (err instanceof ImageDimensionError) {
877
+ emitImageDimensionError(args.json, err);
878
+ await exitAfterFlush(ExitCode.Usage);
879
+ return null;
880
+ }
881
+ throw err;
882
+ }
883
+ return manifest;
884
+ }
885
+ async function uploadAllImages(workspaceId, manifest, cookies, deps) {
886
+ const uploadImpl = deps.uploadImpl ?? ((p) => uploadMiniAppResource(p));
887
+ const logo = await uploadOne(uploadImpl, {
888
+ workspaceId,
889
+ validWidth: DIMENSIONS.logo.width,
890
+ validHeight: DIMENSIONS.logo.height,
891
+ cookies,
892
+ path: manifest.logo
893
+ });
894
+ const logoDarkMode = manifest.logoDarkMode !== void 0 ? await uploadOne(uploadImpl, {
895
+ workspaceId,
896
+ validWidth: DIMENSIONS.logo.width,
897
+ validHeight: DIMENSIONS.logo.height,
898
+ cookies,
899
+ path: manifest.logoDarkMode
900
+ }) : void 0;
901
+ const horizontalThumbnail = await uploadOne(uploadImpl, {
902
+ workspaceId,
903
+ validWidth: DIMENSIONS.horizontalThumbnail.width,
904
+ validHeight: DIMENSIONS.horizontalThumbnail.height,
905
+ cookies,
906
+ path: manifest.horizontalThumbnail
907
+ });
908
+ const verticalScreenshots = [];
909
+ for (const p of manifest.verticalScreenshots) verticalScreenshots.push(await uploadOne(uploadImpl, {
910
+ workspaceId,
911
+ validWidth: DIMENSIONS.verticalScreenshot.width,
912
+ validHeight: DIMENSIONS.verticalScreenshot.height,
913
+ cookies,
914
+ path: p
915
+ }));
916
+ const horizontalScreenshots = [];
917
+ for (const p of manifest.horizontalScreenshots) horizontalScreenshots.push(await uploadOne(uploadImpl, {
918
+ workspaceId,
919
+ validWidth: DIMENSIONS.horizontalScreenshot.width,
920
+ validHeight: DIMENSIONS.horizontalScreenshot.height,
921
+ cookies,
922
+ path: p
923
+ }));
924
+ return {
925
+ logo,
926
+ logoDarkMode,
927
+ horizontalThumbnail,
928
+ verticalScreenshots,
929
+ horizontalScreenshots
930
+ };
931
+ }
932
+ async function uploadOne(uploadImpl, input) {
933
+ const buffer = await readFile(input.path);
934
+ return uploadImpl({
935
+ workspaceId: input.workspaceId,
936
+ validWidth: input.validWidth,
937
+ validHeight: input.validHeight,
938
+ cookies: input.cookies,
939
+ file: {
940
+ buffer,
941
+ fileName: basename(input.path),
942
+ contentType: "image/png"
943
+ }
944
+ });
945
+ }
946
+ function emitManifestError(json, err) {
947
+ if (json) if (err.kind === "missing-required-field") emitJson({
948
+ ok: false,
949
+ reason: "missing-required-field",
950
+ field: err.field ?? null,
951
+ message: err.message
952
+ });
953
+ else emitJson({
954
+ ok: false,
955
+ reason: "invalid-config",
956
+ message: err.message
957
+ });
958
+ else process.stderr.write(`${err.message}\n`);
959
+ }
960
+ function emitImageDimensionError(json, err) {
961
+ if (json) if (err.reason === "mismatch") emitJson({
962
+ ok: false,
963
+ reason: "image-dimension-mismatch",
964
+ path: err.path,
965
+ expected: err.expected,
966
+ actual: err.actual ?? null,
967
+ message: err.message
968
+ });
969
+ else emitJson({
970
+ ok: false,
971
+ reason: "image-unreadable",
972
+ path: err.path,
973
+ message: err.message
974
+ });
975
+ else process.stderr.write(`${err.message}\n`);
976
+ }
977
+ function emitTermsNotAccepted(json) {
978
+ const message = "The console requires several legal-agreement checkboxes before submitting a mini-app for review. Re-run with --accept-terms to attest that you have read and agree to each of them (see VALIDATION-RULES.md or the console UI), or use --dry-run to preview the payload without submitting.";
979
+ if (json) emitJson({
980
+ ok: false,
981
+ reason: "terms-not-accepted",
982
+ message
983
+ });
984
+ else process.stderr.write(`${message}\n`);
985
+ }
986
+ function emitDryRun(json, workspaceId, payload) {
987
+ if (json) emitJson({
988
+ ok: true,
989
+ dryRun: true,
990
+ workspaceId,
991
+ payload
992
+ });
993
+ else {
994
+ process.stdout.write("[dry-run] Would POST to ");
995
+ process.stdout.write(`https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole/workspaces/${workspaceId}/mini-app/review\n`);
996
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
997
+ }
998
+ }
999
+ function emitSuccess(json, workspaceId, result) {
1000
+ if (json) emitJson({
1001
+ ok: true,
1002
+ workspaceId,
1003
+ appId: result.miniAppId ?? null,
1004
+ reviewState: result.reviewState ?? null
1005
+ });
1006
+ else process.stdout.write(`Registered mini-app ${result.miniAppId ?? "(id unknown)"} in workspace ${workspaceId} (reviewState=${result.reviewState ?? "unknown"}).\n`);
1007
+ }
1008
+ async function emitFailureAndExit(json, err) {
1009
+ return emitFailureFromError(json, err);
1010
+ }
1011
+ //#endregion
1012
+ //#region src/commands/app.ts
1013
+ function findReviewEntry(reviewEntries, appId) {
1014
+ const target = String(appId);
1015
+ for (const entry of reviewEntries) {
1016
+ const candidate = entry.id ?? entry.miniAppId ?? entry.appId;
1017
+ if (candidate !== void 0 && String(candidate) === target) return entry;
1018
+ }
1019
+ return null;
1020
+ }
1021
+ function reviewStateFor(entry) {
1022
+ if (!entry) return void 0;
1023
+ const raw = entry.reviewState ?? entry.status;
1024
+ return typeof raw === "string" ? raw : void 0;
1025
+ }
1026
+ const appCommand = defineCommand({
1027
+ meta: {
1028
+ name: "app",
1029
+ description: "Inspect mini-apps in a workspace."
1030
+ },
1031
+ subCommands: {
1032
+ ls: defineCommand({
1033
+ meta: {
1034
+ name: "ls",
1035
+ description: "List mini-apps in the selected workspace."
1036
+ },
1037
+ args: {
1038
+ workspace: {
1039
+ type: "string",
1040
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
1041
+ },
1042
+ json: {
1043
+ type: "boolean",
1044
+ description: "Emit machine-readable JSON to stdout.",
1045
+ default: false
1046
+ }
1047
+ },
1048
+ async run({ args }) {
1049
+ const ctx = await resolveWorkspaceContext(args);
1050
+ if (!ctx) return;
1051
+ const { session, workspaceId } = ctx;
1052
+ try {
1053
+ const [apps, review] = await Promise.all([fetchMiniApps(workspaceId, session.cookies), fetchReviewStatus(workspaceId, session.cookies)]);
1054
+ if (args.json) {
1055
+ const joined = apps.map((app) => {
1056
+ const reviewState = reviewStateFor(findReviewEntry(review.miniApps, app.id));
1057
+ return {
1058
+ id: app.id,
1059
+ name: app.name ?? null,
1060
+ ...reviewState !== void 0 ? { reviewState } : {},
1061
+ extra: app.extra
1062
+ };
1063
+ });
1064
+ emitJson({
1065
+ ok: true,
1066
+ workspaceId,
1067
+ hasPolicyViolation: review.hasPolicyViolation,
1068
+ apps: joined
1069
+ });
1070
+ return exitAfterFlush(ExitCode.Ok);
1071
+ }
1072
+ if (apps.length === 0) {
1073
+ process.stdout.write(`No apps in workspace ${workspaceId}.\n`);
1074
+ if (review.hasPolicyViolation) process.stderr.write("Note: workspace-wide policy violation flag is set.\n");
1075
+ return exitAfterFlush(ExitCode.Ok);
1076
+ }
1077
+ for (const app of apps) {
1078
+ const reviewState = reviewStateFor(findReviewEntry(review.miniApps, app.id)) ?? "-";
1079
+ const name = app.name ?? "(unnamed)";
1080
+ process.stdout.write(`${app.id}\t${name}\t${reviewState}\n`);
1081
+ }
1082
+ if (review.hasPolicyViolation) process.stderr.write("Note: workspace-wide policy violation flag is set.\n");
1083
+ return exitAfterFlush(ExitCode.Ok);
1084
+ } catch (err) {
1085
+ return emitFailureFromError(args.json, err);
1086
+ }
1087
+ }
1088
+ }),
1089
+ register: defineCommand({
1090
+ meta: {
1091
+ name: "register",
1092
+ description: "Register a mini-app in the selected workspace from a YAML/JSON manifest. Uploads logo/thumbnail/screenshots, then submits the create payload."
1093
+ },
1094
+ args: {
1095
+ workspace: {
1096
+ type: "string",
1097
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
1098
+ },
1099
+ config: {
1100
+ type: "string",
1101
+ description: "Path to the app manifest. Defaults to `./aitcc.app.yaml`, then `./aitcc.app.json`."
1102
+ },
1103
+ "dry-run": {
1104
+ type: "boolean",
1105
+ description: "Validate manifest + images and print the inferred submit payload; no uploads.",
1106
+ default: false
1107
+ },
1108
+ "accept-terms": {
1109
+ type: "boolean",
1110
+ description: "Attest to the required console legal-agreement checkboxes (see VALIDATION-RULES.md). Required for real submits.",
1111
+ default: false
1112
+ },
1113
+ json: {
1114
+ type: "boolean",
1115
+ description: "Emit machine-readable JSON to stdout.",
1116
+ default: false
1117
+ }
1118
+ },
1119
+ async run({ args }) {
1120
+ await runRegister({
1121
+ json: args.json,
1122
+ dryRun: args["dry-run"],
1123
+ acceptTerms: args["accept-terms"],
1124
+ ...args.workspace !== void 0 ? { workspace: args.workspace } : {},
1125
+ ...args.config !== void 0 ? { config: args.config } : {}
1126
+ });
1127
+ }
1128
+ })
1129
+ }
1130
+ });
1131
+ //#endregion
1132
+ //#region src/api/api-keys.ts
1133
+ const BASE$1 = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole";
1134
+ async function fetchApiKeys(workspaceId, cookies, opts = {}) {
1135
+ const raw = await requestConsoleApi({
1136
+ url: `${BASE$1}/workspaces/${workspaceId}/api-keys`,
1137
+ cookies,
1138
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
1139
+ });
1140
+ if (!Array.isArray(raw)) throw new Error(`Unexpected api-keys shape for workspace=${workspaceId}: not an array`);
1141
+ return raw.map((entry, index) => normalizeKey(entry, workspaceId, index));
1142
+ }
1143
+ function normalizeKey(raw, workspaceId, index) {
1144
+ if (raw === null || typeof raw !== "object") throw new Error(`Unexpected api-key entry at index ${index} for workspace=${workspaceId}: not an object`);
1145
+ const rec = raw;
1146
+ const rawId = rec.id ?? rec.apiKeyId ?? rec.keyId;
1147
+ if (typeof rawId !== "string" && typeof rawId !== "number") throw new Error(`Unexpected api-key entry at index ${index} for workspace=${workspaceId}: missing id`);
1148
+ const rawName = rec.name ?? rec.apiKeyName ?? rec.keyName ?? rec.description;
1149
+ const name = typeof rawName === "string" ? rawName : void 0;
1150
+ const { id: _id, apiKeyId: _aid, keyId: _kid, name: _n, apiKeyName: _an, keyName: _kn, description: _d, ...extra } = rec;
1151
+ return {
1152
+ id: rawId,
1153
+ name,
1154
+ extra
1155
+ };
1156
+ }
1157
+ const keysCommand = defineCommand({
1158
+ meta: {
1159
+ name: "keys",
1160
+ description: "Inspect console API keys used for deploy automation."
1161
+ },
1162
+ subCommands: { ls: defineCommand({
1163
+ meta: {
1164
+ name: "ls",
1165
+ description: "List console API keys in the selected workspace."
1166
+ },
1167
+ args: {
1168
+ workspace: {
1169
+ type: "string",
1170
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
1171
+ },
1172
+ json: {
1173
+ type: "boolean",
1174
+ description: "Emit machine-readable JSON to stdout.",
1175
+ default: false
1176
+ }
1177
+ },
1178
+ async run({ args }) {
1179
+ const ctx = await resolveWorkspaceContext(args);
1180
+ if (!ctx) return;
1181
+ const { session, workspaceId } = ctx;
1182
+ try {
1183
+ const keys = await fetchApiKeys(workspaceId, session.cookies);
1184
+ if (args.json) {
1185
+ emitJson({
1186
+ ok: true,
1187
+ workspaceId,
1188
+ keys: keys.map((k) => ({
1189
+ id: k.id,
1190
+ name: k.name ?? null,
1191
+ extra: k.extra
1192
+ })),
1193
+ ...keys.length === 0 ? { needsKey: true } : {}
1194
+ });
1195
+ return exitAfterFlush(ExitCode.Ok);
1196
+ }
1197
+ if (keys.length === 0) {
1198
+ process.stdout.write(`No API keys in workspace ${workspaceId}.\n`);
1199
+ process.stderr.write("Hint: issue a key from the console UI (API 키 → 발급받기) to enable deploy automation.\n");
1200
+ return exitAfterFlush(ExitCode.Ok);
1201
+ }
1202
+ process.stdout.write(`${keys.length} API key(s) in workspace ${workspaceId}:\n`);
1203
+ for (const k of keys) {
1204
+ const name = k.name ?? "(unnamed)";
1205
+ process.stdout.write(`${k.id}\t${name}\n`);
1206
+ }
1207
+ return exitAfterFlush(ExitCode.Ok);
1208
+ } catch (err) {
1209
+ return emitFailureFromError(args.json, err);
1210
+ }
1211
+ }
1212
+ }) }
1213
+ });
1214
+ //#endregion
146
1215
  //#region src/api/me.ts
147
1216
  const MEMBER_USER_INFO_URL = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole/members/me/user-info";
148
1217
  async function fetchConsoleMemberUserInfo(cookies, opts = {}) {
@@ -519,102 +1588,6 @@ async function launchChrome(options) {
519
1588
  };
520
1589
  }
521
1590
  //#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
1591
  //#region src/commands/login.ts
619
1592
  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
1593
  const LOGIN_LANDING_HOST = "apps-in-toss.toss.im";
@@ -776,7 +1749,7 @@ const loginCommand = defineCommand({
776
1749
  return exitWith(authFailed ? ExitCode.LoginCookieCaptureFailed : ExitCode.ApiError);
777
1750
  }
778
1751
  const session = {
779
- schemaVersion: 1,
1752
+ schemaVersion: 2,
780
1753
  user: {
781
1754
  id: String(user.id),
782
1755
  email: user.email,
@@ -902,6 +1875,96 @@ const logoutCommand = defineCommand({
902
1875
  }
903
1876
  });
904
1877
  //#endregion
1878
+ //#region src/api/members.ts
1879
+ const BASE = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole";
1880
+ async function fetchWorkspaceMembers(workspaceId, cookies, opts = {}) {
1881
+ const raw = await requestConsoleApi({
1882
+ url: `${BASE}/workspaces/${workspaceId}/members`,
1883
+ cookies,
1884
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
1885
+ });
1886
+ if (!Array.isArray(raw)) throw new Error(`Unexpected members shape for workspace=${workspaceId}: not an array`);
1887
+ return raw.map((entry, index) => normalizeMember(entry, workspaceId, index));
1888
+ }
1889
+ function normalizeMember(raw, workspaceId, index) {
1890
+ if (raw === null || typeof raw !== "object") throw new Error(`Unexpected member entry at index ${index} for workspace=${workspaceId}: not an object`);
1891
+ const rec = raw;
1892
+ const stringField = (k) => {
1893
+ const v = rec[k];
1894
+ if (typeof v !== "string") throw new Error(`Unexpected member entry at index ${index} for workspace=${workspaceId}: missing ${k}`);
1895
+ return v;
1896
+ };
1897
+ const numField = (k) => {
1898
+ const v = rec[k];
1899
+ if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`Unexpected member entry at index ${index} for workspace=${workspaceId}: missing ${k}`);
1900
+ return v;
1901
+ };
1902
+ return {
1903
+ workspaceId: numField("workspaceId"),
1904
+ bizUserNo: numField("bizUserNo"),
1905
+ name: stringField("name"),
1906
+ email: stringField("email"),
1907
+ status: stringField("status"),
1908
+ role: stringField("role"),
1909
+ isOwnerDelegationRequested: Boolean(rec.isOwnerDelegationRequested),
1910
+ isAdult: Boolean(rec.isAdult)
1911
+ };
1912
+ }
1913
+ const membersCommand = defineCommand({
1914
+ meta: {
1915
+ name: "members",
1916
+ description: "Inspect workspace members."
1917
+ },
1918
+ subCommands: { ls: defineCommand({
1919
+ meta: {
1920
+ name: "ls",
1921
+ description: "List members of the selected workspace."
1922
+ },
1923
+ args: {
1924
+ workspace: {
1925
+ type: "string",
1926
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
1927
+ },
1928
+ json: {
1929
+ type: "boolean",
1930
+ description: "Emit machine-readable JSON to stdout.",
1931
+ default: false
1932
+ }
1933
+ },
1934
+ async run({ args }) {
1935
+ const ctx = await resolveWorkspaceContext(args);
1936
+ if (!ctx) return;
1937
+ const { session, workspaceId } = ctx;
1938
+ try {
1939
+ const members = await fetchWorkspaceMembers(workspaceId, session.cookies);
1940
+ if (args.json) {
1941
+ emitJson({
1942
+ ok: true,
1943
+ workspaceId,
1944
+ members: members.map((m) => ({
1945
+ bizUserNo: m.bizUserNo,
1946
+ name: m.name,
1947
+ email: m.email,
1948
+ status: m.status,
1949
+ role: m.role,
1950
+ isOwnerDelegationRequested: m.isOwnerDelegationRequested
1951
+ }))
1952
+ });
1953
+ return exitAfterFlush(ExitCode.Ok);
1954
+ }
1955
+ if (members.length === 0) {
1956
+ process.stdout.write(`No members in workspace ${workspaceId}.\n`);
1957
+ return exitAfterFlush(ExitCode.Ok);
1958
+ }
1959
+ for (const m of members) process.stdout.write(`${m.bizUserNo}\t${m.name}\t${m.email}\t${m.role}\t${m.status}\n`);
1960
+ return exitAfterFlush(ExitCode.Ok);
1961
+ } catch (err) {
1962
+ return emitFailureFromError(args.json, err);
1963
+ }
1964
+ }
1965
+ }) }
1966
+ });
1967
+ //#endregion
905
1968
  //#region src/github.ts
906
1969
  const REPO_OWNER = "apps-in-toss-community";
907
1970
  const REPO_NAME = "console-cli";
@@ -921,6 +1984,29 @@ async function fetchLatestRelease() {
921
1984
  if (!res.ok) throw new Error(`GitHub releases/latest returned ${res.status} ${res.statusText}`);
922
1985
  return await res.json();
923
1986
  }
1987
+ /**
1988
+ * Conditional GET against `releases/latest`. If the server returns 304 we
1989
+ * learn "no change" without consuming a core rate-limit slot. Intended for
1990
+ * the background update check, which re-runs often; `fetchLatestRelease()`
1991
+ * remains the right call when the upgrade command actually needs the body.
1992
+ */
1993
+ async function fetchLatestReleaseConditional(previousEtag) {
1994
+ const url = `https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases/latest`;
1995
+ const headers = defaultHeaders();
1996
+ if (previousEtag && previousEtag.length > 0) headers["If-None-Match"] = previousEtag;
1997
+ const res = await fetch(url, { headers });
1998
+ const etag = res.headers.get("etag") ?? void 0;
1999
+ if (res.status === 304) return {
2000
+ status: "not-modified",
2001
+ etag
2002
+ };
2003
+ if (!res.ok) throw new Error(`GitHub releases/latest returned ${res.status} ${res.statusText}`);
2004
+ return {
2005
+ status: "updated",
2006
+ release: await res.json(),
2007
+ etag
2008
+ };
2009
+ }
924
2010
  function versionFromTag(tag) {
925
2011
  const at = tag.lastIndexOf("@");
926
2012
  const candidate = at >= 0 ? tag.slice(at + 1) : tag;
@@ -991,7 +2077,7 @@ function resolveVersion() {
991
2077
  if (typeof injected === "string" && injected.length > 0) return injected;
992
2078
  } catch {}
993
2079
  try {
994
- return "0.1.4";
2080
+ return "0.1.6";
995
2081
  } catch {}
996
2082
  return "0.0.0-dev";
997
2083
  }
@@ -1137,7 +2223,113 @@ const upgradeCommand = defineCommand({
1137
2223
  }
1138
2224
  });
1139
2225
  //#endregion
2226
+ //#region src/update-check.ts
2227
+ const UPDATE_CHECK_INTERVAL_MS = 1440 * 60 * 1e3;
2228
+ async function readCache() {
2229
+ let raw;
2230
+ try {
2231
+ raw = await readFile(upgradeCheckPath(), "utf8");
2232
+ } catch {
2233
+ return null;
2234
+ }
2235
+ let parsed;
2236
+ try {
2237
+ parsed = JSON.parse(raw);
2238
+ } catch {
2239
+ return null;
2240
+ }
2241
+ if (!parsed || typeof parsed !== "object") return null;
2242
+ const obj = parsed;
2243
+ if (typeof obj.lastCheckedAt !== "string") return null;
2244
+ if (obj.latestTag !== void 0 && typeof obj.latestTag !== "string") return null;
2245
+ if (obj.etag !== void 0 && typeof obj.etag !== "string") return null;
2246
+ return {
2247
+ lastCheckedAt: obj.lastCheckedAt,
2248
+ ...obj.latestTag !== void 0 ? { latestTag: obj.latestTag } : {},
2249
+ ...obj.etag !== void 0 ? { etag: obj.etag } : {}
2250
+ };
2251
+ }
2252
+ async function writeCache(entry) {
2253
+ const path = upgradeCheckPath();
2254
+ await mkdir(dirname(path), { recursive: true });
2255
+ const tmp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 10)}.tmp`;
2256
+ try {
2257
+ await writeFile(tmp, JSON.stringify(entry, null, 2), { mode: 384 });
2258
+ await rename(tmp, path);
2259
+ } catch (err) {
2260
+ await unlink(tmp).catch(() => {});
2261
+ throw err;
2262
+ }
2263
+ }
2264
+ /** Has the throttle window elapsed since the last recorded check? */
2265
+ function isDueForCheck(cache, now = Date.now(), intervalMs = UPDATE_CHECK_INTERVAL_MS) {
2266
+ if (!cache) return true;
2267
+ const last = Date.parse(cache.lastCheckedAt);
2268
+ if (!Number.isFinite(last)) return true;
2269
+ if (now < last) return true;
2270
+ return now - last >= intervalMs;
2271
+ }
2272
+ /**
2273
+ * Perform the throttled update check. Returns the final cache entry (for
2274
+ * testing) or null when skipped. Never throws — network errors are
2275
+ * intentionally swallowed so they never interrupt the foreground command.
2276
+ */
2277
+ async function maybeCheckForUpdate(opts = {}) {
2278
+ const env = opts.env ?? process.env;
2279
+ const isTTY = opts.isTTY ?? Boolean(process.stderr.isTTY);
2280
+ const now = opts.now ?? Date.now();
2281
+ const intervalMs = opts.intervalMs ?? 864e5;
2282
+ const optOut = env.AITCC_NO_UPDATE_CHECK;
2283
+ if (optOut && optOut !== "0" && optOut.toLowerCase() !== "false") return null;
2284
+ if (!isTTY) return null;
2285
+ const cache = await readCache();
2286
+ if (!isDueForCheck(cache, now, intervalMs)) return null;
2287
+ const nowIso = new Date(now).toISOString();
2288
+ const placeholder = {
2289
+ lastCheckedAt: nowIso,
2290
+ ...cache?.latestTag !== void 0 ? { latestTag: cache.latestTag } : {},
2291
+ ...cache?.etag !== void 0 ? { etag: cache.etag } : {}
2292
+ };
2293
+ await writeCache(placeholder).catch(() => {});
2294
+ const previousEtag = cache?.etag;
2295
+ let entry = placeholder;
2296
+ try {
2297
+ const result = await fetchLatestReleaseConditional(previousEtag);
2298
+ if (result.status === "not-modified") entry = {
2299
+ lastCheckedAt: nowIso,
2300
+ ...cache?.latestTag !== void 0 ? { latestTag: cache.latestTag } : {},
2301
+ ...result.etag !== void 0 ? { etag: result.etag } : cache?.etag !== void 0 ? { etag: cache.etag } : {}
2302
+ };
2303
+ else entry = {
2304
+ lastCheckedAt: nowIso,
2305
+ latestTag: result.release.tag_name,
2306
+ ...result.etag !== void 0 ? { etag: result.etag } : {}
2307
+ };
2308
+ await writeCache(entry).catch(() => {});
2309
+ } catch {}
2310
+ maybeEmitNotice(entry, env);
2311
+ return entry;
2312
+ }
2313
+ function maybeEmitNotice(entry, env) {
2314
+ if (!entry.latestTag) return;
2315
+ if (VERSION.startsWith("0.0.0-dev")) return;
2316
+ const latest = versionFromTag(entry.latestTag);
2317
+ if (!latest) return;
2318
+ if (compareSemver(latest, VERSION) <= 0) return;
2319
+ const dim = env.NO_COLOR ? "" : "\x1B[2m";
2320
+ const reset = env.NO_COLOR ? "" : "\x1B[0m";
2321
+ process.stderr.write(`\n${dim}(aitcc ${latest} is available — run \`aitcc upgrade\` to install)${reset}\n`);
2322
+ }
2323
+ //#endregion
1140
2324
  //#region src/commands/whoami.ts
2325
+ async function runBackgroundUpdateCheck(json) {
2326
+ if (json) return;
2327
+ const timeoutMs = 500;
2328
+ await Promise.race([maybeCheckForUpdate().catch(() => null), new Promise((resolve) => {
2329
+ const t = setTimeout(() => resolve(null), timeoutMs);
2330
+ if (typeof t.unref === "function") t.unref();
2331
+ })]);
2332
+ }
1141
2333
  const whoamiCommand = defineCommand({
1142
2334
  meta: {
1143
2335
  name: "whoami",
@@ -1182,6 +2374,7 @@ const whoamiCommand = defineCommand({
1182
2374
  const label = session.user.displayName ? `${session.user.displayName} <${session.user.email}>` : session.user.email;
1183
2375
  process.stdout.write(`Logged in as ${label} (cached)\n`);
1184
2376
  process.stdout.write(`Session captured: ${session.capturedAt}\n`);
2377
+ await runBackgroundUpdateCheck(args.json);
1185
2378
  return exitAfterFlush(ExitCode.Ok);
1186
2379
  }
1187
2380
  try {
@@ -1212,6 +2405,7 @@ const whoamiCommand = defineCommand({
1212
2405
  process.stdout.write("Workspaces:\n");
1213
2406
  for (const w of info.workspaces) process.stdout.write(` - ${w.workspaceName} (id ${w.workspaceId}, ${w.role})\n`);
1214
2407
  }
2408
+ await runBackgroundUpdateCheck(args.json);
1215
2409
  return exitAfterFlush(ExitCode.Ok);
1216
2410
  } catch (err) {
1217
2411
  if (err instanceof TossApiError && err.isAuthError) {
@@ -1244,6 +2438,213 @@ const whoamiCommand = defineCommand({
1244
2438
  }
1245
2439
  });
1246
2440
  //#endregion
2441
+ //#region src/api/workspaces.ts
2442
+ const WORKSPACES_BASE = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole";
2443
+ async function fetchWorkspaceDetail(workspaceId, cookies, opts = {}) {
2444
+ const raw = await requestConsoleApi({
2445
+ url: `${WORKSPACES_BASE}/workspaces/${workspaceId}`,
2446
+ cookies,
2447
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
2448
+ });
2449
+ const id = raw.id;
2450
+ const name = raw.name;
2451
+ if (typeof id !== "number" || !Number.isInteger(id) || id <= 0 || typeof name !== "string") throw new Error(`Unexpected workspace detail shape for id=${workspaceId}`);
2452
+ const { id: _id, name: _name, ...extra } = raw;
2453
+ return {
2454
+ workspaceId: id,
2455
+ workspaceName: name,
2456
+ extra
2457
+ };
2458
+ }
2459
+ //#endregion
2460
+ //#region src/commands/workspace.ts
2461
+ function formatScalar(v) {
2462
+ if (v === null) return "null";
2463
+ if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") return String(v);
2464
+ return JSON.stringify(v);
2465
+ }
2466
+ const workspaceCommand = defineCommand({
2467
+ meta: {
2468
+ name: "workspace",
2469
+ description: "Inspect and switch between the workspaces this account can access."
2470
+ },
2471
+ subCommands: {
2472
+ ls: defineCommand({
2473
+ meta: {
2474
+ name: "ls",
2475
+ description: "List workspaces the current user has access to."
2476
+ },
2477
+ args: { json: {
2478
+ type: "boolean",
2479
+ description: "Emit machine-readable JSON to stdout.",
2480
+ default: false
2481
+ } },
2482
+ async run({ args }) {
2483
+ const session = await readSession();
2484
+ if (!session) {
2485
+ emitNotAuthenticated(args.json);
2486
+ return exitAfterFlush(ExitCode.NotAuthenticated);
2487
+ }
2488
+ try {
2489
+ const info = await fetchConsoleMemberUserInfo(session.cookies);
2490
+ const current = session.currentWorkspaceId;
2491
+ if (args.json) {
2492
+ emitJson({
2493
+ ok: true,
2494
+ workspaces: info.workspaces.map((w) => ({
2495
+ workspaceId: w.workspaceId,
2496
+ workspaceName: w.workspaceName,
2497
+ role: w.role,
2498
+ current: w.workspaceId === current
2499
+ }))
2500
+ });
2501
+ return exitAfterFlush(ExitCode.Ok);
2502
+ }
2503
+ if (info.workspaces.length === 0) {
2504
+ process.stdout.write("No workspaces.\n");
2505
+ return exitAfterFlush(ExitCode.Ok);
2506
+ }
2507
+ for (const w of info.workspaces) {
2508
+ const marker = w.workspaceId === current ? "* " : " ";
2509
+ process.stdout.write(`${marker}${w.workspaceId} ${w.workspaceName} (${w.role})\n`);
2510
+ }
2511
+ if (current === void 0) process.stderr.write("No workspace selected. Run `aitcc workspace use <id>`.\n");
2512
+ return exitAfterFlush(ExitCode.Ok);
2513
+ } catch (err) {
2514
+ return emitFailureFromError(args.json, err);
2515
+ }
2516
+ }
2517
+ }),
2518
+ use: defineCommand({
2519
+ meta: {
2520
+ name: "use",
2521
+ description: "Select the current workspace by ID. Subsequent commands use this."
2522
+ },
2523
+ args: {
2524
+ id: {
2525
+ type: "positional",
2526
+ description: "Workspace ID",
2527
+ required: true
2528
+ },
2529
+ json: {
2530
+ type: "boolean",
2531
+ description: "Emit machine-readable JSON to stdout.",
2532
+ default: false
2533
+ }
2534
+ },
2535
+ async run({ args }) {
2536
+ const raw = String(args.id);
2537
+ const parsed = parsePositiveInt(raw);
2538
+ if (parsed === null) {
2539
+ const message = `workspace id must be a positive integer (got ${raw})`;
2540
+ if (args.json) emitJson({
2541
+ ok: false,
2542
+ reason: "invalid-id",
2543
+ message
2544
+ });
2545
+ else process.stderr.write(`${message}\n`);
2546
+ return exitAfterFlush(ExitCode.Usage);
2547
+ }
2548
+ const session = await readSession();
2549
+ if (!session) {
2550
+ emitNotAuthenticated(args.json);
2551
+ return exitAfterFlush(ExitCode.NotAuthenticated);
2552
+ }
2553
+ try {
2554
+ const match = (await fetchConsoleMemberUserInfo(session.cookies)).workspaces.find((w) => w.workspaceId === parsed);
2555
+ if (!match) {
2556
+ if (args.json) emitJson({
2557
+ ok: false,
2558
+ reason: "not-found",
2559
+ workspaceId: parsed
2560
+ });
2561
+ else process.stderr.write(`Workspace ${parsed} is not accessible from this account. Run \`aitcc workspace ls\` to see available workspaces.\n`);
2562
+ return exitAfterFlush(ExitCode.Usage);
2563
+ }
2564
+ if (await setCurrentWorkspaceId(parsed) === null) {
2565
+ emitNotAuthenticated(args.json);
2566
+ return exitAfterFlush(ExitCode.NotAuthenticated);
2567
+ }
2568
+ if (args.json) emitJson({
2569
+ ok: true,
2570
+ workspaceId: match.workspaceId,
2571
+ workspaceName: match.workspaceName
2572
+ });
2573
+ else process.stdout.write(`Using workspace ${match.workspaceId} (${match.workspaceName}).\n`);
2574
+ return exitAfterFlush(ExitCode.Ok);
2575
+ } catch (err) {
2576
+ return emitFailureFromError(args.json, err);
2577
+ }
2578
+ }
2579
+ }),
2580
+ show: defineCommand({
2581
+ meta: {
2582
+ name: "show",
2583
+ description: "Show details of the selected workspace (or the one passed with --workspace)."
2584
+ },
2585
+ args: {
2586
+ workspace: {
2587
+ type: "string",
2588
+ description: "Workspace ID to inspect. Defaults to the selected workspace."
2589
+ },
2590
+ json: {
2591
+ type: "boolean",
2592
+ description: "Emit machine-readable JSON to stdout.",
2593
+ default: false
2594
+ }
2595
+ },
2596
+ async run({ args }) {
2597
+ const session = await readSession();
2598
+ if (!session) {
2599
+ emitNotAuthenticated(args.json);
2600
+ return exitAfterFlush(ExitCode.NotAuthenticated);
2601
+ }
2602
+ let workspaceId;
2603
+ if (args.workspace) {
2604
+ const raw = String(args.workspace);
2605
+ const parsed = parsePositiveInt(raw);
2606
+ if (parsed === null) {
2607
+ const message = `--workspace must be a positive integer (got ${raw})`;
2608
+ if (args.json) emitJson({
2609
+ ok: false,
2610
+ reason: "invalid-id",
2611
+ message
2612
+ });
2613
+ else process.stderr.write(`${message}\n`);
2614
+ return exitAfterFlush(ExitCode.Usage);
2615
+ }
2616
+ workspaceId = parsed;
2617
+ } else workspaceId = session.currentWorkspaceId;
2618
+ if (workspaceId === void 0) {
2619
+ if (args.json) emitJson({
2620
+ ok: false,
2621
+ reason: "no-workspace-selected"
2622
+ });
2623
+ else process.stderr.write("No workspace selected. Pass `--workspace <id>` or run `aitcc workspace use <id>`.\n");
2624
+ return exitAfterFlush(ExitCode.Usage);
2625
+ }
2626
+ try {
2627
+ const detail = await fetchWorkspaceDetail(workspaceId, session.cookies);
2628
+ if (args.json) {
2629
+ emitJson({
2630
+ ok: true,
2631
+ workspaceId: detail.workspaceId,
2632
+ workspaceName: detail.workspaceName,
2633
+ extra: detail.extra ?? {}
2634
+ });
2635
+ return exitAfterFlush(ExitCode.Ok);
2636
+ }
2637
+ process.stdout.write(`Workspace ${detail.workspaceId}: ${detail.workspaceName}\n`);
2638
+ if (detail.extra) for (const [k, v] of Object.entries(detail.extra)) process.stdout.write(` ${k}: ${formatScalar(v)}\n`);
2639
+ return exitAfterFlush(ExitCode.Ok);
2640
+ } catch (err) {
2641
+ return emitFailureFromError(args.json, err);
2642
+ }
2643
+ }
2644
+ })
2645
+ }
2646
+ });
2647
+ //#endregion
1247
2648
  //#region src/cli.ts
1248
2649
  runMain(defineCommand({
1249
2650
  meta: {
@@ -1255,7 +2656,11 @@ runMain(defineCommand({
1255
2656
  whoami: whoamiCommand,
1256
2657
  login: loginCommand,
1257
2658
  logout: logoutCommand,
1258
- upgrade: upgradeCommand
2659
+ upgrade: upgradeCommand,
2660
+ workspace: workspaceCommand,
2661
+ app: appCommand,
2662
+ members: membersCommand,
2663
+ keys: keysCommand
1259
2664
  }
1260
2665
  }));
1261
2666
  //#endregion