@ait-co/console-cli 0.1.36 → 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
@@ -5913,6 +5913,74 @@ function redactStderr(stderr) {
5913
5913
  return trimmed;
5914
5914
  }
5915
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
5916
5984
  //#region src/auth/backends/linux.ts
5917
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.";
5918
5986
  const MISSING_HINT_SHORT$1 = "libsecret tools are missing. Install `libsecret-tools` and ensure a Secret Service provider is running.";
@@ -6182,6 +6250,7 @@ if ($ok) { [Console]::Out.Write('deleted'); } else { [Console]::Out.Write('absen
6182
6250
  //#region src/auth/credentials.ts
6183
6251
  function resolveBackend(opts = {}) {
6184
6252
  if (opts.override) return opts.override;
6253
+ if (opts.useFile === true || process.env.AITCC_CREDENTIAL_BACKEND === "file") return FILE_BACKEND;
6185
6254
  const platform = opts.platform ?? process.platform;
6186
6255
  switch (platform) {
6187
6256
  case "darwin": return MACOS_BACKEND;
@@ -6233,15 +6302,14 @@ async function clearAuthState() {
6233
6302
  /**
6234
6303
  * Resolve credentials from the highest-priority source available:
6235
6304
  * 1. `AITCC_EMAIL` + `AITCC_PASSWORD` env vars (CI single-shot use).
6236
- * 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`.
6237
6308
  *
6238
6309
  * Returns `null` when no source is configured. The discriminated `kind`
6239
6310
  * lets callers (e.g. the login flow) tell why a credential was found
6240
6311
  * without having to peek at process env themselves — useful for
6241
6312
  * "auto-login from CI" diagnostics.
6242
- *
6243
- * A future `'file'` source (~/.config/aitcc/credentials.json) is left as a
6244
- * follow-up; once added, it slots between (1) and (2).
6245
6313
  */
6246
6314
  async function loadCredentials(opts = {}) {
6247
6315
  const env = opts.env ?? process.env;
@@ -6254,10 +6322,11 @@ async function loadCredentials(opts = {}) {
6254
6322
  };
6255
6323
  const state = await readAuthState();
6256
6324
  if (!state) return null;
6257
- const password = await resolveBackend(opts).get(state.activeEmail);
6325
+ const backend = resolveBackend(opts);
6326
+ const password = await backend.get(state.activeEmail);
6258
6327
  if (password === null) return null;
6259
6328
  return {
6260
- kind: "keychain",
6329
+ kind: backend.name === "file" ? "file" : "keychain",
6261
6330
  email: state.activeEmail,
6262
6331
  password
6263
6332
  };
@@ -6308,7 +6377,7 @@ async function getActiveCredentialEmail(opts = {}) {
6308
6377
  const state = await readAuthState();
6309
6378
  if (!state) return null;
6310
6379
  return {
6311
- kind: "keychain",
6380
+ kind: env.AITCC_CREDENTIAL_BACKEND === "file" ? "file" : "keychain",
6312
6381
  email: state.activeEmail
6313
6382
  };
6314
6383
  }
@@ -8095,13 +8164,20 @@ const defaultPromptDeps = {
8095
8164
  saveTarget: () => select({
8096
8165
  message: "Where would you like to save the credentials?",
8097
8166
  default: "keychain",
8098
- choices: [{
8099
- name: "OS keychain (recommended) — next login runs headlessly",
8100
- value: "keychain"
8101
- }, {
8102
- name: "Do not save — one-shot. (Tip: AITCC_EMAIL/AITCC_PASSWORD env for CI.)",
8103
- value: "none"
8104
- }]
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
+ ]
8105
8181
  })
8106
8182
  };
8107
8183
  const loginCommand = defineCommand({
@@ -8140,7 +8216,7 @@ const loginCommand = defineCommand({
8140
8216
  },
8141
8217
  save: {
8142
8218
  type: "string",
8143
- 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."
8144
8220
  },
8145
8221
  "skip-onboarding": {
8146
8222
  type: "boolean",
@@ -8159,7 +8235,7 @@ const loginCommand = defineCommand({
8159
8235
  save: typeof args.save === "string" ? args.save : void 0
8160
8236
  }, {
8161
8237
  getCredentials: loadCredentials,
8162
- saveCredentials
8238
+ saveCredentials: (email, password, useFile) => saveCredentials(email, password, { useFile: useFile === true })
8163
8239
  });
8164
8240
  }
8165
8241
  });
@@ -8209,7 +8285,8 @@ async function runLoginCommand(args, deps) {
8209
8285
  return exitAfterFlush(resolved.exitCode);
8210
8286
  }
8211
8287
  let saved = "skipped";
8212
- if (resolved.saveTarget === "keychain" && resolved.credentials !== null) {
8288
+ if ((resolved.saveTarget === "keychain" || resolved.saveTarget === "file") && resolved.credentials !== null) {
8289
+ const useFile = resolved.saveTarget === "file";
8213
8290
  const save = deps.saveCredentials;
8214
8291
  if (!save) {
8215
8292
  emitError({
@@ -8219,16 +8296,25 @@ async function runLoginCommand(args, deps) {
8219
8296
  return exitAfterFlush(ExitCode.Generic);
8220
8297
  }
8221
8298
  try {
8222
- const result = await save(resolved.credentials.email, resolved.credentials.password);
8299
+ const result = await save(resolved.credentials.email, resolved.credentials.password, useFile);
8223
8300
  saved = result.status;
8224
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`);
8225
8303
  else process.stderr.write(`Credentials saved to OS keychain (${resolved.credentials.email}).\n`);
8226
8304
  } catch (err) {
8227
8305
  const message = err.message;
8228
- 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({
8229
8311
  reason: "keychain-save-failed",
8230
8312
  message
8231
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.`);
8232
8318
  return exitAfterFlush(ExitCode.Usage);
8233
8319
  }
8234
8320
  }
@@ -8290,10 +8376,10 @@ async function resolveCredentialsForLogin(args, deps, opts = {}) {
8290
8376
  };
8291
8377
  let saveTarget;
8292
8378
  if (args.save !== void 0) {
8293
- if (args.save !== "keychain" && args.save !== "none") return {
8379
+ if (args.save !== "keychain" && args.save !== "file" && args.save !== "none") return {
8294
8380
  kind: "error",
8295
8381
  reason: "invalid-save",
8296
- message: `--save must be "keychain" or "none" (got "${args.save}").`,
8382
+ message: `--save must be "keychain", "file", or "none" (got "${args.save}").`,
8297
8383
  exitCode: ExitCode.Usage
8298
8384
  };
8299
8385
  saveTarget = args.save;
@@ -9338,7 +9424,7 @@ function resolveVersion() {
9338
9424
  if (typeof injected === "string" && injected.length > 0) return injected;
9339
9425
  } catch {}
9340
9426
  try {
9341
- return "0.1.36";
9427
+ return "0.1.37";
9342
9428
  } catch {}
9343
9429
  return "0.0.0-dev";
9344
9430
  }
@@ -10395,6 +10481,7 @@ async function describeCredentialSource() {
10395
10481
  function formatCredentials(cred) {
10396
10482
  if (cred.source === "none") return "none (run `aitcc login` to save)";
10397
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})` : ""}`;
10398
10485
  return `keychain${cred.email ? ` (${cred.email})` : ""}`;
10399
10486
  }
10400
10487
  async function runBackgroundUpdateCheck(json) {