@athsra/cli 1.0.4 → 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.
- package/README.md +34 -10
- package/package.json +3 -3
- package/src/commands/delete.ts +16 -13
- package/src/commands/get.ts +8 -5
- package/src/commands/handoff.ts +13 -3
- package/src/commands/login.ts +164 -59
- package/src/commands/logout.ts +3 -2
- package/src/commands/ls.ts +32 -10
- package/src/commands/manifest.ts +2 -2
- package/src/commands/mcp.ts +259 -7
- package/src/commands/migrate-envelopes.ts +55 -3
- package/src/commands/purge.ts +13 -10
- package/src/commands/restore.ts +10 -6
- package/src/commands/rollback.ts +12 -9
- package/src/commands/rotate-master.ts +13 -13
- package/src/commands/run.ts +6 -24
- package/src/commands/service-token.ts +15 -31
- package/src/commands/set.ts +7 -6
- package/src/commands/unset.ts +11 -8
- package/src/commands/versions.ts +7 -5
- package/src/index.ts +12 -8
- package/src/lib/auth-context.ts +74 -12
- package/src/lib/auth-proof.ts +10 -0
- package/src/lib/auto-project.ts +58 -14
- package/src/lib/client.ts +94 -17
- package/src/lib/config.ts +2 -0
- package/src/lib/device-login.ts +157 -0
- package/src/lib/env-format.ts +1 -1
- package/src/lib/envelope.ts +105 -15
- package/src/lib/identity-key.ts +21 -0
- package/src/lib/keyring.ts +25 -0
- package/src/lib/mcp-register.ts +223 -0
- package/src/lib/mcp-tools/admin.ts +267 -0
- package/src/lib/mcp-tools/args.ts +26 -0
- package/src/lib/mcp-tools/confirm.ts +21 -0
- package/src/lib/mcp-tools/defs.ts +388 -3
- package/src/lib/mcp-tools/login.ts +156 -0
- package/src/lib/mcp-tools/mask.ts +41 -0
- package/src/lib/mcp-tools/read.ts +115 -1
- package/src/lib/mcp-tools/result.ts +5 -5
- package/src/lib/mcp-tools/run.ts +101 -0
- package/src/lib/mcp-tools/write.ts +84 -5
- package/src/lib/oidc-flow.ts +43 -1
- package/src/lib/org-rewrap.ts +9 -3
- 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.
|
|
44
|
+
### 2. 로그인
|
|
43
45
|
|
|
44
46
|
```bash
|
|
45
|
-
#
|
|
46
|
-
athsra
|
|
47
|
+
# 일반 머신/AI agent: 브라우저 device flow.
|
|
48
|
+
ATHSRA_WORKER_URL=https://athsra-worker.<account>.workers.dev athsra login
|
|
49
|
+
# URL + 코드 + 디바이스 지문 출력 → 브라우저에서 승인
|
|
47
50
|
|
|
48
|
-
|
|
49
|
-
#
|
|
50
|
-
|
|
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` |
|
|
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` (
|
|
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
|
|
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
|
+
"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
|
|
46
|
+
"@athsra/crypto": "^1.1.0",
|
|
47
47
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
|
-
"@napi-rs/keyring": "^1.
|
|
48
|
+
"@napi-rs/keyring": "^1.3.0",
|
|
49
49
|
"prompts": "^2.4.2"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
package/src/commands/delete.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
23
|
-
const yes =
|
|
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;
|
package/src/commands/get.ts
CHANGED
|
@@ -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
|
|
7
|
-
const key =
|
|
9
|
+
const { project, config, rest } = resolveProject(args, { requirePositional: true });
|
|
10
|
+
const key = rest[0];
|
|
8
11
|
if (!project) {
|
|
9
|
-
console.error(
|
|
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
|
|
package/src/commands/handoff.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/commands/login.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { hostname } from 'node:os';
|
|
2
|
-
import {
|
|
3
|
-
|
|
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 =
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
316
|
+
} catch {
|
|
317
|
+
console.warn(' verify: 보류 (login 자체는 성공 — 다음 명령에서 재확인).');
|
|
310
318
|
}
|
|
311
|
-
|
|
312
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
|
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);
|
package/src/commands/logout.ts
CHANGED
|
@@ -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) {
|
package/src/commands/ls.ts
CHANGED
|
@@ -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>
|
|
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
|
|
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 (
|
|
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> —
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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(
|
|
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;
|