@ait-co/console-cli 0.1.35 → 0.1.37

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.en.md CHANGED
@@ -111,6 +111,43 @@ Pass `--interactive` to force the visible-browser flow even when credentials are
111
111
 
112
112
  The CLI looks for Chrome in the standard OS install locations (Google Chrome, Chromium, Microsoft Edge). Override the executable with `AITCC_BROWSER=/path/to/chrome` if your install is elsewhere; override the sign-in URL with `AITCC_OAUTH_URL` if you need to point at a staging environment. `--timeout <seconds>` controls how long the CLI will wait for sign-in to finish (default 300s).
113
113
 
114
+ ## Using aitcc in SSH / headless environments
115
+
116
+ macOS Keychain access is blocked in SSH remote sessions and GUI-less servers, so `--save keychain` will fail there. Three workarounds are available:
117
+
118
+ **Option 1 — Export / import the session (recommended, requires a KR IP)**
119
+
120
+ Export the session on a desktop GUI Mac and inject it into the SSH environment:
121
+
122
+ ```sh
123
+ # On a desktop Mac that is already logged in:
124
+ aitcc auth export --format env # outputs: AITCC_SESSION=...
125
+
126
+ # In the SSH environment:
127
+ AITCC_SESSION='...' aitcc app deploy ./bundle.ait --json
128
+ ```
129
+
130
+ **Option 2 — Unlock the keychain**
131
+
132
+ Unlock the keychain in the same SSH session, then retry:
133
+
134
+ ```sh
135
+ security unlock-keychain ~/Library/Keychains/login.keychain-db
136
+ # (enter login password when prompted)
137
+ aitcc login --save keychain --email you@example.com --password-stdin
138
+ ```
139
+
140
+ **Option 3 — File backend**
141
+
142
+ Store credentials in a plain file instead of the keychain. Assumes a single-user machine; FileVault (full-disk encryption) is strongly recommended:
143
+
144
+ ```sh
145
+ aitcc login --save=file --email you@example.com --password-stdin
146
+ # → stored in ~/.config/aitcc/credentials.json (perm 0600)
147
+ ```
148
+
149
+ After saving, subsequent `aitcc login` runs in the SSH environment will read this file and sign in headlessly. Override the path with the `AITCC_CREDENTIAL_FILE` environment variable. See [issue #176](https://github.com/apps-in-toss-community/console-cli/issues/176) for background.
150
+
114
151
  ## Session storage
115
152
 
116
153
  The local session lives at an XDG-compliant path with file mode `0600`:
package/README.md CHANGED
@@ -111,6 +111,43 @@ aitcc app status # 플래그 없이 동작 — aitcc.yaml에서 컨텍
111
111
 
112
112
  CLI는 OS 표준 위치(Google Chrome, Chromium, Microsoft Edge)에서 Chrome을 찾습니다. 다른 곳에 설치돼 있으면 `AITCC_BROWSER=/path/to/chrome`으로 실행 파일을 지정하고, 스테이징 환경을 가리키려면 `AITCC_OAUTH_URL`로 로그인 URL을 override합니다. `--timeout <초>`로 로그인 대기 시간을 조절합니다 (기본 300초).
113
113
 
114
+ ## SSH / headless 환경에서 로그인
115
+
116
+ SSH 원격 세션이나 GUI가 없는 서버에서는 macOS Keychain 접근이 막혀 `--save keychain`이 실패할 수 있습니다. 세 가지 우회 방법이 있습니다:
117
+
118
+ **방법 1 — 세션 export/import (권장, KR IP 필요)**
119
+
120
+ Desktop GUI Mac에서 세션을 export해 SSH 환경에 주입합니다:
121
+
122
+ ```sh
123
+ # Desktop (GUI Mac, 이미 로그인된 상태):
124
+ aitcc auth export --format env # → AITCC_SESSION=... 출력
125
+
126
+ # SSH 환경에서:
127
+ AITCC_SESSION='...' aitcc app deploy ./bundle.ait --json
128
+ ```
129
+
130
+ **방법 2 — keychain unlock**
131
+
132
+ 같은 SSH 세션에서 keychain을 잠금 해제한 뒤 재시도합니다:
133
+
134
+ ```sh
135
+ security unlock-keychain ~/Library/Keychains/login.keychain-db
136
+ # (login 비밀번호 입력)
137
+ aitcc login --save keychain --email you@example.com --password-stdin
138
+ ```
139
+
140
+ **방법 3 — 파일 backend**
141
+
142
+ Keychain 대신 평문 파일에 자격증명을 저장합니다. 단일 사용자 머신 전제이며 FileVault(디스크 암호화) 사용을 권장합니다:
143
+
144
+ ```sh
145
+ aitcc login --save=file --email you@example.com --password-stdin
146
+ # → ~/.config/aitcc/credentials.json (perm 0600)에 저장
147
+ ```
148
+
149
+ 저장 후 SSH 환경에서 `aitcc login`이 자동으로 이 파일을 읽어 headless 로그인을 진행합니다. 경로를 바꾸려면 `AITCC_CREDENTIAL_FILE` env var를 지정합니다. 자세한 내용은 [issue #176](https://github.com/apps-in-toss-community/console-cli/issues/176) 참조.
150
+
114
151
  ## 세션 저장
115
152
 
116
153
  로컬 세션은 XDG 규약 경로에 mode `0600`으로 저장됩니다:
package/dist/cli.mjs CHANGED
@@ -7,8 +7,8 @@ import { isMap, parse, parseDocument } from "yaml";
7
7
  import { unzipSync } from "fflate";
8
8
  import { checkbox, confirm, editor, input, password, select } from "@inquirer/prompts";
9
9
  import { imageSize } from "image-size";
10
- import { execFile, spawn } from "node:child_process";
11
- import { constants, createReadStream, existsSync } from "node:fs";
10
+ import { execFile, execFileSync, spawn } from "node:child_process";
11
+ import { constants, createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { createInterface } from "node:readline/promises";
13
13
  import { promisify } from "node:util";
14
14
  import { createHash } from "node:crypto";
@@ -682,14 +682,15 @@ async function postBundleTestPush(workspaceId, miniAppId, deploymentId, cookies,
682
682
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
683
683
  return raw;
684
684
  }
685
- async function fetchBundleTestLinks(workspaceId, miniAppId, cookies, opts = {}) {
685
+ async function fetchBundleTestLinks(workspaceId, miniAppId, deploymentId, cookies, opts = {}) {
686
686
  const raw = await requestConsoleApi({
687
- url: `${BASE$4}/workspaces/${workspaceId}/mini-app/${miniAppId}/bundles/test-links`,
687
+ url: `${BASE$4}/workspaces/${workspaceId}/mini-app/${miniAppId}/bundles/test-links?deploymentId=${encodeURIComponent(deploymentId)}`,
688
688
  cookies,
689
689
  ...opts.fetchImpl ? { fetchImpl: opts.fetchImpl } : {}
690
690
  });
691
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
692
- return raw;
691
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) throw new Error(`Unexpected test-links shape for app=${miniAppId}`);
692
+ const data = raw;
693
+ return { linkUri: typeof data.linkUri === "string" ? data.linkUri : "" };
693
694
  }
694
695
  async function createMiniApp(workspaceId, payload, cookies, opts = {}) {
695
696
  return normalizeCreateResult(await requestConsoleApi({
@@ -4527,7 +4528,7 @@ const bundlesCommand = defineCommand({
4527
4528
  "test-links": defineCommand({
4528
4529
  meta: {
4529
4530
  name: "test-links",
4530
- description: "Show per-device test URLs for the mini-app."
4531
+ description: "Show the test deep-link URL for a specific bundle."
4531
4532
  },
4532
4533
  args: {
4533
4534
  id: {
@@ -4535,6 +4536,10 @@ const bundlesCommand = defineCommand({
4535
4536
  description: "Mini-app ID. Optional when `aitcc.yaml` provides `miniAppId`.",
4536
4537
  required: false
4537
4538
  },
4539
+ "deployment-id": {
4540
+ type: "string",
4541
+ description: "deploymentId of the bundle to fetch the test link for."
4542
+ },
4538
4543
  workspace: {
4539
4544
  type: "string",
4540
4545
  description: "Workspace ID. Defaults to the selected workspace."
@@ -4546,6 +4551,15 @@ const bundlesCommand = defineCommand({
4546
4551
  }
4547
4552
  },
4548
4553
  async run({ args }) {
4554
+ const deploymentId = typeof args["deployment-id"] === "string" ? args["deployment-id"] : "";
4555
+ if (deploymentId === "") {
4556
+ if (args.json) emitJson({
4557
+ ok: false,
4558
+ reason: "missing-deployment-id"
4559
+ });
4560
+ else process.stderr.write("app bundles test-links: --deployment-id <uuid> is required.\n");
4561
+ return exitAfterFlush(ExitCode.Usage);
4562
+ }
4549
4563
  const ctx = await resolveAppOrFail({
4550
4564
  json: args.json,
4551
4565
  appIdRaw: args.id,
@@ -4557,26 +4571,22 @@ const bundlesCommand = defineCommand({
4557
4571
  printContextHeader(ctx, { json: args.json });
4558
4572
  const { session, workspaceId } = ctx;
4559
4573
  try {
4560
- const links = await fetchBundleTestLinks(workspaceId, appId, session.cookies);
4574
+ const { linkUri } = await fetchBundleTestLinks(workspaceId, appId, deploymentId, session.cookies);
4561
4575
  if (args.json) {
4562
4576
  emitJson({
4563
4577
  ok: true,
4564
4578
  workspaceId,
4565
4579
  appId,
4566
- links
4580
+ deploymentId,
4581
+ linkUri
4567
4582
  });
4568
4583
  return exitAfterFlush(ExitCode.Ok);
4569
4584
  }
4570
- const keys = Object.keys(links);
4571
- if (keys.length === 0) {
4572
- process.stdout.write(`App ${appId} (ws ${workspaceId}): no test links available\n`);
4585
+ if (linkUri === "") {
4586
+ process.stdout.write(`App ${appId} (ws ${workspaceId}): no test link available for deployment ${deploymentId}\n`);
4573
4587
  return exitAfterFlush(ExitCode.Ok);
4574
4588
  }
4575
- process.stdout.write(`App ${appId} (ws ${workspaceId}):\n`);
4576
- for (const k of keys) {
4577
- const v = links[k];
4578
- process.stdout.write(` ${k}\t${typeof v === "string" ? v : JSON.stringify(v)}\n`);
4579
- }
4589
+ process.stdout.write(`App ${appId} (ws ${workspaceId}):\n ${linkUri}\n`);
4580
4590
  return exitAfterFlush(ExitCode.Ok);
4581
4591
  } catch (err) {
4582
4592
  return emitFailureFromError(args.json, err);
@@ -5903,6 +5913,74 @@ function redactStderr(stderr) {
5903
5913
  return trimmed;
5904
5914
  }
5905
5915
  //#endregion
5916
+ //#region src/auth/backends/file.ts
5917
+ function credentialFilePath() {
5918
+ const override = process.env.AITCC_CREDENTIAL_FILE;
5919
+ if (override && override.length > 0) return override;
5920
+ return join(configDir(), "credentials.json");
5921
+ }
5922
+ function makeKey(account) {
5923
+ return `${CREDENTIAL_SERVICE}:${account}`;
5924
+ }
5925
+ async function readStore(filePath) {
5926
+ let raw;
5927
+ try {
5928
+ raw = await readFile(filePath, "utf8");
5929
+ } catch (err) {
5930
+ if (err.code === "ENOENT") return null;
5931
+ throw err;
5932
+ }
5933
+ try {
5934
+ const mode = (await stat(filePath)).mode & 511;
5935
+ if (mode !== 384) process.stderr.write(`Warning: credential file ${filePath} has permissions ${mode.toString(8)} — expected 0600.\n Run: chmod 600 ${filePath}\n`);
5936
+ } catch {}
5937
+ try {
5938
+ return JSON.parse(raw);
5939
+ } catch {
5940
+ return null;
5941
+ }
5942
+ }
5943
+ async function writeStore(filePath, store) {
5944
+ await mkdir(dirname(filePath), {
5945
+ recursive: true,
5946
+ mode: 448
5947
+ });
5948
+ await writeFile(filePath, JSON.stringify(store, null, 2), { mode: 384 });
5949
+ try {
5950
+ await chmod(filePath, 384);
5951
+ } catch {}
5952
+ }
5953
+ const FILE_BACKEND = {
5954
+ name: "file",
5955
+ async get(account) {
5956
+ const store = await readStore(credentialFilePath());
5957
+ if (store === null) return null;
5958
+ const value = store[makeKey(account)];
5959
+ return typeof value === "string" && value.length > 0 ? value : null;
5960
+ },
5961
+ async set(account, password) {
5962
+ const filePath = credentialFilePath();
5963
+ const store = await readStore(filePath) ?? {};
5964
+ store[makeKey(account)] = password;
5965
+ await writeStore(filePath, store);
5966
+ },
5967
+ async clear(account) {
5968
+ const filePath = credentialFilePath();
5969
+ const store = await readStore(filePath);
5970
+ if (store === null) return { existed: false };
5971
+ const key = makeKey(account);
5972
+ if (!(key in store)) return { existed: false };
5973
+ delete store[key];
5974
+ if (Object.keys(store).length === 0) try {
5975
+ await unlink(filePath);
5976
+ } catch (err) {
5977
+ if (err.code !== "ENOENT") throw err;
5978
+ }
5979
+ else await writeStore(filePath, store);
5980
+ return { existed: true };
5981
+ }
5982
+ };
5983
+ //#endregion
5906
5984
  //#region src/auth/backends/linux.ts
5907
5985
  const MISSING_HINT_FULL$1 = "libsecret tools are missing. Install `libsecret-tools` (Debian/Ubuntu) or the equivalent and ensure a Secret Service provider (gnome-keyring / KWallet) is running.";
5908
5986
  const MISSING_HINT_SHORT$1 = "libsecret tools are missing. Install `libsecret-tools` and ensure a Secret Service provider is running.";
@@ -6172,6 +6250,7 @@ if ($ok) { [Console]::Out.Write('deleted'); } else { [Console]::Out.Write('absen
6172
6250
  //#region src/auth/credentials.ts
6173
6251
  function resolveBackend(opts = {}) {
6174
6252
  if (opts.override) return opts.override;
6253
+ if (opts.useFile === true || process.env.AITCC_CREDENTIAL_BACKEND === "file") return FILE_BACKEND;
6175
6254
  const platform = opts.platform ?? process.platform;
6176
6255
  switch (platform) {
6177
6256
  case "darwin": return MACOS_BACKEND;
@@ -6223,15 +6302,14 @@ async function clearAuthState() {
6223
6302
  /**
6224
6303
  * Resolve credentials from the highest-priority source available:
6225
6304
  * 1. `AITCC_EMAIL` + `AITCC_PASSWORD` env vars (CI single-shot use).
6226
- * 2. OS keychain entry whose email is recorded in `auth-state.json`.
6305
+ * 2. File backend (`~/.config/aitcc/credentials.json`) when
6306
+ * `AITCC_CREDENTIAL_BACKEND=file` is set or `opts.useFile` is true.
6307
+ * 3. OS keychain entry whose email is recorded in `auth-state.json`.
6227
6308
  *
6228
6309
  * Returns `null` when no source is configured. The discriminated `kind`
6229
6310
  * lets callers (e.g. the login flow) tell why a credential was found
6230
6311
  * without having to peek at process env themselves — useful for
6231
6312
  * "auto-login from CI" diagnostics.
6232
- *
6233
- * A future `'file'` source (~/.config/aitcc/credentials.json) is left as a
6234
- * follow-up; once added, it slots between (1) and (2).
6235
6313
  */
6236
6314
  async function loadCredentials(opts = {}) {
6237
6315
  const env = opts.env ?? process.env;
@@ -6244,10 +6322,11 @@ async function loadCredentials(opts = {}) {
6244
6322
  };
6245
6323
  const state = await readAuthState();
6246
6324
  if (!state) return null;
6247
- const password = await resolveBackend(opts).get(state.activeEmail);
6325
+ const backend = resolveBackend(opts);
6326
+ const password = await backend.get(state.activeEmail);
6248
6327
  if (password === null) return null;
6249
6328
  return {
6250
- kind: "keychain",
6329
+ kind: backend.name === "file" ? "file" : "keychain",
6251
6330
  email: state.activeEmail,
6252
6331
  password
6253
6332
  };
@@ -6298,7 +6377,7 @@ async function getActiveCredentialEmail(opts = {}) {
6298
6377
  const state = await readAuthState();
6299
6378
  if (!state) return null;
6300
6379
  return {
6301
- kind: "keychain",
6380
+ kind: env.AITCC_CREDENTIAL_BACKEND === "file" ? "file" : "keychain",
6302
6381
  email: state.activeEmail
6303
6382
  };
6304
6383
  }
@@ -6975,6 +7054,81 @@ const completionCommand = defineCommand({
6975
7054
  }
6976
7055
  });
6977
7056
  //#endregion
7057
+ //#region src/ait-token-profile.ts
7058
+ const AIT_CREDENTIALS_PATH = join(homedir(), ".ait", "credentials");
7059
+ function credentialsPath() {
7060
+ const override = process.env._AIT_CREDENTIALS_PATH_OVERRIDE;
7061
+ if (override && override.length > 0) return override;
7062
+ return AIT_CREDENTIALS_PATH;
7063
+ }
7064
+ function readCredentials(path) {
7065
+ if (!existsSync(path)) return {};
7066
+ try {
7067
+ const raw = readFileSync(path, "utf8");
7068
+ const parsed = JSON.parse(raw);
7069
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
7070
+ return {};
7071
+ } catch {
7072
+ return {};
7073
+ }
7074
+ }
7075
+ function writeCredentials(path, map) {
7076
+ const dir = join(path, "..");
7077
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
7078
+ writeFileSync(path, JSON.stringify(map, null, 2) + "\n", {
7079
+ encoding: "utf8",
7080
+ mode: 384
7081
+ });
7082
+ }
7083
+ /**
7084
+ * Save a Deploy Key to the `ait` token profile store.
7085
+ *
7086
+ * Tries direct-write first. If that fails and `ait` is on PATH, falls back
7087
+ * to spawning `ait token add --api-key ... <profile>`.
7088
+ *
7089
+ * SECRET-HANDLING: `apiKey` must not appear in any log or thrown message.
7090
+ * The `detail` field in failure results contains only non-secret context.
7091
+ */
7092
+ function saveAitTokenProfile(profile, apiKey) {
7093
+ const path = credentialsPath();
7094
+ try {
7095
+ const map = readCredentials(path);
7096
+ map[profile] = apiKey;
7097
+ writeCredentials(path, map);
7098
+ return {
7099
+ ok: true,
7100
+ method: "direct",
7101
+ profile
7102
+ };
7103
+ } catch (err) {
7104
+ const detail = err instanceof Error ? err.message : String(err);
7105
+ try {
7106
+ execFileSync("ait", [
7107
+ "token",
7108
+ "add",
7109
+ "--api-key",
7110
+ apiKey,
7111
+ profile
7112
+ ], {
7113
+ stdio: "ignore",
7114
+ timeout: 1e4,
7115
+ env: { ...process.env }
7116
+ });
7117
+ return {
7118
+ ok: true,
7119
+ method: "spawn",
7120
+ profile
7121
+ };
7122
+ } catch {
7123
+ return {
7124
+ ok: false,
7125
+ reason: "write-failed",
7126
+ detail
7127
+ };
7128
+ }
7129
+ }
7130
+ }
7131
+ //#endregion
6978
7132
  //#region src/api/api-keys.ts
6979
7133
  const BASE$2 = "https://apps-in-toss.toss.im/console/api-public/v3/appsintossconsole";
6980
7134
  async function fetchApiKeys(workspaceId, cookies, opts = {}) {
@@ -7130,6 +7284,10 @@ const createCommand = defineCommand({
7130
7284
  type: "string",
7131
7285
  description: "Workspace ID. Defaults to the selected workspace."
7132
7286
  },
7287
+ "save-profile": {
7288
+ type: "string",
7289
+ description: "After issuing, save the key as an `ait` token profile (written to `~/.ait/credentials`). The named profile can then be used with `ait deploy --profile <name>` immediately. If omitted, the key is printed to stdout once and not persisted locally."
7290
+ },
7133
7291
  json: {
7134
7292
  type: "boolean",
7135
7293
  description: "Emit machine-readable JSON to stdout.",
@@ -7179,6 +7337,14 @@ const createCommand = defineCommand({
7179
7337
  name,
7180
7338
  target
7181
7339
  }, session.cookies);
7340
+ const saveProfileName = args["save-profile"] ? String(args["save-profile"]) : void 0;
7341
+ let savedProfile;
7342
+ let saveProfileWarning;
7343
+ if (saveProfileName !== void 0) {
7344
+ const saveResult = saveAitTokenProfile(saveProfileName, result.apiKey);
7345
+ if (saveResult.ok) savedProfile = saveResult.profile;
7346
+ else saveProfileWarning = `Could not save to ait profile "${saveProfileName}": ${saveResult.detail}. Save the key manually with \`ait token add --api-key <key> ` + saveProfileName + "`.";
7347
+ }
7182
7348
  if (args.json) {
7183
7349
  emitJson({
7184
7350
  ok: true,
@@ -7189,12 +7355,16 @@ const createCommand = defineCommand({
7189
7355
  isAll: target.isAll,
7190
7356
  appNames: [...target.appNames]
7191
7357
  },
7358
+ ...savedProfile !== void 0 ? { savedProfile } : {},
7359
+ ...saveProfileWarning !== void 0 ? { saveProfileWarning } : {},
7192
7360
  extra: result.extra
7193
7361
  });
7194
7362
  return exitAfterFlush(ExitCode.Ok);
7195
7363
  }
7196
7364
  process.stdout.write(`${result.apiKey}\n`);
7197
- process.stderr.write("⚠️ This key is shown only once. Save it to a secret manager now — it cannot be retrieved later.\n");
7365
+ if (savedProfile !== void 0) process.stderr.write(`Saved as ait profile "${savedProfile}". Run: ait deploy --profile ${savedProfile}\n`);
7366
+ else process.stderr.write("⚠️ This key is shown only once. Save it to a secret manager now — it cannot be retrieved later.\n");
7367
+ if (saveProfileWarning !== void 0) process.stderr.write(`Warning: ${saveProfileWarning}\n`);
7198
7368
  return exitAfterFlush(ExitCode.Ok);
7199
7369
  } catch (err) {
7200
7370
  return emitFailureFromError(args.json, err);
@@ -7994,13 +8164,20 @@ const defaultPromptDeps = {
7994
8164
  saveTarget: () => select({
7995
8165
  message: "Where would you like to save the credentials?",
7996
8166
  default: "keychain",
7997
- choices: [{
7998
- name: "OS keychain (recommended) — next login runs headlessly",
7999
- value: "keychain"
8000
- }, {
8001
- name: "Do not save — one-shot. (Tip: AITCC_EMAIL/AITCC_PASSWORD env for CI.)",
8002
- value: "none"
8003
- }]
8167
+ choices: [
8168
+ {
8169
+ name: "OS keychain (recommended) — next login runs headlessly",
8170
+ value: "keychain"
8171
+ },
8172
+ {
8173
+ name: "File (~/.config/aitcc/credentials.json, perm 0600) — use for SSH/headless sessions",
8174
+ value: "file"
8175
+ },
8176
+ {
8177
+ name: "Do not save — one-shot. (Tip: AITCC_EMAIL/AITCC_PASSWORD env for CI.)",
8178
+ value: "none"
8179
+ }
8180
+ ]
8004
8181
  })
8005
8182
  };
8006
8183
  const loginCommand = defineCommand({
@@ -8039,7 +8216,7 @@ const loginCommand = defineCommand({
8039
8216
  },
8040
8217
  save: {
8041
8218
  type: "string",
8042
- description: "Where to persist credentials when --email/--password* are passed: \"keychain\" or \"none\" (default)."
8219
+ description: "Where to persist credentials when --email/--password* are passed: \"keychain\", \"file\", or \"none\" (default). Use \"file\" for SSH/headless sessions where the OS keychain is unavailable."
8043
8220
  },
8044
8221
  "skip-onboarding": {
8045
8222
  type: "boolean",
@@ -8058,7 +8235,7 @@ const loginCommand = defineCommand({
8058
8235
  save: typeof args.save === "string" ? args.save : void 0
8059
8236
  }, {
8060
8237
  getCredentials: loadCredentials,
8061
- saveCredentials
8238
+ saveCredentials: (email, password, useFile) => saveCredentials(email, password, { useFile: useFile === true })
8062
8239
  });
8063
8240
  }
8064
8241
  });
@@ -8108,7 +8285,8 @@ async function runLoginCommand(args, deps) {
8108
8285
  return exitAfterFlush(resolved.exitCode);
8109
8286
  }
8110
8287
  let saved = "skipped";
8111
- if (resolved.saveTarget === "keychain" && resolved.credentials !== null) {
8288
+ if ((resolved.saveTarget === "keychain" || resolved.saveTarget === "file") && resolved.credentials !== null) {
8289
+ const useFile = resolved.saveTarget === "file";
8112
8290
  const save = deps.saveCredentials;
8113
8291
  if (!save) {
8114
8292
  emitError({
@@ -8118,16 +8296,25 @@ async function runLoginCommand(args, deps) {
8118
8296
  return exitAfterFlush(ExitCode.Generic);
8119
8297
  }
8120
8298
  try {
8121
- const result = await save(resolved.credentials.email, resolved.credentials.password);
8299
+ const result = await save(resolved.credentials.email, resolved.credentials.password, useFile);
8122
8300
  saved = result.status;
8123
8301
  if (!args.json) if (result.status === "unchanged") process.stderr.write("Credentials already saved (no change).\n");
8302
+ else if (useFile) process.stderr.write(`Credentials saved to file backend (${resolved.credentials.email}).\n`);
8124
8303
  else process.stderr.write(`Credentials saved to OS keychain (${resolved.credentials.email}).\n`);
8125
8304
  } catch (err) {
8126
8305
  const message = err.message;
8127
- emitError({
8306
+ if (!useFile && process.platform === "darwin") emitError({
8307
+ reason: "keychain-save-failed",
8308
+ message
8309
+ }, "keychain 접근에 실패했습니다.\nSSH/headless 세션이면 다음 중 하나를 시도하세요:\n 1) 데스크톱 GUI Mac에서: aitcc auth export --format=env (KR IP 필요)\n SSH에서: AITCC_SESSION='...' aitcc auth import --from-env\n 2) 같은 SSH 세션에서 keychain unlock:\n security unlock-keychain ~/Library/Keychains/login.keychain-db\n (login 비밀번호 입력 후 재시도)\n 3) keychain 대신 파일 저장:\n aitcc login --save=file (~/.config/aitcc/credentials.json, perm 0600)\n참고: https://github.com/apps-in-toss-community/console-cli/issues/176");
8310
+ else if (!useFile) emitError({
8128
8311
  reason: "keychain-save-failed",
8129
8312
  message
8130
8313
  }, `Failed to save credentials to the OS keychain: ${message}\nOn Linux, install libsecret (\`secret-tool\`) and retry. Re-run with \`--save none\` to skip persistence.`);
8314
+ else emitError({
8315
+ reason: "file-save-failed",
8316
+ message
8317
+ }, `Failed to save credentials to file backend: ${message}\nCheck that ~/.config/aitcc/ is writable, or use AITCC_CREDENTIAL_FILE to specify a custom path.`);
8131
8318
  return exitAfterFlush(ExitCode.Usage);
8132
8319
  }
8133
8320
  }
@@ -8189,10 +8376,10 @@ async function resolveCredentialsForLogin(args, deps, opts = {}) {
8189
8376
  };
8190
8377
  let saveTarget;
8191
8378
  if (args.save !== void 0) {
8192
- if (args.save !== "keychain" && args.save !== "none") return {
8379
+ if (args.save !== "keychain" && args.save !== "file" && args.save !== "none") return {
8193
8380
  kind: "error",
8194
8381
  reason: "invalid-save",
8195
- message: `--save must be "keychain" or "none" (got "${args.save}").`,
8382
+ message: `--save must be "keychain", "file", or "none" (got "${args.save}").`,
8196
8383
  exitCode: ExitCode.Usage
8197
8384
  };
8198
8385
  saveTarget = args.save;
@@ -9237,7 +9424,7 @@ function resolveVersion() {
9237
9424
  if (typeof injected === "string" && injected.length > 0) return injected;
9238
9425
  } catch {}
9239
9426
  try {
9240
- return "0.1.35";
9427
+ return "0.1.37";
9241
9428
  } catch {}
9242
9429
  return "0.0.0-dev";
9243
9430
  }
@@ -10294,6 +10481,7 @@ async function describeCredentialSource() {
10294
10481
  function formatCredentials(cred) {
10295
10482
  if (cred.source === "none") return "none (run `aitcc login` to save)";
10296
10483
  if (cred.source === "env") return `env (AITCC_EMAIL${cred.email ? ` = ${cred.email}` : ""})`;
10484
+ if (cred.source === "file") return `file (~/.config/aitcc/credentials.json)${cred.email ? ` (${cred.email})` : ""}`;
10297
10485
  return `keychain${cred.email ? ` (${cred.email})` : ""}`;
10298
10486
  }
10299
10487
  async function runBackgroundUpdateCheck(json) {