@athsra/cli 1.0.3 → 1.1.0

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.
Files changed (45) hide show
  1. package/README.md +34 -10
  2. package/package.json +3 -3
  3. package/src/commands/delete.ts +16 -13
  4. package/src/commands/get.ts +8 -5
  5. package/src/commands/handoff.ts +13 -3
  6. package/src/commands/login.ts +164 -59
  7. package/src/commands/logout.ts +3 -2
  8. package/src/commands/ls.ts +32 -10
  9. package/src/commands/manifest.ts +2 -2
  10. package/src/commands/mcp.ts +259 -7
  11. package/src/commands/migrate-envelopes.ts +55 -3
  12. package/src/commands/purge.ts +13 -10
  13. package/src/commands/restore.ts +10 -6
  14. package/src/commands/rollback.ts +12 -9
  15. package/src/commands/rotate-master.ts +13 -13
  16. package/src/commands/run.ts +6 -24
  17. package/src/commands/service-token.ts +15 -31
  18. package/src/commands/set.ts +7 -6
  19. package/src/commands/unset.ts +11 -8
  20. package/src/commands/versions.ts +7 -5
  21. package/src/index.ts +12 -8
  22. package/src/lib/auth-context.ts +74 -12
  23. package/src/lib/auth-proof.ts +10 -0
  24. package/src/lib/auto-project.ts +58 -14
  25. package/src/lib/client.ts +94 -17
  26. package/src/lib/config.ts +2 -0
  27. package/src/lib/device-login.ts +157 -0
  28. package/src/lib/env-format.ts +1 -1
  29. package/src/lib/envelope.ts +105 -15
  30. package/src/lib/identity-key.ts +21 -0
  31. package/src/lib/keyring.ts +25 -0
  32. package/src/lib/mcp-register.ts +223 -0
  33. package/src/lib/mcp-tools/admin.ts +267 -0
  34. package/src/lib/mcp-tools/args.ts +26 -0
  35. package/src/lib/mcp-tools/confirm.ts +21 -0
  36. package/src/lib/mcp-tools/defs.ts +388 -3
  37. package/src/lib/mcp-tools/login.ts +156 -0
  38. package/src/lib/mcp-tools/mask.ts +41 -0
  39. package/src/lib/mcp-tools/read.ts +115 -1
  40. package/src/lib/mcp-tools/result.ts +5 -5
  41. package/src/lib/mcp-tools/run.ts +101 -0
  42. package/src/lib/mcp-tools/write.ts +84 -5
  43. package/src/lib/oidc-flow.ts +43 -1
  44. package/src/lib/org-rewrap.ts +9 -3
  45. package/src/lib/service-tokens.ts +62 -0
package/README.md CHANGED
@@ -21,6 +21,8 @@ curl -fsSL https://bun.sh/install | bash
21
21
 
22
22
  # CLI 설치
23
23
  bun add -g @athsra/cli
24
+ # 또는
25
+ npm i -g @athsra/cli
24
26
  ```
25
27
 
26
28
  > Bun runtime 강제 — TypeScript 직접 실행 + native crypto 성능. Node 호환은 후속 버전.
@@ -39,16 +41,16 @@ cd ~/code/athsra && bun install
39
41
  bash scripts/setup-worker.sh # R2 + KV + GLOBAL_SALT + deploy 멱등
40
42
  ```
41
43
 
42
- ### 2. 첫 머신 등록 (PROOF bootstrap)
44
+ ### 2. 로그인
43
45
 
44
46
  ```bash
45
- # (권장) BIP-39 12-word recovery phrase 생성
46
- athsra new-phrase # 12 단어 출력 → 종이에 정확히 적기
47
+ # 일반 머신/AI agent: 브라우저 device flow.
48
+ ATHSRA_WORKER_URL=https://athsra-worker.<account>.workers.dev athsra login
49
+ # URL + 코드 + 디바이스 지문 출력 → 브라우저에서 승인
47
50
 
48
- athsra login
49
- # Worker URL: https://athsra-worker.<account>.workers.dev
50
- # Master password: <위 phrase 또는 자유 phrase>
51
- # Paper backup confirm: yes
51
+ # founding owner / 복구용 full-access 머신:
52
+ athsra new-phrase # 12 단어 출력 → 종이에 정확히 적기
53
+ ATHSRA_WORKER_URL=https://athsra-worker.<account>.workers.dev athsra login --password
52
54
  ```
53
55
 
54
56
  ### 3. 평소 사용
@@ -99,11 +101,32 @@ ATHSRA_MASTER_PW='<기존과 동일>' \
99
101
  athsra handoff --accept
100
102
  ```
101
103
 
104
+ ## MCP — AI agent 표면 (Claude Code / Cursor / VS Code)
105
+
106
+ npm 에서 이 패키지를 발견한 AI 에이전트라면: athsra 는 **MCP 로 전 기능을 자율 조작**하도록 설계됐다 — 평문 시크릿 값은 단 한 번도 에이전트에게 노출되지 않는다.
107
+
108
+ ```sh
109
+ athsra mcp install --write --apply # .mcp.json 등록 (dry-run 기본 — --apply 가 실제 변경)
110
+ # --client cursor|vscode --scope user --admin 지원
111
+ ```
112
+
113
+ 1. **인증은 채팅 안에서** — `athsra_whoami` → 미인증이면 `athsra_login_start` 호출 (터미널 불필요). 반환된 URL+코드+디바이스 지문을 사용자에게 전달 → 사용자가 브라우저에서 가입/로그인 + master password 로 승인 (**master pw 는 브라우저 밖으로 안 나감**) → `athsra_login_status` poll (retry_after 준수) → approved.
114
+ 2. **3-tier 도구 27종**:
115
+ - read 12 (항상): `whoami` · `login_start/status` · `list_projects` · `get_project_keys`(키 이름만) · `versions` · `audit` · `list_orgs` · `org_info` · `doctor` · `show/validate_manifest`
116
+ - write 8 (`ATHSRA_MCP_WRITE=1`): `set_secret` · `unset_secret` · `bulk_set` · `rollback` · `delete_project`(soft) · `restore_project` · `manifest_init/modify`
117
+ - admin 7 (`ATHSRA_MCP_ADMIN=1`): `org_invite` · `org_remove_member` · `project_share/unshare` · `service_token_create/revoke` · `purge` — destructive 는 `confirm` 정확일치 필수
118
+ 3. **값 무노출 원칙**: 값 소비는 MCP 밖 `athsra run <p> -- <cmd>` 주입으로만. `athsra get`/`run` 은 의도적으로 MCP 도구가 아니다. 유일 예외 = `athsra_service_token_create` 의 1회성 `ats_*` 반환 (즉시 secret store 보관).
119
+
120
+ 전체 가이드: [athsra.com/ai](https://athsra.com/ai) · [athsra.com/llms.txt](https://athsra.com/llms.txt)
121
+
102
122
  ## 전체 명령
103
123
 
104
124
  | 명령 | 동작 |
105
125
  |---|---|
106
- | `athsra login` | 등록 (PROOF bootstrap) |
126
+ | `athsra login` | 브라우저 완결 identity 로그인 기본 (`--password` = founding master pw 등록, `--sso`, `--device`) |
127
+ | `athsra mcp` | MCP stdio server (AI agent 표면 — 위 섹션) |
128
+ | `athsra mcp install` | MCP client 설정 등록/갱신 (claude/cursor/vscode, dry-run 기본) |
129
+ | `athsra service-token create/list/revoke` | scoped headless 토큰 (NAS/CI — master pw 없이 복호) |
107
130
  | `athsra set <p> KEY=val [...]` | secret 추가/수정 (`--from-file` / `--stdin` 지원) |
108
131
  | `athsra unset <p> KEY [...]` | 특정 key 제거 (envelope 유지) |
109
132
  | `athsra get <p> [KEY]` | 값 출력 (single 또는 dump) |
@@ -126,7 +149,7 @@ ATHSRA_MASTER_PW='<기존과 동일>' \
126
149
  | 위협 | 완화 |
127
150
  |---|---|
128
151
  | R2 leak (CF 침해) | E2EE — ciphertext 만 노출. Argon2id m=64MB × t=3 brute-force 비용 |
129
- | token leak (머신 도난) | `athsra revoke` (KV ~60s eventual). master pw 모름 decrypt 불가 |
152
+ | token leak (머신 도난) | `athsra revoke` (D1 strong consistency). master pw 또는 identity key 없으면 decrypt 불가 |
130
153
  | handoff token 가로챔 | TTL 1h + single-use settle (Phase 1.2) |
131
154
  | master pw leak | `rotate-master` — 모든 PROOF/token 갱신 + 모든 envelope re-encrypt |
132
155
  | **master pw 분실** | **종이 backup 필수** + BIP-39 12-word phrase. recovery 없음 (E2EE 본질) |
@@ -150,6 +173,7 @@ Server (athsra-worker, BSL 1.1) 는 별도 license — see [main repo](https://g
150
173
 
151
174
  ## Status
152
175
 
153
- **Phase 1.x.1 active** (2026-05-04+) — soft-delete + version history. universe internal alpha.
176
+ **Phase 5 RC** (2026-06-12) — identity device-login + MCP 27 tools + envelope member/self
177
+ recipients. Published package target: `@athsra/cli@1.1.0`.
154
178
 
155
179
  [ROADMAP.md](https://github.com/modfolio/athsra/blob/main/docs/ROADMAP.md) — 남은 작업 + 미래 분기점 SSoT.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@athsra/cli",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "athsra CLI — E2EE secret manager on Cloudflare edge. Doppler-style dev UX + zero-knowledge encryption + soft-delete + version history. MIT.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -43,9 +43,9 @@
43
43
  "typecheck": "tsc --noEmit"
44
44
  },
45
45
  "dependencies": {
46
- "@athsra/crypto": "^1.0.0",
46
+ "@athsra/crypto": "^1.1.0",
47
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
- "@napi-rs/keyring": "^1.1.6",
48
+ "@napi-rs/keyring": "^1.3.0",
49
49
  "prompts": "^2.4.2"
50
50
  },
51
51
  "devDependencies": {
@@ -1,26 +1,29 @@
1
1
  import { loadAuthContext } from '../lib/auth-context.ts';
2
+ import { CONFIG_USAGE_HINT, configTag, projectRef, resolveProject } from '../lib/auto-project.ts';
2
3
  import { promptConfirm } from '../lib/prompt.ts';
3
4
 
4
5
  const USAGE = [
5
- 'usage: athsra delete <project> # soft-delete (versions 보존, restore 가능)',
6
- ' or: athsra delete <project> --hard # hard-delete (모든 versions 영구 제거)',
7
- ' or: athsra delete <project> --yes # confirm 우회 (CI 용)',
6
+ 'usage: athsra delete <project>[:<env>] # soft-delete (versions 보존, restore 가능)',
7
+ ' or: athsra delete <project>[:<env>] --hard # hard-delete (모든 versions 영구 제거)',
8
+ ' or: athsra delete <project>[:<env>] --yes # confirm 우회 (CI 용)',
9
+ CONFIG_USAGE_HINT,
8
10
  ].join('\n');
9
11
 
10
12
  /**
11
- * athsra delete <project>
12
- * - soft (default): tombstone marker 작성, versions 보존, restore 가능
13
+ * athsra delete <project>[:<env>]
14
+ * - soft (default): tombstone marker 작성, versions 보존, restore 가능 (해당 환경(config) 한정)
13
15
  * - --hard: 모든 versions + tombstone 영구 제거. 복원 불가능.
14
16
  * - confirm 필요 (--yes 또는 ATHSRA_DELETE_CONFIRMED=1 로 우회)
15
17
  */
16
18
  export async function deleteCmd(args: string[]): Promise<number> {
17
- const project = args[0];
19
+ const { project, config, rest } = resolveProject(args, { requirePositional: true });
18
20
  if (!project) {
19
21
  console.error(USAGE);
20
22
  return 2;
21
23
  }
22
- const hard = args.includes('--hard');
23
- const yes = args.includes('--yes') || args.includes('-y');
24
+ const hard = rest.includes('--hard');
25
+ const yes = rest.includes('--yes') || rest.includes('-y');
26
+ const tag = configTag(config);
24
27
 
25
28
  const ctx = await loadAuthContext();
26
29
  if (!ctx) return 1;
@@ -28,20 +31,20 @@ export async function deleteCmd(args: string[]): Promise<number> {
28
31
 
29
32
  if (!yes && process.env.ATHSRA_DELETE_CONFIRMED !== '1') {
30
33
  const msg = hard
31
- ? `HARD-DELETE ${project}? All version history permanently removed. NOT RECOVERABLE.`
32
- : `Soft-delete ${project}? Restore via 'athsra restore ${project}'.`;
34
+ ? `HARD-DELETE ${project}${tag}? All version history permanently removed. NOT RECOVERABLE.`
35
+ : `Soft-delete ${project}${tag}? Restore via 'athsra restore ${projectRef(project, config)}'.`;
33
36
  const ok = await promptConfirm(msg, false);
34
37
  if (!ok) return 0;
35
38
  }
36
39
 
37
- const result = await client.deleteProject(project, { hard });
40
+ const result = await client.deleteProject(project, { hard, config });
38
41
  if (hard) {
39
42
  console.log(
40
- `✓ ${project}: hard-deleted (${result.removed_versions ?? 0} version${(result.removed_versions ?? 0) === 1 ? '' : 's'} removed)`,
43
+ `✓ ${project}${tag}: hard-deleted (${result.removed_versions ?? 0} version${(result.removed_versions ?? 0) === 1 ? '' : 's'} removed)`,
41
44
  );
42
45
  } else {
43
46
  console.log(
44
- `✓ ${project}: soft-deleted (${result.recoverable_versions ?? 0} version${(result.recoverable_versions ?? 0) === 1 ? '' : 's'} recoverable via 'athsra restore')`,
47
+ `✓ ${project}${tag}: soft-deleted (${result.recoverable_versions ?? 0} version${(result.recoverable_versions ?? 0) === 1 ? '' : 's'} recoverable via 'athsra restore')`,
45
48
  );
46
49
  }
47
50
  return 0;
@@ -1,21 +1,24 @@
1
1
  import { loadAuthContext } from '../lib/auth-context.ts';
2
+ import { CONFIG_USAGE_HINT, configTag, resolveProject } from '../lib/auto-project.ts';
2
3
  import { serializeEnv } from '../lib/env-format.ts';
3
4
  import { readPlain } from '../lib/envelope.ts';
4
5
 
6
+ const USAGE = ['usage: athsra get <project>[:<env>] [KEY]', CONFIG_USAGE_HINT].join('\n');
7
+
5
8
  export async function getCmd(args: string[]): Promise<number> {
6
- const project = args[0];
7
- const key = args[1];
9
+ const { project, config, rest } = resolveProject(args, { requirePositional: true });
10
+ const key = rest[0];
8
11
  if (!project) {
9
- console.error('usage: athsra get <project> [KEY]');
12
+ console.error(USAGE);
10
13
  return 2;
11
14
  }
12
15
 
13
16
  const ctx = await loadAuthContext();
14
17
  if (!ctx) return 1;
15
18
 
16
- const plain = await readPlain(ctx, project);
19
+ const plain = await readPlain(ctx, project, config);
17
20
  if (!plain) {
18
- console.error(`project not found: ${project}`);
21
+ console.error(`project not found: ${project}${configTag(config)}`);
19
22
  return 1;
20
23
  }
21
24
 
@@ -1,6 +1,6 @@
1
1
  import { hostname } from 'node:os';
2
- import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
3
2
  import { loadAuthContext, type UserAuthContext } from '../lib/auth-context.ts';
3
+ import { deriveMasterProof } from '../lib/auth-proof.ts';
4
4
  import { AthsraClient } from '../lib/client.ts';
5
5
  import { type Config, saveConfig } from '../lib/config.ts';
6
6
  import { readPlain } from '../lib/envelope.ts';
@@ -29,7 +29,12 @@ async function issueToken(): Promise<number> {
29
29
  }
30
30
 
31
31
  const info = await client.info();
32
- const proof = toBase64(deriveKey(masterPw, fromBase64(info.global_salt)));
32
+ const me = await client.whoami();
33
+ if (me.userId === undefined) {
34
+ console.error('whoami 에 user_id 없음 — 재로그인 필요 (`athsra login`).');
35
+ return 1;
36
+ }
37
+ const proof = deriveMasterProof(masterPw, me.userId, info.global_salt);
33
38
  const result = await client.handoff(proof, newLabel);
34
39
 
35
40
  const ttlMin = result.ttlSeconds ? Math.floor(result.ttlSeconds / 60) : null;
@@ -93,6 +98,10 @@ async function acceptToken(): Promise<number> {
93
98
  console.error(`✗ token invalid: ${(err as Error).message}`);
94
99
  return 1;
95
100
  }
101
+ if (me.userId === undefined) {
102
+ console.error('✗ token 에 user_id 없음 — 재로그인 필요 (`athsra login`).');
103
+ return 1;
104
+ }
96
105
 
97
106
  // master pw 검증: 첫 envelope (있으면) decrypt 시도 (v1/v2 dispatcher)
98
107
  const projects = await client.listProjects();
@@ -100,7 +109,7 @@ async function acceptToken(): Promise<number> {
100
109
  if (first) {
101
110
  const tempCtx: UserAuthContext = {
102
111
  kind: 'user',
103
- config: { workerUrl, machineId, createdAt: me.createdAt },
112
+ config: { workerUrl, machineId, createdAt: me.createdAt, userId: me.userId },
104
113
  masterPw,
105
114
  token,
106
115
  client,
@@ -118,6 +127,7 @@ async function acceptToken(): Promise<number> {
118
127
  workerUrl,
119
128
  machineId,
120
129
  createdAt: me.createdAt,
130
+ userId: me.userId,
121
131
  };
122
132
  saveConfig(config);
123
133
  setMasterPw(machineId, masterPw);
@@ -1,15 +1,16 @@
1
1
  import { hostname } from 'node:os';
2
- import {
3
- deriveProof,
4
- fromBase64,
5
- isValidPhrase,
6
- normalizePhrase,
7
- toBase64,
8
- wordCount,
9
- } from '@athsra/crypto';
2
+ import { isValidPhrase, normalizePhrase, wordCount } from '@athsra/crypto';
3
+ import { deriveMasterProof } from '../lib/auth-proof.ts';
10
4
  import { resolveProject } from '../lib/auto-project.ts';
11
5
  import { AthsraClient } from '../lib/client.ts';
12
6
  import { type Config, loadConfig, saveConfig } from '../lib/config.ts';
7
+ import {
8
+ completeIdentityLogin,
9
+ DEFAULT_WORKER_URL,
10
+ type IdentityFlow,
11
+ runDevicePollLoop,
12
+ startIdentityFlow,
13
+ } from '../lib/device-login.ts';
13
14
  import { ensureKeypair } from '../lib/identity-key.ts';
14
15
  import { probeKeyring, setDeviceToken, setMasterPw, setToken } from '../lib/keyring.ts';
15
16
  import { consumeLegacySession } from '../lib/legacy-session.ts';
@@ -133,12 +134,16 @@ async function ssoLoginCmd(): Promise<number> {
133
134
  // proof = Argon2id(masterPw, perUserSalt(user_id, GLOBAL_SALT)) 단방향 해시 — 평문 송신 X (E2EE).
134
135
  // G-1: per-user salt 로 같은 pw 두 user 도 proof 가 유일. 첫 SSO = bootstrap, 재로그인 = 검증.
135
136
  const info = await tempClient.info();
136
- const proof = toBase64(deriveProof(masterPw, ssoBody.user_id, fromBase64(info.global_salt)));
137
+ const proof = deriveMasterProof(masterPw, ssoBody.user_id, info.global_salt);
137
138
  try {
138
139
  const pr = await new AthsraClient(workerUrl, ssoBody.token).setProof(
139
140
  proof,
140
141
  info.global_salt_version,
141
142
  );
143
+ if (pr.userId !== ssoBody.user_id) {
144
+ console.error(`✗ proof user mismatch (${pr.userId} ≠ ${ssoBody.user_id}) — 재로그인 필요.`);
145
+ return 1;
146
+ }
142
147
  if (pr.version_reset) {
143
148
  console.log('• Master password re-registered (GLOBAL_SALT changed).');
144
149
  } else if (pr.bootstrap) {
@@ -162,6 +167,7 @@ async function ssoLoginCmd(): Promise<number> {
162
167
  workerUrl,
163
168
  machineId,
164
169
  createdAt: existing?.createdAt ?? ssoBody.createdAt,
170
+ userId: ssoBody.user_id,
165
171
  };
166
172
  saveConfig(config);
167
173
  setMasterPw(machineId, masterPw);
@@ -178,9 +184,6 @@ async function ssoLoginCmd(): Promise<number> {
178
184
  return 0;
179
185
  }
180
186
 
181
- /** 비-인터랙티브 agent 기본 worker (config/env 없을 때). production athsra worker. */
182
- const DEFAULT_WORKER_URL = 'https://athsra-worker.winterermod.workers.dev';
183
-
184
187
  interface DeviceArgs {
185
188
  project?: string;
186
189
  perms: 'read' | 'write';
@@ -216,8 +219,6 @@ function parseDeviceArgs(args: string[]): DeviceArgs {
216
219
  return { project, perms, noBrowser };
217
220
  }
218
221
 
219
- const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
220
-
221
222
  /**
222
223
  * Phase 3 P3 (2026-05-31) — device-login (RFC 8628 적응).
223
224
  *
@@ -268,56 +269,143 @@ async function deviceLoginCmd(args: string[]): Promise<number> {
268
269
  console.log(' (master pw 는 그 브라우저에서 1회 — 이 기기엔 저장되지 않습니다)\n');
269
270
  if (!noBrowser) openBrowser(dc.verification_uri_complete);
270
271
 
271
- // poll
272
- let interval = Math.max(1, dc.interval) * 1000;
273
- const deadline = Date.now() + dc.expires_in * 1000;
272
+ // poll (lib/device-login.ts 공용 루프 — Phase 5 B5 추출)
274
273
  process.stdout.write(' 대기 중');
275
- while (Date.now() < deadline) {
276
- await sleep(interval);
277
- let result: Awaited<ReturnType<typeof client.devicePollToken>>;
278
- try {
279
- result = await client.devicePollToken(dc.device_code);
280
- } catch {
281
- process.stdout.write('.');
282
- continue;
283
- }
284
- if (result.status === 'token') {
285
- setDeviceToken(project, result.token);
286
- saveConfig({
287
- workerUrl,
288
- machineId,
289
- createdAt: existing?.createdAt ?? new Date().toISOString(),
290
- });
291
- console.log('\n\n✓ device-login 완료');
292
- console.log(` project: ${result.project} (${result.perms})`);
293
- console.log(' keyring: device token 저장 (master pw 불필요)');
294
- console.log(` 사용: athsra run ${result.project} -- <command>\n`);
295
- return 0;
296
- }
297
- // pending / terminal
298
- if (result.error === 'access_denied') {
299
- console.error('\n\n✗ 승인이 거부되었습니다.');
300
- return 1;
301
- }
302
- if (result.error === 'expired_token') {
303
- console.error('\n\n 요청이 만료되었습니다. `athsra login --device` 재시도.');
304
- return 1;
305
- }
306
- if (result.error === 'slow_down') {
307
- interval += 5000;
274
+ const outcome = await runDevicePollLoop(client, dc.device_code, {
275
+ intervalMs: Math.max(1, dc.interval) * 1000,
276
+ expiresAt: Date.now() + dc.expires_in * 1000,
277
+ onTick: () => process.stdout.write('.'),
278
+ });
279
+ if (outcome.step === 'denied') {
280
+ console.error('\n\n✗ 승인이 거부되었습니다.');
281
+ return 1;
282
+ }
283
+ if (outcome.step === 'expired') {
284
+ console.error('\n\n✗ 요청이 만료되었습니다. `athsra login --device` 재시도.');
285
+ return 1;
286
+ }
287
+ if (outcome.step === 'timeout') {
288
+ console.error('\n\n✗ 승인 대기 시간 초과. `athsra login --device` 재시도.');
289
+ return 1;
290
+ }
291
+ const result = outcome.result;
292
+ if (result.tokenType !== 'service') {
293
+ console.error('\n\n✗ 예상치 못한 user-kind 토큰 (service device-login).');
294
+ return 1;
295
+ }
296
+ setDeviceToken(project, result.token);
297
+ saveConfig({
298
+ workerUrl,
299
+ machineId,
300
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
301
+ });
302
+ console.log('\n\n device-login 완료');
303
+ console.log(` project: ${result.project} (${result.perms})`);
304
+ console.log(' keyring: device token 저장 (master pw 불필요)');
305
+ console.log(` 사용: athsra run ${result.project} -- <command>\n`);
306
+ return 0;
307
+ }
308
+
309
+ /** identity login 후 best-effort 검증 — whoami (실패해도 login 성공 유지). */
310
+ async function verifyIdentityLogin(client: AthsraClient): Promise<void> {
311
+ try {
312
+ const me = await client.whoami();
313
+ if (me.userId !== undefined) {
314
+ console.log(` verified: user #${me.userId} ✓`);
308
315
  }
309
- process.stdout.write('.');
316
+ } catch {
317
+ console.warn(' verify: 보류 (login 자체는 성공 — 다음 명령에서 재확인).');
310
318
  }
311
- console.error('\n\n✗ 승인 대기 시간 초과. `athsra login --device` 재시도.');
312
- return 1;
319
+ }
320
+
321
+ /**
322
+ * Phase 5 (2026-06-12) — identity device-login (기본 `athsra login`).
323
+ *
324
+ * 브라우저 완결 흐름 (TTY 0회, master pw 이 머신 미보관):
325
+ * 1. 디바이스 X25519 키쌍 생성 (privkey 는 이 머신에만 — sealed-box unseal 용)
326
+ * 2. deviceCode(kind:user, device_pub_key) → user_code + URL + fingerprint 출력 + openBrowser
327
+ * 3. 사용자가 athsra.com/device 에서 로그인 + master pw 로 identity priv 봉인 → complete
328
+ * 4. poll → {token(atk_*), sealed_identity_key, user_id, key_version}
329
+ * 5. unseal(user_id 대조) → identity priv → keyring 저장 + token + config.userId
330
+ * 6. whoami best-effort 검증
331
+ */
332
+ async function identityLoginCmd(args: string[]): Promise<number> {
333
+ const noBrowser = args.includes('--no-browser');
334
+
335
+ // 1. flow 시작 (keyring probe → config/worker 해석 → 키쌍 생성 → deviceCode) —
336
+ // lib/device-login.ts 공용 코어 (Phase 5 B5 추출, MCP athsra_login_start 와 동일 경로).
337
+ let flow: IdentityFlow;
338
+ try {
339
+ flow = await startIdentityFlow();
340
+ } catch (err) {
341
+ console.error(`✗ ${(err as Error).message}`);
342
+ return 1;
343
+ }
344
+
345
+ console.log('\n● athsra login — 브라우저에서 승인이 필요합니다\n');
346
+ console.log(` 1. 열기: ${flow.verificationUriComplete}`);
347
+ console.log(` 2. 코드: ${flow.userCode}`);
348
+ console.log(
349
+ ` 3. 지문: ${flow.fingerprint} ← 브라우저 화면의 지문과 일치하는지 확인 (phishing 가드)`,
350
+ );
351
+ console.log('\n athsra.com 에 로그인 후 master password 로 승인하면 자동 진행됩니다.');
352
+ console.log(' (master password 는 그 브라우저에서만 — 이 기기엔 저장되지 않습니다)\n');
353
+ if (!noBrowser) openBrowser(flow.verificationUriComplete);
354
+
355
+ // 2. poll (공용 루프)
356
+ process.stdout.write(' 대기 중');
357
+ const outcome = await runDevicePollLoop(flow.client, flow.deviceCode, {
358
+ intervalMs: flow.intervalMs,
359
+ expiresAt: flow.expiresAt,
360
+ onTick: () => process.stdout.write('.'),
361
+ });
362
+ if (outcome.step === 'denied') {
363
+ console.error('\n\n✗ 승인이 거부되었습니다.');
364
+ return 1;
365
+ }
366
+ if (outcome.step === 'expired') {
367
+ console.error('\n\n✗ 요청이 만료되었습니다. `athsra login` 재시도.');
368
+ return 1;
369
+ }
370
+ if (outcome.step === 'timeout') {
371
+ console.error('\n\n✗ 승인 대기 시간 초과. `athsra login` 재시도.');
372
+ return 1;
373
+ }
374
+ const result = outcome.result;
375
+ if (result.tokenType !== 'user') {
376
+ console.error('\n\n✗ 예상치 못한 service 토큰 (identity login).');
377
+ return 1;
378
+ }
379
+
380
+ // 3. unseal(user_id 대조) → keyring identity+token → config.userId (공용 완료 처리)
381
+ try {
382
+ await completeIdentityLogin({
383
+ result,
384
+ devicePrivateKey: flow.devicePrivateKey,
385
+ machineId: flow.machineId,
386
+ workerUrl: flow.workerUrl,
387
+ existingCreatedAt: flow.existingCreatedAt,
388
+ });
389
+ } catch (err) {
390
+ console.error(`\n\n✗ identity key unseal 실패: ${(err as Error).message}`);
391
+ return 1;
392
+ }
393
+ console.log('\n\n✓ logged in (identity 모드)');
394
+ console.log(` machine: ${flow.machineId}`);
395
+ console.log(` worker: ${flow.workerUrl}`);
396
+ console.log(' keyring: identity key + token 저장 (master pw 이 기기엔 없음)');
397
+ // 4. best-effort 검증
398
+ await verifyIdentityLogin(new AthsraClient(flow.workerUrl, result.token));
399
+ return 0;
313
400
  }
314
401
 
315
402
  const LOGIN_USAGE = [
316
- 'usage: athsra login [--sso | --device]',
403
+ 'usage: athsra login [--password | --sso | --device]',
317
404
  '',
318
- '기본 (master pw + paper backup): athsra login',
405
+ '기본 (identity device-login, master pw 기기 미보관): athsra login',
406
+ 'master pw register (founding owner, full 권한): athsra login --password',
319
407
  'SSO (modfolio-connect OIDC PKCE — Phase 2.8): athsra login --sso',
320
- 'device (비-TTY agent, master pw 불필요 — Phase 3 P3): athsra login --device [--project <p>] [--write]',
408
+ 'device (비-TTY agent, project-scoped — Phase 3 P3): athsra login --device [--project <p>] [--write]',
321
409
  '',
322
410
  'device-login: agent 가 user_code 출력 → 사용자가 athsra.com/device 에서 1-click 승인 →',
323
411
  ' project-scoped ats_* 수령 (keyring 저장). master pw 는 승인 브라우저에서만 사용.',
@@ -342,7 +430,18 @@ export async function loginCmd(args: string[]): Promise<number> {
342
430
  if (args.includes('--sso')) {
343
431
  return ssoLoginCmd();
344
432
  }
345
- console.log('athsra login\n');
433
+ if (args.includes('--password')) {
434
+ return passwordLoginCmd();
435
+ }
436
+ return identityLoginCmd(args);
437
+ }
438
+
439
+ /**
440
+ * 기존 기본 — founding(user 1) master pw register. Phase 5 부터 `--password` 로 명시.
441
+ * identity 모드(신규 기본)와 달리 이 머신 keyring 에 master pw 보관 (full owner 권한).
442
+ */
443
+ async function passwordLoginCmd(): Promise<number> {
444
+ console.log('athsra login --password\n');
346
445
 
347
446
  // 1. keyring backend probe (정공법: fallback 없음)
348
447
  const probe = probeKeyring();
@@ -451,13 +550,18 @@ export async function loginCmd(args: string[]): Promise<number> {
451
550
  // password register 는 founding singleton(user 1) 전용(SSO 외 유일 경로) — userId=1 고정.
452
551
  // 다중 사용자 self-serve password register 는 out-of-scope(SSO-gated 유지).
453
552
  const FOUNDING_USER_ID = 1;
454
- const proofBytes = deriveProof(masterPw, FOUNDING_USER_ID, fromBase64(info.global_salt));
455
- const proofBase64 = toBase64(proofBytes);
553
+ const proofBase64 = deriveMasterProof(masterPw, FOUNDING_USER_ID, info.global_salt);
456
554
 
457
555
  // 8. register → token
458
556
  let reg: Awaited<ReturnType<typeof tempClient.register>>;
459
557
  try {
460
558
  reg = await tempClient.register(proofBase64, machineId);
559
+ if (reg.userId !== undefined && reg.userId !== FOUNDING_USER_ID) {
560
+ console.error(
561
+ `✗ register user mismatch (${reg.userId} ≠ ${FOUNDING_USER_ID}) — worker 확인 필요.`,
562
+ );
563
+ return 1;
564
+ }
461
565
  } catch (err) {
462
566
  const msg = (err as Error).message;
463
567
  if (msg.includes('401')) {
@@ -474,6 +578,7 @@ export async function loginCmd(args: string[]): Promise<number> {
474
578
  workerUrl,
475
579
  machineId,
476
580
  createdAt: existing?.createdAt ?? reg.createdAt,
581
+ userId: reg.userId ?? FOUNDING_USER_ID,
477
582
  };
478
583
  saveConfig(config);
479
584
  setMasterPw(machineId, masterPw);
@@ -2,7 +2,7 @@ import { existsSync, unlinkSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { loadConfig } from '../lib/config.ts';
5
- import { clearMasterPw, clearToken } from '../lib/keyring.ts';
5
+ import { clearIdentityKey, clearMasterPw, clearToken } from '../lib/keyring.ts';
6
6
  import { promptConfirm } from '../lib/prompt.ts';
7
7
 
8
8
  /**
@@ -33,8 +33,9 @@ export async function logoutCmd(args: string[]): Promise<number> {
33
33
 
34
34
  clearMasterPw(config.machineId);
35
35
  clearToken(config.machineId);
36
+ clearIdentityKey(config.machineId);
36
37
  console.log(`✓ keyring cleared for machine: ${config.machineId}`);
37
- console.log(' • master-pw + Bearer token removed from OS keyring');
38
+ console.log(' • master-pw + identity key + Bearer token removed from OS keyring');
38
39
  console.log(' • worker-side token still active — `athsra revoke` to invalidate fully');
39
40
 
40
41
  if (full) {
@@ -1,23 +1,30 @@
1
1
  import { loadAuthContext } from '../lib/auth-context.ts';
2
+ import { configTag, projectRef, resolveProject } from '../lib/auto-project.ts';
2
3
  import { partitionEnv } from '../lib/env-format.ts';
3
4
  import { readPlain } from '../lib/envelope.ts';
4
5
 
6
+ /** ls 자체 flag — project/config 해석 전에 args 에서 제거 (positional 오인 방지). */
7
+ const LS_FLAGS = ['--all', '--include-deleted', '--configs'];
8
+
5
9
  /**
6
10
  * athsra ls — active projects only
7
11
  * athsra ls --all — active + soft-deleted (deleted 표시)
8
12
  * athsra ls --include-deleted — alias of --all
9
- * athsra ls <project> — keys of project (decrypt 필요)
13
+ * athsra ls <project>[:<env>] — keys of a project/environment (decrypt 필요)
14
+ * athsra ls <project> --configs — environments(config) of a project + active/deleted 상태
10
15
  */
11
16
  export async function lsCmd(args: string[]): Promise<number> {
12
17
  const ctx = await loadAuthContext();
13
18
  if (!ctx) return 1;
14
19
  const client = ctx.client;
15
20
 
16
- const positional = args.filter((a) => !a.startsWith('-'));
21
+ const wantConfigs = args.includes('--configs');
17
22
  const includeDeleted = args.includes('--all') || args.includes('--include-deleted');
23
+ const cleaned = args.filter((a) => !LS_FLAGS.includes(a));
24
+ const { project, config } = resolveProject(cleaned, { requirePositional: true });
18
25
 
19
26
  // ls — project 목록
20
- if (positional.length === 0) {
27
+ if (!project) {
21
28
  const result = await client.listProjectsExtended({ includeDeleted });
22
29
  if (result.count === 0) {
23
30
  console.log('(no projects yet — run `athsra set <project> KEY=value`)');
@@ -37,30 +44,45 @@ export async function lsCmd(args: string[]): Promise<number> {
37
44
  return 0;
38
45
  }
39
46
 
40
- // ls <project> — key 목록 + 상태 ((empty) 또는 길이, decrypt 필요)
41
- const project = positional[0];
42
- if (!project) return 0;
47
+ // ls <project> --configs 환경(config) 목록 + active/deleted 상태 (default * 표시)
48
+ if (wantConfigs) {
49
+ const result = await client.listConfigs(project);
50
+ if (result.count === 0) {
51
+ console.log(`(no environments for ${project})`);
52
+ return 0;
53
+ }
54
+ const width = Math.max(...result.configs.map((cfg) => cfg.config.length));
55
+ for (const cfg of result.configs) {
56
+ const status = cfg.deleted ? '(deleted)' : cfg.active ? 'active' : '(empty)';
57
+ const star = cfg.config === 'default' ? '*' : ' ';
58
+ console.log(`${star} ${cfg.config.padEnd(width)} ${status}`);
59
+ }
60
+ console.log(`(${result.count} environment${result.count > 1 ? 's' : ''})`);
61
+ return 0;
62
+ }
43
63
 
44
- const plain = await readPlain(ctx, project);
64
+ // ls <project>[:<env>] key 목록 + 값 상태 ((empty) 또는 길이, decrypt 필요)
65
+ const plain = await readPlain(ctx, project, config);
45
66
  if (!plain) {
46
- console.error(`project not found: ${project}`);
67
+ console.error(`project not found: ${project}${configTag(config)}`);
47
68
  return 1;
48
69
  }
49
70
  const keys = Object.keys(plain).sort();
50
71
  if (keys.length === 0) {
51
- console.log('(no keys)');
72
+ console.log(`(no keys${configTag(config)})`);
52
73
  return 0;
53
74
  }
54
75
  const { filled, emptyKeys } = partitionEnv(plain);
55
76
  const emptySet = new Set(emptyKeys);
56
77
  const width = Math.max(...keys.map((k) => k.length));
78
+ if (config !== 'default') console.log(`# config: ${config}`);
57
79
  for (const k of keys) {
58
80
  const status = emptySet.has(k) ? '(empty)' : `${(filled[k] ?? '').length} chars`;
59
81
  console.log(`${k.padEnd(width)} ${status}`);
60
82
  }
61
83
  if (emptyKeys.length > 0) {
62
84
  console.error(
63
- `\n${emptyKeys.length} of ${keys.length} key${keys.length > 1 ? 's' : ''} empty — \`athsra run\` skips empty keys (parent env used). Set values: \`athsra set ${project} KEY=value\``,
85
+ `\n${emptyKeys.length} of ${keys.length} key${keys.length > 1 ? 's' : ''} empty — \`athsra run\` skips empty keys (parent env used). Set values: \`athsra set ${projectRef(project, config)} KEY=value\``,
64
86
  );
65
87
  }
66
88
  return 0;