@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/README.md +11 -0
- package/dist/cli.mjs +1509 -104
- package/dist/cli.mjs.map +1 -1
- package/package.json +4 -2
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
|
-
|
|
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
|
|
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:
|
|
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.
|
|
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
|