@athsra/cli 0.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/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 athsra contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ This license applies to the following packages:
24
+ - packages/crypto
25
+ - packages/cli (when added in Phase 0 Day 1 PM)
26
+ - packages/sdk-* (when added in Phase 2)
27
+ - integrations/* (when added in Phase 3+)
28
+
29
+ Server code (apps/worker) is licensed separately under BSL 1.1 — see LICENSE-BSL.
package/README.md ADDED
@@ -0,0 +1,155 @@
1
+ # @athsra/cli
2
+
3
+ [athsra](https://github.com/modfolio/athsra) — **E2EE secret manager on Cloudflare edge**. Doppler 의 dev UX + zero-knowledge encryption + Cloudflare 글로벌 edge `<50ms` latency.
4
+
5
+ > **Brand**: 한국어 "아스라이" (어렴풋이) 어원, 발음 _Ah-sra_.
6
+
7
+ ## 핵심 가치
8
+
9
+ - **Zero-knowledge E2EE** — server (CF Worker) 는 ciphertext + Argon2id proof 만, master password 절대 X
10
+ - **Self-hosted** — 본인 Cloudflare 계정 (R2 + KV + Workers, free tier 충분)
11
+ - **Doppler-style** — `athsra run <project> -- <cmd>` 으로 env inject 후 명령 실행
12
+ - **Soft-delete + version history** — 모든 PUT 은 immutable version 보존, DELETE 는 default soft (restore 가능)
13
+ - **Cross-machine** — handoff TTL + single-use settle 로 새 머신 추가
14
+ - **BIP-39 12-word phrase** — master pw 권장 형식 (paper backup + checksum)
15
+
16
+ ## 설치 (prereq: Bun 1.3+)
17
+
18
+ ```bash
19
+ # Bun 설치 (없으면)
20
+ curl -fsSL https://bun.sh/install | bash
21
+
22
+ # CLI 설치
23
+ bun add -g @athsra/cli
24
+ ```
25
+
26
+ > Bun runtime 강제 — TypeScript 직접 실행 + native crypto 성능. Node 호환은 후속 버전.
27
+ >
28
+ > Linux/WSL2: `gnome-keyring` + `libsecret-1-dev` + `dbus-x11` 필요. macOS/Windows 자동 (Keychain / Cred Manager).
29
+
30
+ ## Quick start
31
+
32
+ ### 1. Worker 배포 (1머신 1회)
33
+
34
+ `athsra-worker` 를 본인 CF 계정에 배포:
35
+
36
+ ```bash
37
+ gh repo clone modfolio/athsra ~/code/athsra
38
+ cd ~/code/athsra && bun install
39
+ bash scripts/setup-worker.sh # R2 + KV + GLOBAL_SALT + deploy 멱등
40
+ ```
41
+
42
+ ### 2. 첫 머신 등록 (PROOF bootstrap)
43
+
44
+ ```bash
45
+ # (권장) BIP-39 12-word recovery phrase 생성
46
+ athsra new-phrase # 12 단어 출력 → 종이에 정확히 적기
47
+
48
+ athsra login
49
+ # Worker URL: https://athsra-worker.<account>.workers.dev
50
+ # Master password: <위 phrase 또는 자유 phrase>
51
+ # Paper backup confirm: yes
52
+ ```
53
+
54
+ ### 3. 평소 사용
55
+
56
+ ```bash
57
+ # secret 추가 / 수정
58
+ athsra set my-app DATABASE_URL=postgres://...
59
+ athsra set my-app API_KEY=sk_xxx STRIPE_KEY=sk_yyy
60
+
61
+ # .env 형식 일괄 import
62
+ athsra set my-app --from-file .env
63
+
64
+ # 조회
65
+ athsra get my-app # dump 모두 (.env 형식)
66
+ athsra get my-app DATABASE_URL # 특정 key
67
+ athsra ls # project 목록
68
+ athsra ls my-app # key 이름 목록 (값 X)
69
+
70
+ # Doppler-style env inject
71
+ athsra run my-app -- bun run dev
72
+ athsra run my-app -- npm run build
73
+ ```
74
+
75
+ ### 4. 실수 복구 (Phase 1.x.1)
76
+
77
+ ```bash
78
+ athsra versions my-app # 모든 version + tombstone 상태
79
+ athsra rollback my-app v1234 # 특정 version 으로 current 복원
80
+ athsra delete my-app # soft-delete (versions 보존)
81
+ athsra ls --all # 'my-app (deleted)' 표시
82
+ athsra restore my-app # 최신 version 으로 활성화
83
+ athsra purge my-app # 영구 삭제 (double-confirm)
84
+ ```
85
+
86
+ 원리: R2 `secrets/<project>/{current,versions/<id>,tombstone}.json` 3-tier layout. PUT 시 새 version + current 갱신 + tombstone 자동 제거 (auto-restore).
87
+
88
+ ### 5. 다른 머신 추가
89
+
90
+ ```bash
91
+ # 기존 머신
92
+ athsra handoff # New machine label → handoff token (1h TTL, single-use)
93
+
94
+ # 새 머신
95
+ ATHSRA_HANDOFF_TOKEN='atk_...' \
96
+ ATHSRA_HANDOFF_MACHINE='home-desktop' \
97
+ ATHSRA_WORKER_URL='https://...' \
98
+ ATHSRA_MASTER_PW='<기존과 동일>' \
99
+ athsra handoff --accept
100
+ ```
101
+
102
+ ## 전체 명령
103
+
104
+ | 명령 | 동작 |
105
+ |---|---|
106
+ | `athsra login` | 첫 등록 (PROOF bootstrap) |
107
+ | `athsra set <p> KEY=val [...]` | secret 추가/수정 (`--from-file` / `--stdin` 지원) |
108
+ | `athsra unset <p> KEY [...]` | 특정 key 제거 (envelope 유지) |
109
+ | `athsra get <p> [KEY]` | 값 출력 (single 또는 dump) |
110
+ | `athsra ls [<p>] [--all]` | project 또는 key 목록 |
111
+ | `athsra run <p> -- <cmd>` | env inject 후 명령 실행 |
112
+ | `athsra versions <p>` | 모든 version + tombstone 상태 |
113
+ | `athsra rollback <p> <vid>` | 특정 version 으로 current 복원 |
114
+ | `athsra delete <p> [--hard]` | soft-delete (default) 또는 hard-delete |
115
+ | `athsra restore <p>` | tombstone 제거 + 최신 version 활성화 |
116
+ | `athsra purge <p>` | hard-delete 별칭 (double-confirm) |
117
+ | `athsra rotate-master` | master pw 변경 (모든 envelope re-encrypt) |
118
+ | `athsra new-phrase` | BIP-39 12-word recovery phrase 생성 |
119
+ | `athsra handoff [--accept]` | 새 머신 추가 |
120
+ | `athsra revoke [<atk_*>]` | self 또는 명시 token revoke |
121
+ | `athsra doctor` | 환경 검증 (keyring/dbus/worker phase) |
122
+ | `athsra init <p>` | 신규 project 안내 |
123
+
124
+ ## 보안
125
+
126
+ | 위협 | 완화 |
127
+ |---|---|
128
+ | R2 leak (CF 침해) | E2EE — ciphertext 만 노출. Argon2id m=64MB × t=3 brute-force 비용 |
129
+ | token leak (머신 도난) | `athsra revoke` (KV ~60s eventual). master pw 모름 → decrypt 불가 |
130
+ | handoff token 가로챔 | TTL 1h + single-use settle (Phase 1.2) |
131
+ | master pw leak | `rotate-master` — 모든 PROOF/token 갱신 + 모든 envelope re-encrypt |
132
+ | **master pw 분실** | **종이 backup 필수** + BIP-39 12-word phrase. recovery 없음 (E2EE 본질) |
133
+ | **실수 삭제 / 덮어쓰기** | soft-delete + version history (Phase 1.x.1) — restore/rollback 으로 복구 |
134
+ | keyring leak | OS 자체 격리 (libsecret D-Bus / Keychain / Cred Manager DPAPI) |
135
+
136
+ 자세한 architecture + threat model: [github.com/modfolio/athsra/blob/main/docs/ARCHITECTURE.md](https://github.com/modfolio/athsra/blob/main/docs/ARCHITECTURE.md).
137
+
138
+ ## Cryptographic primitives
139
+
140
+ - **Argon2id** (memory-hard KDF) — PHC 2015 winner, OWASP 2024+ 1순위. m=64MB. `@noble/hashes/argon2` (Cure53 부분 audit, 0 deps)
141
+ - **AES-256-GCM** — WebCrypto native. NIST SP 800-38D / FIPS 140-2 approved. nonce 12B per-envelope
142
+ - **SHA-256** — WebCrypto subtle.digest (token hash)
143
+ - **BIP-39 mnemonic** — `@scure/bip39` (paulmillr audited). 128-bit entropy + 4-bit checksum
144
+
145
+ ## License
146
+
147
+ MIT — [LICENSE](./LICENSE)
148
+
149
+ Server (athsra-worker, BSL 1.1) 는 별도 license — see [main repo](https://github.com/modfolio/athsra).
150
+
151
+ ## Status
152
+
153
+ **Phase 1.x.1 active** (2026-05-04+) — soft-delete + version history. universe internal alpha.
154
+
155
+ [ROADMAP.md](https://github.com/modfolio/athsra/blob/main/docs/ROADMAP.md) — 남은 작업 + 미래 분기점 SSoT.
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@athsra/cli",
3
+ "version": "0.1.0",
4
+ "description": "athsra CLI — E2EE secret manager on Cloudflare edge. Doppler-style dev UX + zero-knowledge encryption + soft-delete + version history. MIT.",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "bin": {
8
+ "athsra": "./src/index.ts"
9
+ },
10
+ "license": "MIT",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/modfolio/athsra.git",
14
+ "directory": "packages/cli"
15
+ },
16
+ "homepage": "https://github.com/modfolio/athsra",
17
+ "bugs": "https://github.com/modfolio/athsra/issues",
18
+ "keywords": [
19
+ "secret-manager",
20
+ "secrets",
21
+ "e2ee",
22
+ "encryption",
23
+ "argon2id",
24
+ "aes-256-gcm",
25
+ "cloudflare-workers",
26
+ "doppler-alternative",
27
+ "athsra"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "registry": "https://registry.npmjs.org/"
32
+ },
33
+ "files": ["src/", "README.md", "LICENSE"],
34
+ "engines": {
35
+ "bun": ">=1.3"
36
+ },
37
+ "scripts": {
38
+ "test": "bun test",
39
+ "typecheck": "tsc --noEmit"
40
+ },
41
+ "dependencies": {
42
+ "@athsra/crypto": "^0.1.0",
43
+ "@napi-rs/keyring": "^1.1.6",
44
+ "@scure/bip39": "^2.2.0",
45
+ "prompts": "^2.4.2"
46
+ },
47
+ "devDependencies": {
48
+ "@types/bun": "^1.3.11",
49
+ "@types/prompts": "^2.4.9",
50
+ "typescript": "^6.0.2"
51
+ }
52
+ }
@@ -0,0 +1,48 @@
1
+ import { loadAuthContext } from '../lib/auth-context.ts';
2
+ import { promptConfirm } from '../lib/prompt.ts';
3
+
4
+ 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 용)',
8
+ ].join('\n');
9
+
10
+ /**
11
+ * athsra delete <project>
12
+ * - soft (default): tombstone marker 작성, versions 보존, restore 가능
13
+ * - --hard: 모든 versions + tombstone 영구 제거. 복원 불가능.
14
+ * - confirm 필요 (--yes 또는 ATHSRA_DELETE_CONFIRMED=1 로 우회)
15
+ */
16
+ export async function deleteCmd(args: string[]): Promise<number> {
17
+ const project = args[0];
18
+ if (!project) {
19
+ console.error(USAGE);
20
+ return 2;
21
+ }
22
+ const hard = args.includes('--hard');
23
+ const yes = args.includes('--yes') || args.includes('-y');
24
+
25
+ const ctx = loadAuthContext();
26
+ if (!ctx) return 1;
27
+ const { client } = ctx;
28
+
29
+ if (!yes && process.env.ATHSRA_DELETE_CONFIRMED !== '1') {
30
+ const msg = hard
31
+ ? `HARD-DELETE ${project}? All version history permanently removed. NOT RECOVERABLE.`
32
+ : `Soft-delete ${project}? Restore via 'athsra restore ${project}'.`;
33
+ const ok = await promptConfirm(msg, false);
34
+ if (!ok) return 0;
35
+ }
36
+
37
+ const result = await client.deleteProject(project, { hard });
38
+ if (hard) {
39
+ console.log(
40
+ `✓ ${project}: hard-deleted (${result.removed_versions ?? 0} version${(result.removed_versions ?? 0) === 1 ? '' : 's'} removed)`,
41
+ );
42
+ } else {
43
+ console.log(
44
+ `✓ ${project}: soft-deleted (${result.recoverable_versions ?? 0} version${(result.recoverable_versions ?? 0) === 1 ? '' : 's'} recoverable via 'athsra restore')`,
45
+ );
46
+ }
47
+ return 0;
48
+ }
@@ -0,0 +1,76 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { AthsraClient } from '../lib/client.ts';
3
+ import { CONFIG_FILE, loadConfig, SESSION_FILE } from '../lib/config.ts';
4
+ import { getMasterPw, getToken, probeKeyring } from '../lib/keyring.ts';
5
+
6
+ export async function doctorCmd(_args: string[]): Promise<number> {
7
+ console.log('athsra doctor\n');
8
+
9
+ const kr = probeKeyring();
10
+ console.log(` keyring backend: ${kr.ok ? '✓ OK' : `✗ ${kr.error ?? 'unknown'}`}`);
11
+ if (!kr.ok) {
12
+ console.log(' setup (WSL2/Linux):');
13
+ console.log(' sudo apt update && sudo apt install gnome-keyring libsecret-1-dev dbus-x11');
14
+ console.log(' eval $(dbus-launch --sh-syntax) # if DBUS_SESSION_BUS_ADDRESS missing');
15
+ console.log(' macOS / Windows: 자동 (Keychain / Credential Manager)');
16
+ }
17
+ console.log(
18
+ ` DBUS_SESSION_BUS_ADDRESS: ${process.env.DBUS_SESSION_BUS_ADDRESS ? '✓ present' : '✗ missing'}`,
19
+ );
20
+
21
+ const config = loadConfig();
22
+ console.log(` config: ${existsSync(CONFIG_FILE) ? '✓' : '✗'} ${CONFIG_FILE}`);
23
+ if (!config) {
24
+ console.log(' (run `athsra login` first)');
25
+ return kr.ok ? 0 : 1;
26
+ }
27
+ console.log(` worker: ${config.workerUrl}`);
28
+ console.log(` machine: ${config.machineId}`);
29
+ console.log(` created: ${config.createdAt}`);
30
+
31
+ if (existsSync(SESSION_FILE)) {
32
+ console.log(` ⚠ legacy: ${SESSION_FILE} 잔존 (next \`athsra login\` 시 자동 제거).`);
33
+ }
34
+
35
+ const masterPw = kr.ok ? getMasterPw(config.machineId) : null;
36
+ const token = kr.ok ? getToken(config.machineId) : null;
37
+ console.log(` master-pw (keyring): ${masterPw ? '✓ present' : '✗ missing'}`);
38
+ console.log(` token (keyring): ${token ? '✓ present' : '✗ missing'}`);
39
+
40
+ const client = new AthsraClient(config.workerUrl, token);
41
+ const reachable = await client.health();
42
+ console.log(` worker reachable: ${reachable ? '✓' : '✗'}`);
43
+ if (!reachable) return 1;
44
+
45
+ try {
46
+ const info = await client.info();
47
+ console.log(` worker phase: ${info.phase}`);
48
+ console.log(` global_salt v${info.global_salt_version}: ${info.global_salt.slice(0, 12)}…`);
49
+ if (info.phase < 1) {
50
+ console.log(' ⚠ phase < 1 — Pre-A 인증 미배포 상태.');
51
+ }
52
+ } catch (err) {
53
+ console.log(` info: error — ${(err as Error).message}`);
54
+ }
55
+
56
+ if (token) {
57
+ try {
58
+ const me = await client.whoami();
59
+ console.log(` whoami: machineId=${me.machineId} lastSeenAt=${me.lastSeenAt}`);
60
+ } catch (err) {
61
+ console.log(` whoami: ✗ ${(err as Error).message}`);
62
+ }
63
+ try {
64
+ const projects = await client.listProjects();
65
+ const head = projects.slice(0, 5).join(', ');
66
+ const more = projects.length > 5 ? ', …' : '';
67
+ console.log(
68
+ ` projects: ${projects.length}${projects.length ? ` (${head}${more})` : ''}`,
69
+ );
70
+ } catch (err) {
71
+ console.log(` projects: fetch error — ${(err as Error).message}`);
72
+ }
73
+ }
74
+
75
+ return kr.ok && reachable ? 0 : 1;
76
+ }
@@ -0,0 +1,40 @@
1
+ import { decrypt, deriveKey, fromBase64 } from '@athsra/crypto';
2
+ import { loadAuthContext } from '../lib/auth-context.ts';
3
+ import { parseEnv } from '../lib/env-format.ts';
4
+
5
+ export async function getCmd(args: string[]): Promise<number> {
6
+ const project = args[0];
7
+ const key = args[1];
8
+ if (!project) {
9
+ console.error('usage: athsra get <project> [KEY]');
10
+ return 2;
11
+ }
12
+
13
+ const ctx = loadAuthContext();
14
+ if (!ctx) return 1;
15
+ const { masterPw, client } = ctx;
16
+ const envelope = await client.getEnvelope(project);
17
+ if (!envelope) {
18
+ console.error(`project not found: ${project}`);
19
+ return 1;
20
+ }
21
+
22
+ const derived = deriveKey(masterPw, fromBase64(envelope.salt));
23
+ const text = await decrypt(derived, {
24
+ ciphertext: fromBase64(envelope.ciphertext),
25
+ nonce: fromBase64(envelope.nonce),
26
+ });
27
+
28
+ if (key) {
29
+ const plain = parseEnv(text);
30
+ const value = plain[key];
31
+ if (value === undefined) {
32
+ console.error(`key not found: ${key}`);
33
+ return 1;
34
+ }
35
+ console.log(value);
36
+ } else {
37
+ console.log(text);
38
+ }
39
+ return 0;
40
+ }
@@ -0,0 +1,138 @@
1
+ import { hostname } from 'node:os';
2
+ import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
3
+ import { loadAuthContext } from '../lib/auth-context.ts';
4
+ import { AthsraClient } from '../lib/client.ts';
5
+ import { type Config, saveConfig } from '../lib/config.ts';
6
+ import { probeKeyring, setMasterPw, setToken } from '../lib/keyring.ts';
7
+ import { promptPassword, promptText } from '../lib/prompt.ts';
8
+
9
+ const USAGE = [
10
+ 'usage: athsra handoff — 새 머신 등록 token 발급 (기존 머신에서 실행)',
11
+ ' or: athsra handoff --accept — 발급된 token + master pw 로 새 머신 셋업',
12
+ ].join('\n');
13
+
14
+ async function issueToken(): Promise<number> {
15
+ console.log('athsra handoff (issue, 기존 머신에서)\n');
16
+ const ctx = loadAuthContext();
17
+ if (!ctx) return 1;
18
+ const { masterPw, client } = ctx;
19
+
20
+ const newLabel = await promptText('New machine label (예: home-desktop)');
21
+ if (!newLabel) {
22
+ console.error('label required');
23
+ return 1;
24
+ }
25
+
26
+ const info = await client.info();
27
+ const proof = toBase64(deriveKey(masterPw, fromBase64(info.global_salt)));
28
+ const result = await client.handoff(proof, newLabel);
29
+
30
+ const ttlMin = result.ttlSeconds ? Math.floor(result.ttlSeconds / 60) : null;
31
+ console.log('\n✓ Handoff token issued.');
32
+ if (ttlMin) {
33
+ console.log(` ⏰ TTL: ${ttlMin} min (single-use 등록 grace period)`);
34
+ if (result.expiresAt) console.log(` expires: ${result.expiresAt}`);
35
+ }
36
+ console.log('\n새 머신에서 다음 명령 실행:');
37
+ console.log(`\n ATHSRA_HANDOFF_TOKEN='${result.token}' \\`);
38
+ console.log(` ATHSRA_HANDOFF_MACHINE='${result.machineId}' \\`);
39
+ console.log(` ATHSRA_WORKER_URL='${client.workerUrl}' \\`);
40
+ console.log(' bun packages/cli/src/index.ts handoff --accept');
41
+ console.log('\n (master password 도 새 머신에서 한 번 입력해야 keyring 저장 가능)');
42
+ console.log(' (TTL 안에 사용 안 하면 token 자동 만료 — handoff 다시 발급 필요)');
43
+ return 0;
44
+ }
45
+
46
+ async function acceptToken(): Promise<number> {
47
+ console.log('athsra handoff --accept (새 머신 셋업)\n');
48
+
49
+ const probe = probeKeyring();
50
+ if (!probe.ok) {
51
+ console.error(`✗ keyring backend unavailable: ${probe.error ?? 'unknown'}`);
52
+ console.error(' Run `athsra doctor` for setup instructions.');
53
+ return 1;
54
+ }
55
+
56
+ const token = process.env.ATHSRA_HANDOFF_TOKEN ?? (await promptText('Handoff token (atk_*)'));
57
+ if (!token.startsWith('atk_')) {
58
+ console.error('Invalid token format (must start with atk_)');
59
+ return 1;
60
+ }
61
+ const machineId =
62
+ process.env.ATHSRA_HANDOFF_MACHINE ??
63
+ (await promptText('Machine label', `${hostname()}-${Date.now().toString(36)}`));
64
+ const workerUrl =
65
+ process.env.ATHSRA_WORKER_URL ??
66
+ (await promptText('Worker URL', 'https://athsra-worker.winterermod.workers.dev'));
67
+
68
+ // master pw 입력 (새 머신에서도 keyring 저장 필요 — set/get 시 sync 그 아래 envelope decrypt 위해)
69
+ const masterPw = process.env.ATHSRA_MASTER_PW ?? (await promptPassword('Master password'));
70
+ if (masterPw.length < 8) {
71
+ console.error('Master password too short');
72
+ return 1;
73
+ }
74
+
75
+ // worker reachability
76
+ const client = new AthsraClient(workerUrl, token);
77
+ const ok = await client.health();
78
+ if (!ok) {
79
+ console.error(`✗ worker unreachable: ${workerUrl}`);
80
+ return 1;
81
+ }
82
+
83
+ // whoami 로 token + master pw 동시 검증 (envelope decrypt round-trip)
84
+ let me: Awaited<ReturnType<typeof client.whoami>>;
85
+ try {
86
+ me = await client.whoami();
87
+ } catch (err) {
88
+ console.error(`✗ token invalid: ${(err as Error).message}`);
89
+ return 1;
90
+ }
91
+
92
+ // master pw 검증: 첫 envelope (있으면) decrypt 시도
93
+ const projects = await client.listProjects();
94
+ const first = projects[0];
95
+ if (first) {
96
+ const env = await client.getEnvelope(first);
97
+ if (env) {
98
+ try {
99
+ const key = deriveKey(masterPw, fromBase64(env.salt));
100
+ const { decrypt } = await import('@athsra/crypto');
101
+ await decrypt(key, {
102
+ ciphertext: fromBase64(env.ciphertext),
103
+ nonce: fromBase64(env.nonce),
104
+ });
105
+ } catch {
106
+ console.error('✗ master password mismatch — 옛 master pw 와 일치 X');
107
+ return 1;
108
+ }
109
+ }
110
+ }
111
+
112
+ // config + keyring 저장
113
+ const config: Config = {
114
+ workerUrl,
115
+ machineId,
116
+ createdAt: me.createdAt,
117
+ };
118
+ saveConfig(config);
119
+ setMasterPw(machineId, masterPw);
120
+ setToken(machineId, token);
121
+
122
+ console.log(`\n✓ Handoff accepted (machine: ${machineId}).`);
123
+ console.log(` worker: ${workerUrl}`);
124
+ console.log(` whoami: lastSeenAt=${me.lastSeenAt}`);
125
+ console.log(` projects: ${projects.length}`);
126
+ return 0;
127
+ }
128
+
129
+ export async function handoffCmd(args: string[]): Promise<number> {
130
+ if (args[0] === '--help' || args[0] === '-h') {
131
+ console.log(USAGE);
132
+ return 0;
133
+ }
134
+ if (args[0] === '--accept') {
135
+ return acceptToken();
136
+ }
137
+ return issueToken();
138
+ }
@@ -0,0 +1,11 @@
1
+ export async function initCmd(args: string[]): Promise<number> {
2
+ const project = args[0];
3
+ if (!project) {
4
+ console.error('usage: athsra init <project>');
5
+ return 2;
6
+ }
7
+ console.log(`Phase 0: project '${project}' is created on first \`athsra set\`.`);
8
+ console.log('No metadata registration needed in Phase 0.');
9
+ console.log(`\nNext: athsra set ${project} KEY=value`);
10
+ return 0;
11
+ }