@ait-co/console-cli 0.1.27 → 0.1.29

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
@@ -8,7 +8,8 @@ import { unzipSync } from "fflate";
8
8
  import { checkbox, confirm, editor, input, password, select } from "@inquirer/prompts";
9
9
  import { imageSize } from "image-size";
10
10
  import { execFile, spawn } from "node:child_process";
11
- import { constants, createReadStream } from "node:fs";
11
+ import { constants, createReadStream, existsSync } from "node:fs";
12
+ import { createInterface } from "node:readline/promises";
12
13
  import { promisify } from "node:util";
13
14
  import { createHash } from "node:crypto";
14
15
  //#region src/api/http.ts
@@ -2448,7 +2449,7 @@ function optionalPathArray(input, key, configDir) {
2448
2449
  });
2449
2450
  }
2450
2451
  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,}))$/;
2451
- function isValidEmail(v) {
2452
+ function isValidEmail$1(v) {
2452
2453
  return EMAIL_REGEX.test(v.toLowerCase());
2453
2454
  }
2454
2455
  const TITLE_KO_REGEX = /^[가-힣A-Za-z0-9 :·?]+$/;
@@ -2510,7 +2511,7 @@ function validateManifest(raw, configDir) {
2510
2511
  }
2511
2512
  const appName = requireString(raw, "appName");
2512
2513
  const csEmail = requireString(raw, "csEmail");
2513
- if (!isValidEmail(csEmail)) throw new ManifestError("invalid-config", `csEmail is not a valid email address (got ${csEmail})`, "csEmail");
2514
+ if (!isValidEmail$1(csEmail)) throw new ManifestError("invalid-config", `csEmail is not a valid email address (got ${csEmail})`, "csEmail");
2514
2515
  const subtitle = requireString(raw, "subtitle");
2515
2516
  if (subtitle.length > MANIFEST_LIMITS.subtitleMaxChars) throw new ManifestError("invalid-config", `subtitle must be ${MANIFEST_LIMITS.subtitleMaxChars} characters or fewer (got ${subtitle.length})`, "subtitle");
2516
2517
  const description = requireString(raw, "description");
@@ -2792,7 +2793,7 @@ function validateAppName(raw) {
2792
2793
  return true;
2793
2794
  }
2794
2795
  function validateEmail(raw) {
2795
- if (!isValidEmail(raw)) return "not a valid email address";
2796
+ if (!isValidEmail$1(raw)) return "not a valid email address";
2796
2797
  return true;
2797
2798
  }
2798
2799
  function validateSubtitle(raw) {
@@ -3601,7 +3602,7 @@ function serviceStatusFor(entry) {
3601
3602
  }
3602
3603
  const POLL_MIN_INTERVAL_SEC = 30;
3603
3604
  const POLL_MAX_INTERVAL_SEC = 3600;
3604
- const statusCommand$1 = defineCommand({
3605
+ const statusCommand$2 = defineCommand({
3605
3606
  meta: {
3606
3607
  name: "status",
3607
3608
  description: "Show the derived review state of a mini-app (under-review / rejected / approved)."
@@ -5702,7 +5703,7 @@ const appCommand = defineCommand({
5702
5703
  }),
5703
5704
  ls: lsCommand$4,
5704
5705
  show: showCommand$2,
5705
- status: statusCommand$1,
5706
+ status: statusCommand$2,
5706
5707
  ratings: ratingsCommand,
5707
5708
  reports: reportsCommand,
5708
5709
  bundles: bundlesCommand,
@@ -8695,6 +8696,46 @@ async function fetchWorkspaceMembers(workspaceId, cookies, opts = {}) {
8695
8696
  if (!Array.isArray(raw)) throw new Error(`Unexpected members shape for workspace=${workspaceId}: not an array`);
8696
8697
  return raw.map((entry, index) => normalizeMember(entry, workspaceId, index));
8697
8698
  }
8699
+ /**
8700
+ * Invite a user by email to the workspace.
8701
+ *
8702
+ * Maps to `POST /workspaces/:wid/invites/send/by-email`. Payload shape is
8703
+ * inferred from static bundle analysis (PR #118); `role` is optional —
8704
+ * omit to use the server default.
8705
+ *
8706
+ * ⚠️ Inferred endpoint: method/path confirmed, payload/response/errorCodes
8707
+ * not live-captured. See docs/api/members.md "Invite 관련 endpoint".
8708
+ */
8709
+ async function inviteMember(workspaceId, email, role, cookies, opts = {}) {
8710
+ const url = `${BASE$1}/workspaces/${workspaceId}/invites/send/by-email`;
8711
+ const body = { email };
8712
+ if (role !== void 0) body.role = role;
8713
+ return { raw: await requestConsoleApi({
8714
+ method: "POST",
8715
+ url,
8716
+ cookies,
8717
+ body,
8718
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
8719
+ }) };
8720
+ }
8721
+ /**
8722
+ * Remove a member from the workspace by their `bizUserNo`.
8723
+ *
8724
+ * Maps to `DELETE /workspaces/:wid/members/:memberBizUserNo`. The path
8725
+ * param name `memberBizUserNo` is confirmed from bundle analysis (PR #118).
8726
+ * Response body shape is uncaptured; we treat any SUCCESS as success.
8727
+ *
8728
+ * ⚠️ Inferred endpoint: method/path confirmed, response/errorCodes not
8729
+ * live-captured. See docs/api/members.md "DELETE …/members/<memberBizUserNo>".
8730
+ */
8731
+ async function removeMember(workspaceId, memberBizUserNo, cookies, opts = {}) {
8732
+ await requestConsoleApi({
8733
+ method: "DELETE",
8734
+ url: `${BASE$1}/workspaces/${workspaceId}/members/${memberBizUserNo}`,
8735
+ cookies,
8736
+ ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
8737
+ });
8738
+ }
8698
8739
  function normalizeMember(raw, workspaceId, index) {
8699
8740
  if (raw === null || typeof raw !== "object") throw new Error(`Unexpected member entry at index ${index} for workspace=${workspaceId}: not an object`);
8700
8741
  const rec = raw;
@@ -8719,60 +8760,185 @@ function normalizeMember(raw, workspaceId, index) {
8719
8760
  isAdult: Boolean(rec.isAdult)
8720
8761
  };
8721
8762
  }
8763
+ //#endregion
8764
+ //#region src/commands/members.ts
8765
+ const lsCommand$2 = defineCommand({
8766
+ meta: {
8767
+ name: "ls",
8768
+ description: "List members of the selected workspace."
8769
+ },
8770
+ args: {
8771
+ workspace: {
8772
+ type: "string",
8773
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
8774
+ },
8775
+ json: {
8776
+ type: "boolean",
8777
+ description: "Emit machine-readable JSON to stdout.",
8778
+ default: false
8779
+ }
8780
+ },
8781
+ async run({ args }) {
8782
+ const ctx = await resolveWorkspaceContext(args);
8783
+ if (!ctx) return;
8784
+ const { session, workspaceId } = ctx;
8785
+ printContextHeader(ctx, { json: args.json });
8786
+ try {
8787
+ const members = await fetchWorkspaceMembers(workspaceId, session.cookies);
8788
+ if (args.json) {
8789
+ emitJson({
8790
+ ok: true,
8791
+ workspaceId,
8792
+ members: members.map((m) => ({
8793
+ bizUserNo: m.bizUserNo,
8794
+ name: m.name,
8795
+ email: m.email,
8796
+ status: m.status,
8797
+ role: m.role,
8798
+ isOwnerDelegationRequested: m.isOwnerDelegationRequested
8799
+ }))
8800
+ });
8801
+ return exitAfterFlush(ExitCode.Ok);
8802
+ }
8803
+ if (members.length === 0) {
8804
+ process.stdout.write(`No members in workspace ${workspaceId}.\n`);
8805
+ return exitAfterFlush(ExitCode.Ok);
8806
+ }
8807
+ for (const m of members) process.stdout.write(`${m.bizUserNo}\t${m.name}\t${m.email}\t${m.role}\t${m.status}\n`);
8808
+ return exitAfterFlush(ExitCode.Ok);
8809
+ } catch (err) {
8810
+ return emitFailureFromError(args.json, err);
8811
+ }
8812
+ }
8813
+ });
8814
+ function isValidEmail(email) {
8815
+ const at = email.indexOf("@");
8816
+ if (at <= 0) return false;
8817
+ const domain = email.slice(at + 1);
8818
+ return domain.length > 0 && domain.includes(".");
8819
+ }
8722
8820
  const membersCommand = defineCommand({
8723
8821
  meta: {
8724
8822
  name: "members",
8725
- description: "Inspect workspace members."
8823
+ description: "Inspect and manage workspace members."
8726
8824
  },
8727
- subCommands: { ls: defineCommand({
8728
- meta: {
8729
- name: "ls",
8730
- description: "List members of the selected workspace."
8731
- },
8732
- args: {
8733
- workspace: {
8734
- type: "string",
8735
- description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
8825
+ subCommands: {
8826
+ ls: lsCommand$2,
8827
+ invite: defineCommand({
8828
+ meta: {
8829
+ name: "invite",
8830
+ description: "Invite a user to the workspace by email."
8736
8831
  },
8737
- json: {
8738
- type: "boolean",
8739
- description: "Emit machine-readable JSON to stdout.",
8740
- default: false
8741
- }
8742
- },
8743
- async run({ args }) {
8744
- const ctx = await resolveWorkspaceContext(args);
8745
- if (!ctx) return;
8746
- const { session, workspaceId } = ctx;
8747
- printContextHeader(ctx, { json: args.json });
8748
- try {
8749
- const members = await fetchWorkspaceMembers(workspaceId, session.cookies);
8750
- if (args.json) {
8751
- emitJson({
8752
- ok: true,
8753
- workspaceId,
8754
- members: members.map((m) => ({
8755
- bizUserNo: m.bizUserNo,
8756
- name: m.name,
8757
- email: m.email,
8758
- status: m.status,
8759
- role: m.role,
8760
- isOwnerDelegationRequested: m.isOwnerDelegationRequested
8761
- }))
8832
+ args: {
8833
+ email: {
8834
+ type: "positional",
8835
+ required: true,
8836
+ description: "Email address of the user to invite."
8837
+ },
8838
+ role: {
8839
+ type: "string",
8840
+ description: "Role to assign (default: server default). Example: MEMBER."
8841
+ },
8842
+ workspace: {
8843
+ type: "string",
8844
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
8845
+ },
8846
+ json: {
8847
+ type: "boolean",
8848
+ description: "Emit machine-readable JSON to stdout.",
8849
+ default: false
8850
+ }
8851
+ },
8852
+ async run({ args }) {
8853
+ const ctx = await resolveWorkspaceContext(args);
8854
+ if (!ctx) return;
8855
+ const { session, workspaceId } = ctx;
8856
+ printContextHeader(ctx, { json: args.json });
8857
+ const email = String(args.email).trim();
8858
+ if (!isValidEmail(email)) {
8859
+ const message = `<email> must be a valid email address (got ${JSON.stringify(email)})`;
8860
+ if (args.json) emitJson({
8861
+ ok: false,
8862
+ reason: "invalid-email",
8863
+ message
8762
8864
  });
8865
+ else process.stderr.write(`${message}\n`);
8866
+ return exitAfterFlush(ExitCode.Usage);
8867
+ }
8868
+ const role = args.role ? String(args.role).trim() : void 0;
8869
+ try {
8870
+ await inviteMember(workspaceId, email, role, session.cookies);
8871
+ if (args.json) {
8872
+ emitJson({
8873
+ ok: true,
8874
+ workspaceId,
8875
+ email
8876
+ });
8877
+ return exitAfterFlush(ExitCode.Ok);
8878
+ }
8879
+ process.stdout.write(`Invited ${email} to workspace ${workspaceId}.\n`);
8763
8880
  return exitAfterFlush(ExitCode.Ok);
8881
+ } catch (err) {
8882
+ return emitFailureFromError(args.json, err);
8883
+ }
8884
+ }
8885
+ }),
8886
+ remove: defineCommand({
8887
+ meta: {
8888
+ name: "remove",
8889
+ description: "Remove a member from the workspace by their bizUserNo."
8890
+ },
8891
+ args: {
8892
+ bizUserNo: {
8893
+ type: "positional",
8894
+ required: true,
8895
+ description: "bizUserNo of the member to remove (from `aitcc members ls`)."
8896
+ },
8897
+ workspace: {
8898
+ type: "string",
8899
+ description: "Workspace ID. Defaults to the selected workspace (`aitcc workspace use`)."
8900
+ },
8901
+ json: {
8902
+ type: "boolean",
8903
+ description: "Emit machine-readable JSON to stdout.",
8904
+ default: false
8764
8905
  }
8765
- if (members.length === 0) {
8766
- process.stdout.write(`No members in workspace ${workspaceId}.\n`);
8906
+ },
8907
+ async run({ args }) {
8908
+ const ctx = await resolveWorkspaceContext(args);
8909
+ if (!ctx) return;
8910
+ const { session, workspaceId } = ctx;
8911
+ printContextHeader(ctx, { json: args.json });
8912
+ const rawId = String(args.bizUserNo);
8913
+ const parsed = parsePositiveInt$1(rawId);
8914
+ if (parsed === null) {
8915
+ const message = `<bizUserNo> must be a positive integer (got ${JSON.stringify(rawId)})`;
8916
+ if (args.json) emitJson({
8917
+ ok: false,
8918
+ reason: "invalid-id",
8919
+ message
8920
+ });
8921
+ else process.stderr.write(`${message}\n`);
8922
+ return exitAfterFlush(ExitCode.Usage);
8923
+ }
8924
+ try {
8925
+ await removeMember(workspaceId, parsed, session.cookies);
8926
+ if (args.json) {
8927
+ emitJson({
8928
+ ok: true,
8929
+ workspaceId,
8930
+ bizUserNo: parsed
8931
+ });
8932
+ return exitAfterFlush(ExitCode.Ok);
8933
+ }
8934
+ process.stdout.write(`Removed member ${parsed} from workspace ${workspaceId}.\n`);
8767
8935
  return exitAfterFlush(ExitCode.Ok);
8936
+ } catch (err) {
8937
+ return emitFailureFromError(args.json, err);
8768
8938
  }
8769
- for (const m of members) process.stdout.write(`${m.bizUserNo}\t${m.name}\t${m.email}\t${m.role}\t${m.status}\n`);
8770
- return exitAfterFlush(ExitCode.Ok);
8771
- } catch (err) {
8772
- return emitFailureFromError(args.json, err);
8773
8939
  }
8774
- }
8775
- }) }
8940
+ })
8941
+ }
8776
8942
  });
8777
8943
  const BASE = "https://api-public.toss.im/api-public/v3/ipd-thor/api/v1";
8778
8944
  async function fetchNotices(params, cookies, opts = {}) {
@@ -9026,6 +9192,393 @@ const noticesCommand = defineCommand({
9026
9192
  }
9027
9193
  });
9028
9194
  //#endregion
9195
+ //#region src/version.ts
9196
+ function resolveVersion() {
9197
+ try {
9198
+ const injected = globalThis.AITCC_VERSION;
9199
+ if (typeof injected === "string" && injected.length > 0) return injected;
9200
+ } catch {}
9201
+ try {
9202
+ return "0.1.29";
9203
+ } catch {}
9204
+ return "0.0.0-dev";
9205
+ }
9206
+ const VERSION = resolveVersion();
9207
+ //#endregion
9208
+ //#region src/telemetry/state.ts
9209
+ /**
9210
+ * Telemetry consent state + anon_id I/O for console-cli.
9211
+ *
9212
+ * Storage: ~/.config/aitcc/telemetry.json (0600, XDG-aware via configDir())
9213
+ * Consistent with devtools' localStorage schema names where applicable.
9214
+ */
9215
+ /** Current policy version. Bump whenever the privacy policy changes. */
9216
+ const CURRENT_POLICY_VERSION = "2026-05-12";
9217
+ function telemetryFilePath() {
9218
+ return join(configDir(), "telemetry.json");
9219
+ }
9220
+ async function readStateFile() {
9221
+ let raw;
9222
+ try {
9223
+ raw = await readFile(telemetryFilePath(), "utf8");
9224
+ } catch {
9225
+ return null;
9226
+ }
9227
+ let parsed;
9228
+ try {
9229
+ parsed = JSON.parse(raw);
9230
+ } catch {
9231
+ return null;
9232
+ }
9233
+ if (!parsed || typeof parsed !== "object") return null;
9234
+ const obj = parsed;
9235
+ if (obj.schemaVersion !== 1) return null;
9236
+ if (obj.consent !== "granted" && obj.consent !== "denied" && obj.consent !== "undecided") return null;
9237
+ if (typeof obj.policyVersion !== "string") return null;
9238
+ return {
9239
+ schemaVersion: 1,
9240
+ consent: obj.consent,
9241
+ policyVersion: obj.policyVersion,
9242
+ ...typeof obj.anonId === "string" ? { anonId: obj.anonId } : {}
9243
+ };
9244
+ }
9245
+ async function writeStateFile(state) {
9246
+ const path = telemetryFilePath();
9247
+ await mkdir(dirname(path), {
9248
+ recursive: true,
9249
+ mode: 448
9250
+ });
9251
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
9252
+ try {
9253
+ await writeFile(tmp, JSON.stringify(state, null, 2), { mode: 384 });
9254
+ await rename(tmp, path);
9255
+ } catch (err) {
9256
+ await unlink(tmp).catch(() => {});
9257
+ throw err;
9258
+ }
9259
+ }
9260
+ /** Read the raw consent from disk. Returns 'undecided' if no file. */
9261
+ async function readConsentState() {
9262
+ const s = await readStateFile();
9263
+ if (!s) return "undecided";
9264
+ return s.consent;
9265
+ }
9266
+ /**
9267
+ * Resolve effective consent with policy-version bump rule:
9268
+ * - Previously 'granted' but on an old policy version → revert to 'undecided'
9269
+ * - Previously 'denied' on any version → stay 'denied'
9270
+ */
9271
+ async function resolveEffectiveConsent() {
9272
+ const s = await readStateFile();
9273
+ if (!s) return "undecided";
9274
+ if (s.consent === "granted") {
9275
+ if (s.policyVersion !== "2026-05-12") return "undecided";
9276
+ return "granted";
9277
+ }
9278
+ return s.consent;
9279
+ }
9280
+ /**
9281
+ * Returns the stored anon_id, or generates + persists a new UUID v4.
9282
+ * Once generated it is never overwritten except after a successful deleteMyData call.
9283
+ */
9284
+ async function getOrCreateAnonId() {
9285
+ const s = await readStateFile();
9286
+ if (s?.anonId) return s.anonId;
9287
+ const id = crypto.randomUUID();
9288
+ await writeStateFile({
9289
+ ...s ?? {
9290
+ schemaVersion: 1,
9291
+ consent: "undecided",
9292
+ policyVersion: "2026-05-12"
9293
+ },
9294
+ anonId: id
9295
+ });
9296
+ return id;
9297
+ }
9298
+ async function acceptConsent() {
9299
+ await writeStateFile({
9300
+ schemaVersion: 1,
9301
+ consent: "granted",
9302
+ policyVersion: CURRENT_POLICY_VERSION,
9303
+ anonId: (await readStateFile())?.anonId ?? crypto.randomUUID()
9304
+ });
9305
+ }
9306
+ async function denyConsent() {
9307
+ const s = await readStateFile();
9308
+ await writeStateFile({
9309
+ schemaVersion: 1,
9310
+ consent: "denied",
9311
+ policyVersion: CURRENT_POLICY_VERSION,
9312
+ ...s?.anonId ? { anonId: s.anonId } : {}
9313
+ });
9314
+ }
9315
+ /**
9316
+ * Delete data: send DELETE /e?anon_id=... to the server (if we have an id),
9317
+ * then rotate local anon_id so subsequent events are unlinkable.
9318
+ */
9319
+ async function deleteMyData(endpoint) {
9320
+ const s = await readStateFile();
9321
+ if (!s?.anonId) return false;
9322
+ try {
9323
+ if (!(await fetch(`${endpoint}/e?anon_id=${encodeURIComponent(s.anonId)}`, { method: "DELETE" })).ok) return false;
9324
+ await writeStateFile({
9325
+ ...s,
9326
+ anonId: crypto.randomUUID()
9327
+ });
9328
+ return true;
9329
+ } catch {
9330
+ return false;
9331
+ }
9332
+ }
9333
+ //#endregion
9334
+ //#region src/telemetry/send.ts
9335
+ /**
9336
+ * Telemetry send — fire-and-forget with one retry.
9337
+ *
9338
+ * Rules:
9339
+ * 1. If consent ≠ "granted" — drop silently.
9340
+ * 2. POST event as JSON with 5 s timeout.
9341
+ * 3. On network error or non-2xx: retry ONCE after 2 s. On second failure: drop.
9342
+ * 4. Meta is capped at 256 bytes (JSON-serialized); oversized meta is dropped.
9343
+ * 5. All calls are non-blocking — caller never awaits send().
9344
+ */
9345
+ /** Meta size cap per server contract (JSON bytes). */
9346
+ const META_BYTE_CAP = 256;
9347
+ function sanitizeMeta(meta) {
9348
+ if (meta === void 0) return void 0;
9349
+ const serialized = JSON.stringify(meta);
9350
+ if (new TextEncoder().encode(serialized).byteLength > META_BYTE_CAP) return void 0;
9351
+ return meta;
9352
+ }
9353
+ async function doFetch(endpoint, payload) {
9354
+ const controller = new AbortController();
9355
+ const timeoutId = setTimeout(() => controller.abort(), 5e3);
9356
+ try {
9357
+ return (await fetch(`${endpoint}/e`, {
9358
+ method: "POST",
9359
+ headers: { "Content-Type": "application/json" },
9360
+ body: JSON.stringify(payload),
9361
+ signal: controller.signal
9362
+ })).ok;
9363
+ } catch {
9364
+ return false;
9365
+ } finally {
9366
+ clearTimeout(timeoutId);
9367
+ }
9368
+ }
9369
+ function delay(ms) {
9370
+ return new Promise((resolve) => setTimeout(resolve, ms));
9371
+ }
9372
+ /** Retry delay in ms — injectable for tests. */
9373
+ let RETRY_DELAY_MS = 2e3;
9374
+ /**
9375
+ * Send a telemetry event. Drops silently if consent is not 'granted'.
9376
+ * Returns a Promise but callers should NOT await it — fire-and-forget only.
9377
+ */
9378
+ async function send(endpoint, event, version, meta) {
9379
+ if (await readConsentState() !== "granted") return;
9380
+ const sanitized = sanitizeMeta(meta);
9381
+ const payload = {
9382
+ source: "console-cli",
9383
+ event,
9384
+ anon_id: await getOrCreateAnonId(),
9385
+ version,
9386
+ ts: Date.now(),
9387
+ ...sanitized !== void 0 ? { meta: sanitized } : {}
9388
+ };
9389
+ if (await doFetch(endpoint, payload)) return;
9390
+ await delay(RETRY_DELAY_MS);
9391
+ await doFetch(endpoint, payload);
9392
+ }
9393
+ //#endregion
9394
+ //#region src/telemetry/index.ts
9395
+ /**
9396
+ * Telemetry client — internal to @ait-co/console-cli.
9397
+ *
9398
+ * Usage: import { trackInvocation, trackInstall } from './telemetry/index.js'
9399
+ *
9400
+ * Events are fire-and-forget (non-blocking). Consent is opt-in only.
9401
+ * First invocation on a TTY prompts the user; non-TTY (CI) defaults to deny.
9402
+ *
9403
+ * Endpoint override for staging: AITCC_TELEMETRY_ENV=staging
9404
+ * (or automatically when VERSION contains '-dev').
9405
+ */
9406
+ function resolveEndpoint() {
9407
+ if (process.env.AITCC_TELEMETRY_ENV === "staging") return "https://t-staging.aitc.dev";
9408
+ if (VERSION.includes("-dev")) return "https://t-staging.aitc.dev";
9409
+ return "https://t.aitc.dev";
9410
+ }
9411
+ const TELEMETRY_ENDPOINT = resolveEndpoint();
9412
+ /** Returns true if this is the very first run (telemetry.json does not exist). */
9413
+ function isFirstRun() {
9414
+ return !existsSync(telemetryFilePath());
9415
+ }
9416
+ /**
9417
+ * Prompt for consent on TTY. Defaults to deny on any non-TTY or error.
9418
+ * Called once per install (no file yet) when stdin/stdout are both TTYs.
9419
+ */
9420
+ async function promptConsent() {
9421
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
9422
+ await denyConsent();
9423
+ return;
9424
+ }
9425
+ const rl = createInterface({
9426
+ input: process.stdin,
9427
+ output: process.stdout
9428
+ });
9429
+ try {
9430
+ process.stderr.write([
9431
+ "",
9432
+ "aitcc 사용 개선을 위해 익명 사용 통계를 수집해도 될까요?",
9433
+ " · 개인 식별 정보 없음 · 랜덤 익명 ID만 사용 · 언제든 off 가능",
9434
+ " 자세한 내용: https://docs.aitc.dev/privacy",
9435
+ ""
9436
+ ].join("\n"));
9437
+ if ((await rl.question("보내도 될까요? [y/N] ")).trim().toLowerCase() === "y") await acceptConsent();
9438
+ else await denyConsent();
9439
+ } catch {
9440
+ await denyConsent();
9441
+ } finally {
9442
+ rl.close();
9443
+ }
9444
+ }
9445
+ /** True only on the first invocation after a fresh install. */
9446
+ async function isNewInstall() {
9447
+ const markerPath = `${telemetryFilePath()}.install`;
9448
+ if (existsSync(markerPath)) return false;
9449
+ try {
9450
+ await writeFile(markerPath, "1", { mode: 384 });
9451
+ return true;
9452
+ } catch {
9453
+ return false;
9454
+ }
9455
+ }
9456
+ /**
9457
+ * Called at CLI entry point with the resolved top-level command name.
9458
+ * Handles first-run consent prompt, install detection, and event send.
9459
+ * Fire-and-forget: do NOT await this.
9460
+ */
9461
+ async function trackInvocation(command) {
9462
+ try {
9463
+ if (isFirstRun()) await promptConsent();
9464
+ if (await resolveEffectiveConsent() !== "granted") return;
9465
+ if (await isNewInstall()) send(TELEMETRY_ENDPOINT, "cli_install", VERSION, {
9466
+ platform: process.platform,
9467
+ arch: process.arch
9468
+ });
9469
+ send(TELEMETRY_ENDPOINT, "cli_invoked", VERSION, { command });
9470
+ } catch {}
9471
+ }
9472
+ const telemetryCommand = defineCommand({
9473
+ meta: {
9474
+ name: "telemetry",
9475
+ description: "Manage anonymous usage telemetry (opt-in)."
9476
+ },
9477
+ subCommands: {
9478
+ status: defineCommand({
9479
+ meta: {
9480
+ name: "status",
9481
+ description: "Show current telemetry consent state and anon_id."
9482
+ },
9483
+ args: { json: {
9484
+ type: "boolean",
9485
+ description: "Emit machine-readable JSON.",
9486
+ default: false
9487
+ } },
9488
+ async run({ args }) {
9489
+ const consent = await resolveEffectiveConsent();
9490
+ const anonId = consent === "granted" ? await getOrCreateAnonId() : null;
9491
+ const filePath = telemetryFilePath();
9492
+ if (args.json) {
9493
+ process.stdout.write(`${JSON.stringify({
9494
+ ok: true,
9495
+ consent,
9496
+ policyVersion: CURRENT_POLICY_VERSION,
9497
+ endpoint: TELEMETRY_ENDPOINT,
9498
+ ...anonId ? { anonId } : {},
9499
+ filePath
9500
+ })}\n`);
9501
+ return exitAfterFlush(ExitCode.Ok);
9502
+ }
9503
+ process.stdout.write(`Telemetry: ${consent}\n`);
9504
+ process.stdout.write(`Policy version: ${CURRENT_POLICY_VERSION}\n`);
9505
+ process.stdout.write(`Endpoint: ${TELEMETRY_ENDPOINT}\n`);
9506
+ if (anonId) process.stdout.write(`Anon ID: ${anonId}\n`);
9507
+ process.stdout.write(`State file: ${filePath}\n`);
9508
+ return exitAfterFlush(ExitCode.Ok);
9509
+ }
9510
+ }),
9511
+ enable: defineCommand({
9512
+ meta: {
9513
+ name: "enable",
9514
+ description: "Enable anonymous usage telemetry (opt-in)."
9515
+ },
9516
+ args: { json: {
9517
+ type: "boolean",
9518
+ description: "Emit machine-readable JSON.",
9519
+ default: false
9520
+ } },
9521
+ async run({ args }) {
9522
+ await acceptConsent();
9523
+ const anonId = await getOrCreateAnonId();
9524
+ if (args.json) process.stdout.write(`${JSON.stringify({
9525
+ ok: true,
9526
+ consent: "granted",
9527
+ anonId
9528
+ })}\n`);
9529
+ else {
9530
+ process.stdout.write("Telemetry enabled. Thank you!\n");
9531
+ process.stdout.write(`Anon ID: ${anonId}\n`);
9532
+ }
9533
+ return exitAfterFlush(ExitCode.Ok);
9534
+ }
9535
+ }),
9536
+ disable: defineCommand({
9537
+ meta: {
9538
+ name: "disable",
9539
+ description: "Disable anonymous usage telemetry."
9540
+ },
9541
+ args: { json: {
9542
+ type: "boolean",
9543
+ description: "Emit machine-readable JSON.",
9544
+ default: false
9545
+ } },
9546
+ async run({ args }) {
9547
+ await denyConsent();
9548
+ if (args.json) process.stdout.write(`${JSON.stringify({
9549
+ ok: true,
9550
+ consent: "denied"
9551
+ })}\n`);
9552
+ else process.stdout.write("Telemetry disabled.\n");
9553
+ return exitAfterFlush(ExitCode.Ok);
9554
+ }
9555
+ }),
9556
+ delete: defineCommand({
9557
+ meta: {
9558
+ name: "delete",
9559
+ description: "Delete your telemetry data from the server and rotate the local anon_id."
9560
+ },
9561
+ args: { json: {
9562
+ type: "boolean",
9563
+ description: "Emit machine-readable JSON.",
9564
+ default: false
9565
+ } },
9566
+ async run({ args }) {
9567
+ const beforeConsent = await readConsentState();
9568
+ const ok = await deleteMyData(TELEMETRY_ENDPOINT);
9569
+ if (args.json) process.stdout.write(`${JSON.stringify({
9570
+ ok,
9571
+ ...ok ? {} : { reason: beforeConsent === "undecided" ? "no-data" : "server-error" }
9572
+ })}\n`);
9573
+ else if (ok) process.stdout.write("Deletion request sent. Your data has been removed and a new anon ID assigned.\n");
9574
+ else if (beforeConsent === "undecided") process.stdout.write("No telemetry data to delete (telemetry was never enabled).\n");
9575
+ else process.stderr.write("Deletion request failed. Please try again or contact the maintainers.\n");
9576
+ return exitAfterFlush(ok || beforeConsent === "undecided" ? ExitCode.Ok : ExitCode.NetworkError);
9577
+ }
9578
+ })
9579
+ }
9580
+ });
9581
+ //#endregion
9029
9582
  //#region src/github.ts
9030
9583
  const REPO_OWNER = "apps-in-toss-community";
9031
9584
  const REPO_NAME = "console-cli";
@@ -9159,19 +9712,6 @@ function sha256OfFile(path) {
9159
9712
  });
9160
9713
  }
9161
9714
  //#endregion
9162
- //#region src/version.ts
9163
- function resolveVersion() {
9164
- try {
9165
- const injected = globalThis.AITCC_VERSION;
9166
- if (typeof injected === "string" && injected.length > 0) return injected;
9167
- } catch {}
9168
- try {
9169
- return "0.1.27";
9170
- } catch {}
9171
- return "0.0.0-dev";
9172
- }
9173
- const VERSION = resolveVersion();
9174
- //#endregion
9175
9715
  //#region src/commands/upgrade.ts
9176
9716
  const execFileP = promisify(execFile);
9177
9717
  function isStandaloneBinary() {
@@ -10319,10 +10859,12 @@ const main = defineCommand({
10319
10859
  keys: keysCommand,
10320
10860
  notices: noticesCommand,
10321
10861
  me: meCommand,
10862
+ telemetry: telemetryCommand,
10322
10863
  completion: completionCommand
10323
10864
  }
10324
10865
  });
10325
10866
  cleanupStaleUpgradeArtifacts().catch(() => {});
10867
+ trackInvocation(process.argv.slice(2).find((a) => !a.startsWith("-")) ?? "(none)");
10326
10868
  runMain(main);
10327
10869
  //#endregion
10328
10870
  export {};