diff --git a/.changeset/feat-auto-deploy-key-bridge.md b/.changeset/feat-auto-deploy-key-bridge.md new file mode 100644 index 0000000..1ccb6ec --- /dev/null +++ b/.changeset/feat-auto-deploy-key-bridge.md @@ -0,0 +1,5 @@ +--- +"@ait-co/console-cli": patch +--- + +`keys create` now automatically saves the issued Deploy Key to `~/.ait/credentials` under the `--name` profile so `ait deploy --profile ` works immediately without a separate `ait token add` step. Pass `--no-save-profile` to skip (stdout-only, for CI pipes). Also fixes the `~/.ait` directory permissions to `0700` (was missing mode, defaulted to `0755`). diff --git a/README.en.md b/README.en.md index dfd11de..4d018fc 100644 --- a/README.en.md +++ b/README.en.md @@ -223,6 +223,28 @@ The following command groups are implemented end-to-end: `app logs` is deferred until the backend endpoint is available. See the [organization landing page](https://aitc.dev/) for the full roadmap. +## Issuing a Deploy Key + +Issue a workspace-scoped credential (Deploy Key) for deploy automation: + +```sh +aitcc keys create --name ci-deploy +``` + +The key is automatically saved to `~/.ait/credentials` under the `ci-deploy` profile as soon as it is issued — no separate `ait token add` step required: + +```sh +ait deploy --profile ci-deploy ./bundle.ait +``` + +Only the plaintext key is written to stdout (pipe-friendly). stderr confirms which profile was saved. If you are piping the key into an external secret manager and do not need a local profile, pass `--no-save-profile`: + +```sh +aitcc keys create --name ci-deploy --no-save-profile | secret-tool store --label=… key password +``` + +To save the profile under a different name than `--name`, pass `--save-profile `. The plaintext key is exposed once at issuance and cannot be retrieved later — if you lose it, revoke it with `aitcc keys revoke ` and issue a new one. + ## Pre-commit hook Optional but recommended. After cloning, activate the standard pre-commit hook (runs `biome check` on staged files): diff --git a/README.md b/README.md index a0f80ed..2ea63b2 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,28 @@ aitcc telemetry tier0-on # Tier 0 다시 활성화 `app logs`는 백엔드 endpoint 확보 후 구현 예정입니다. 전체 로드맵은 [organization landing page](https://aitc.dev/) 참조. +## Deploy Key 발급 + +배포 자동화를 위한 워크스페이스-scope 자격증명(Deploy Key)을 발급합니다. + +```sh +aitcc keys create --name ci-deploy +``` + +키 발급 즉시 `~/.ait/credentials`에 `ci-deploy` 프로파일로 저장되므로, 별도 `ait token add` 단계 없이 바로 사용할 수 있습니다: + +```sh +ait deploy --profile ci-deploy ./bundle.ait +``` + +stdout에는 plaintext 키 한 줄만 나옵니다 (파이프 친화적). stderr는 저장된 프로파일 이름을 확인해줍니다. CI 파이프에서 키를 외부 secret manager에 직접 주입할 때처럼 로컬 저장이 필요 없다면 `--no-save-profile`로 저장을 건너뜁니다: + +```sh +aitcc keys create --name ci-deploy --no-save-profile | secret-tool store --label=… key password +``` + +프로파일 이름을 `--name`과 다르게 지정하려면 `--save-profile `을 사용합니다. plaintext 키는 발급 시 한 번만 노출되며 목록 endpoint에서 다시 확인할 수 없습니다 — 분실 시 `aitcc keys revoke `로 무효화하고 재발급합니다. + ## Pre-commit hook 선택 사항이지만 권장합니다. clone 후 표준 pre-commit hook을 활성화하면 staged 파일에 `biome check`가 자동으로 돕니다 (push 전 빠른 피드백): diff --git a/docs/api/api-keys.md b/docs/api/api-keys.md index 7b6da60..b59617f 100644 --- a/docs/api/api-keys.md +++ b/docs/api/api-keys.md @@ -119,3 +119,18 @@ UI는 응답 후 list query를 invalidate하므로 cli도 `revoke` 후엔 `ls` - plaintext key는 발급 응답(`apiKey`)에서 단 한 번만 surface되고 list endpoint는 이를 echo하지 않는다. 분실 시 `revoke` + `create` 재발급이 유일한 경로다. - 이 키 한 개로 워크스페이스의 배포 API에 접근할 수 있으므로 secret manager에 즉시 저장한다 (`aitcc keys create --json`로 받아 keychain/CI secret으로 파이프). - 세션 쿠키 redaction 룰(`docs/api/_redaction.md`)이 그대로 적용된다 — `--json` 출력 외 어떤 경로(stderr, 로그, error message)에도 plaintext key를 노출하지 않는다. + +## ait 프로파일 자동 저장 + +`aitcc keys create` 발급 즉시 `~/.ait/credentials`(mode `0600`, 상위 디렉토리 `0700`)에 `--name`과 동일한 이름의 프로파일로 저장된다. 저장이 완료되면 별도의 `ait token add` 없이 바로 사용 가능하다: + +```sh +aitcc keys create --name ci-deploy +# stderr: Saved as ait profile "ci-deploy". Run: ait deploy --profile ci-deploy +ait deploy --profile ci-deploy ./bundle.ait +``` + +- `--save-profile `: 프로파일 이름을 `--name`과 다르게 지정한다. +- `--no-save-profile`: 자동 저장을 건너뛰고 stdout에만 key를 출력한다. CI 파이프에서 외부 secret manager로 직접 주입할 때 사용한다. + +파일 write 실패 시(디스크 권한 문제 등)에도 plaintext key는 stdout에 출력되고 exit 0이므로, 호출자가 다른 경로로 저장할 수 있다. 실패 상세는 stderr `Warning:` 줄로 emit되며 **plaintext key는 절대 포함되지 않는다**. diff --git a/src/ait-token-profile.ts b/src/ait-token-profile.ts index 4d45f8c..eaae2f4 100644 --- a/src/ait-token-profile.ts +++ b/src/ait-token-profile.ts @@ -57,9 +57,9 @@ function readCredentials(path: string): Record { function writeCredentials(path: string, map: Record): void { const dir = join(path, '..'); if (!existsSync(dir)) { - mkdirSync(dir, { recursive: true }); + mkdirSync(dir, { recursive: true, mode: 0o700 }); } - writeFileSync(path, JSON.stringify(map, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 }); + writeFileSync(path, `${JSON.stringify(map, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); } /** diff --git a/src/commands/keys.test.ts b/src/commands/keys.test.ts index ce2dac2..648bc19 100644 --- a/src/commands/keys.test.ts +++ b/src/commands/keys.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from 'vitest'; -import { formatExpiry, parseAppsFlag, validateKeyName } from './keys.js'; +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { saveAitTokenProfile } from '../ait-token-profile.js'; +import { formatExpiry, parseAppsFlag, resolveProfileName, validateKeyName } from './keys.js'; // Console UI dialog rules captured from `static/index.ZsA5htf8.js` (`he`): // the field is gated on length 1..16 and the placeholder rejects whitespace @@ -83,3 +87,123 @@ describe('formatExpiry', () => { expect(formatExpiry(now + 86_400_000 - 1, now)).toBe('D-0'); }); }); + +// --------------------------------------------------------------------------- +// resolveProfileName — pure unit tests for the three auto-save branches +// --------------------------------------------------------------------------- +// These cover the key contract: by default the profile name equals --name, +// --save-profile overrides it, and --no-save-profile disables saving. +describe('resolveProfileName', () => { + it('default: returns --name when no flags are given', () => { + expect(resolveProfileName('ci-deploy', {})).toBe('ci-deploy'); + }); + + it('--save-profile : returns the override name, not --name', () => { + expect(resolveProfileName('ci-deploy', { saveProfileOverride: 'staging' })).toBe('staging'); + }); + + it('--no-save-profile: returns undefined (saving disabled)', () => { + expect(resolveProfileName('ci-deploy', { noSaveProfile: true })).toBeUndefined(); + }); + + it('--no-save-profile takes precedence over --save-profile', () => { + // If somehow both flags are present, no-save-profile wins. + expect( + resolveProfileName('ci-deploy', { noSaveProfile: true, saveProfileOverride: 'other' }), + ).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Auto-save integration: credential file written / not written +// --------------------------------------------------------------------------- +// Exercises the three flag-driven branches end-to-end by simulating what +// the `keys create` command does after it receives an apiKey from the server: +// 1. resolve the profile name (already covered above) +// 2. call saveAitTokenProfile when a name is resolved +// +// Tests redirect the credentials path via _AIT_CREDENTIALS_PATH_OVERRIDE so +// the real ~/.ait/credentials is never touched. +// The apiKey value must never appear in any stderr output (SECRET-HANDLING). +// --------------------------------------------------------------------------- + +describe('keys create — auto-save credential branches', () => { + let tempDir: string; + let credPath: string; + const TEST_API_KEY = 'test-deploy-key-abc123'; + + beforeEach(() => { + tempDir = join( + tmpdir(), + `keys-create-test-${Date.now()}-${Math.random().toString(36).slice(2)}`, + ); + credPath = join(tempDir, 'credentials'); + process.env._AIT_CREDENTIALS_PATH_OVERRIDE = credPath; + }); + + afterEach(() => { + delete process.env._AIT_CREDENTIALS_PATH_OVERRIDE; + if (existsSync(tempDir)) { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('default (no flags): profile saved under --name value', () => { + // Simulate what createCommand does on the default path. + const profileName = resolveProfileName('ci-deploy', {}); + expect(profileName).toBe('ci-deploy'); + + // saveAitTokenProfile is the actual write call. + if (profileName !== undefined) { + const saveResult = saveAitTokenProfile(profileName, TEST_API_KEY); + expect(saveResult.ok).toBe(true); + } + + // Credential file must contain the profile. + expect(existsSync(credPath)).toBe(true); + const written = JSON.parse(readFileSync(credPath, 'utf8')); + expect(written['ci-deploy']).toBe(TEST_API_KEY); + + // SECRET-HANDLING: apiKey must not appear in any warning/detail that + // would be emitted to stderr. The saveResult.ok path emits no warning. + }); + + it('--no-save-profile: profile name resolves to undefined; credentials NOT written', () => { + const profileName = resolveProfileName('ci-deploy', { noSaveProfile: true }); + expect(profileName).toBeUndefined(); + + // When profileName is undefined the command skips saveAitTokenProfile. + // Verify the file is not created. + expect(existsSync(credPath)).toBe(false); + }); + + it('--save-profile : saved under override name, not --name', () => { + const profileName = resolveProfileName('ci-deploy', { saveProfileOverride: 'staging' }); + expect(profileName).toBe('staging'); + + if (profileName !== undefined) { + const saveResult = saveAitTokenProfile(profileName, TEST_API_KEY); + expect(saveResult.ok).toBe(true); + } + + // Must be keyed under 'staging', NOT 'ci-deploy'. + const written = JSON.parse(readFileSync(credPath, 'utf8')); + expect(written.staging).toBe(TEST_API_KEY); + expect(written['ci-deploy']).toBeUndefined(); + }); + + it('SECRET-HANDLING: saveAitTokenProfile failure detail never includes the apiKey', () => { + // Force a write failure by pointing credentials at a path whose + // parent is a file (not a directory). + const blocker = join(tempDir, 'blocker'); + mkdirSync(tempDir, { recursive: true }); + writeFileSync(blocker, '', 'utf8'); + process.env._AIT_CREDENTIALS_PATH_OVERRIDE = join(blocker, 'credentials'); + + const SECRET = 'super-secret-deploy-key-xyz'; + const result = saveAitTokenProfile('profile', SECRET); + if (result.ok) return; // skip if write somehow succeeded + + expect(result.detail).not.toContain(SECRET); + }); +}); diff --git a/src/commands/keys.ts b/src/commands/keys.ts index c1be90a..7f12137 100644 --- a/src/commands/keys.ts +++ b/src/commands/keys.ts @@ -30,7 +30,7 @@ import { // wording out of JSON (it lives on stderr plain output only). // // keys create --name