Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/feat-auto-deploy-key-bridge.md
Original file line number Diff line number Diff line change
@@ -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 <name>` 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`).
22 changes: 22 additions & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <other-name>`. The plaintext key is exposed once at issuance and cannot be retrieved later — if you lose it, revoke it with `aitcc keys revoke <id>` 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):
Expand Down
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <other-name>`을 사용합니다. plaintext 키는 발급 시 한 번만 노출되며 목록 endpoint에서 다시 확인할 수 없습니다 — 분실 시 `aitcc keys revoke <id>`로 무효화하고 재발급합니다.

## Pre-commit hook

선택 사항이지만 권장합니다. clone 후 표준 pre-commit hook을 활성화하면 staged 파일에 `biome check`가 자동으로 돕니다 (push 전 빠른 피드백):
Expand Down
15 changes: 15 additions & 0 deletions docs/api/api-keys.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <other>`: 프로파일 이름을 `--name`과 다르게 지정한다.
- `--no-save-profile`: 자동 저장을 건너뛰고 stdout에만 key를 출력한다. CI 파이프에서 외부 secret manager로 직접 주입할 때 사용한다.

파일 write 실패 시(디스크 권한 문제 등)에도 plaintext key는 stdout에 출력되고 exit 0이므로, 호출자가 다른 경로로 저장할 수 있다. 실패 상세는 stderr `Warning:` 줄로 emit되며 **plaintext key는 절대 포함되지 않는다**.
4 changes: 2 additions & 2 deletions src/ait-token-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ function readCredentials(path: string): Record<string, string> {
function writeCredentials(path: string, map: Record<string, string>): 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 });
}

/**
Expand Down
128 changes: 126 additions & 2 deletions src/commands/keys.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <other> 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 <other>: 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 <other>: 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);
});
});
69 changes: 55 additions & 14 deletions src/commands/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ import {
// wording out of JSON (it lives on stderr plain output only).
//
// keys create --name <label> [--apps <slug,slug>] [--workspace <id>]
// [--save-profile <name>]:
// [--save-profile <name>] [--no-save-profile]:
// { ok: true, workspaceId, apiKey, name, target: {isAll, appNames},
// savedProfile?: string, saveProfileWarning?: string, extra } exit 0
// { ok: false, reason: 'invalid-name', message } exit 2
Expand All @@ -41,9 +41,17 @@ import {
// The `apiKey` field carries the plaintext token and is surfaced **only
// here** — the list endpoint does not echo it back. Agent-plugin skills
// should pipe it straight into a secret manager and never log the raw
// value. The CLI itself prints it to stdout once and never persists it.
// value. The CLI itself prints it to stdout once.
//
// When `--save-profile <name>` is given:
// By default the key is automatically saved to `~/.ait/credentials` under
// the same name as --name so `ait deploy --profile <name>` works
// immediately. Pass `--no-save-profile` to skip this (stdout-only, for CI
// pipes that store the key elsewhere).
//
// When `--save-profile <name>` is given it overrides the auto-save
// profile name; `--no-save-profile` disables saving entirely.
//
// On auto/explicit save:
// - On success: `savedProfile` is set to the profile name. The key is
// saved to `~/.ait/credentials` so `ait deploy --profile <name>` works
// immediately without a manual `ait token add` step.
Expand Down Expand Up @@ -83,6 +91,24 @@ export function validateKeyName(raw: string): NameValidationError | null {
return null;
}

/**
* Resolve the ait profile name to save the Deploy Key under.
*
* - `noSaveProfile: true` → undefined (skip saving)
* - `saveProfileOverride` present → use that name
* - default → use `name` (the --name value)
*
* Exported for unit testing.
*/
export function resolveProfileName(
name: string,
opts: { noSaveProfile?: boolean; saveProfileOverride?: string },
): string | undefined {
if (opts.noSaveProfile) return undefined;
if (opts.saveProfileOverride) return opts.saveProfileOverride;
return name;
}

export type AppsParseResult =
| { ok: true; slugs: string[] }
| { ok: false; reason: 'empty' | 'invalid'; bad?: string[] };
Expand Down Expand Up @@ -177,9 +203,16 @@ const createCommand = defineCommand({
'save-profile': {
type: 'string',
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.',
'Profile name for the ait token (defaults to --name). The key is written to ' +
'`~/.ait/credentials` so `ait deploy --profile <name>` works immediately. ' +
'Use --no-save-profile to skip.',
},
'no-save-profile': {
type: 'boolean',
default: false,
description:
'Do not save the issued key to an ait token profile — print to stdout only ' +
'(for CI pipes that store it elsewhere).',
},
json: { type: 'boolean', description: 'Emit machine-readable JSON to stdout.', default: false },
},
Expand Down Expand Up @@ -223,11 +256,16 @@ const createCommand = defineCommand({
try {
const result = await createApiKey(workspaceId, { name, target }, session.cookies);

// --save-profile: write the key to ~/.ait/credentials so `ait deploy
// --profile <name>` works immediately. Failure is non-fatal: we still
// surface the key and exit 0 so the caller can save it elsewhere.
// Auto-save the key to ~/.ait/credentials so `ait deploy --profile <name>`
// works immediately without a manual `ait token add` step.
// --no-save-profile disables this; --save-profile <other> overrides the name.
// Failure is non-fatal: we still surface the key and exit 0 so the caller
// can save it elsewhere.
// SECRET-HANDLING: apiKey is never included in warning messages.
const saveProfileName = args['save-profile'] ? String(args['save-profile']) : undefined;
const saveProfileName = resolveProfileName(name, {
noSaveProfile: args['no-save-profile'],
...(args['save-profile'] ? { saveProfileOverride: String(args['save-profile']) } : {}),
});
let savedProfile: string | undefined;
let saveProfileWarning: string | undefined;

Expand Down Expand Up @@ -258,10 +296,13 @@ const createCommand = defineCommand({
return exitAfterFlush(ExitCode.Ok);
}

// Plaintext is shown exactly once. The console UI surfaces the same
// "이 키는 한 번만 표시되니 복사해서 안전하게 보관해주세요." warning;
// we mirror it on stderr so stdout stays a clean single line that's
// friendly to `aitcc keys create ... | secret-tool store ...` pipes.
// Plaintext is shown exactly once on stdout so the line is pipe-friendly
// (`aitcc keys create ... | secret-tool store ...`).
// On the default/auto-save path stderr confirms where the profile landed
// so the user can immediately run `ait deploy --profile <name>`.
// The "shown only once" warning is only emitted when saving was skipped
// (--no-save-profile) or failed, so the user knows to save it manually.
// SECRET-HANDLING: apiKey never appears in stderr or warning text.
process.stdout.write(`${result.apiKey}\n`);
if (savedProfile !== undefined) {
process.stderr.write(
Expand Down
Loading