@athsra/cli 0.1.0 → 1.0.1

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.
@@ -22,7 +22,7 @@ export async function purgeCmd(args: string[]): Promise<number> {
22
22
  }
23
23
  const yes = args.includes('--yes') || args.includes('-y');
24
24
 
25
- const ctx = loadAuthContext();
25
+ const ctx = await loadAuthContext();
26
26
  if (!ctx) return 1;
27
27
  const { client } = ctx;
28
28
 
@@ -15,7 +15,7 @@ export async function restoreCmd(args: string[]): Promise<number> {
15
15
  return 2;
16
16
  }
17
17
 
18
- const ctx = loadAuthContext();
18
+ const ctx = await loadAuthContext();
19
19
  if (!ctx) return 1;
20
20
  const { client } = ctx;
21
21
 
@@ -10,9 +10,10 @@ import { promptConfirm } from '../lib/prompt.ts';
10
10
  */
11
11
  export async function revokeCmd(args: string[]): Promise<number> {
12
12
  const target = args[0];
13
- const ctx = loadAuthContext();
13
+ const ctx = await loadAuthContext();
14
14
  if (!ctx) return 1;
15
- const { client, config } = ctx;
15
+ const client = ctx.client;
16
+ const userConfig = ctx.kind === 'user' ? ctx.config : null;
16
17
 
17
18
  if (!target) {
18
19
  if (process.env.ATHSRA_REVOKE_CONFIRMED !== '1') {
@@ -22,14 +23,16 @@ export async function revokeCmd(args: string[]): Promise<number> {
22
23
  if (!ok) return 0;
23
24
  }
24
25
  const result = await client.revoke();
25
- clearMasterPw(config.machineId);
26
- clearToken(config.machineId);
27
- console.log(`✓ self-revoke: ${result.revoked} (keyring cleared)`);
26
+ if (userConfig) {
27
+ clearMasterPw(userConfig.machineId);
28
+ clearToken(userConfig.machineId);
29
+ }
30
+ console.log(`✓ self-revoke: ${result.revoked}${userConfig ? ' (keyring cleared)' : ''}`);
28
31
  return 0;
29
32
  }
30
33
 
31
- if (!target.startsWith('atk_')) {
32
- console.error('Token must start with atk_*');
34
+ if (!target.startsWith('atk_') && !target.startsWith('ats_')) {
35
+ console.error('Token must start with atk_ or ats_');
33
36
  return 2;
34
37
  }
35
38
  const result = await client.revoke(target);
@@ -18,7 +18,7 @@ export async function rollbackCmd(args: string[]): Promise<number> {
18
18
  }
19
19
  const yes = args.includes('--yes') || args.includes('-y');
20
20
 
21
- const ctx = loadAuthContext();
21
+ const ctx = await loadAuthContext();
22
22
  if (!ctx) return 1;
23
23
  const { client } = ctx;
24
24
 
@@ -1,30 +1,27 @@
1
- import {
2
- DEFAULT_KDF,
3
- decrypt,
4
- deriveKey,
5
- encrypt,
6
- fromBase64,
7
- randomSalt,
8
- type SecretEnvelope,
9
- toBase64,
10
- } from '@athsra/crypto';
11
- import { loadAuthContext } from '../lib/auth-context.ts';
1
+ import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
2
+ import { loadAuthContext, type UserAuthContext } from '../lib/auth-context.ts';
12
3
  import { isValidPhrase, normalizePhrase, wordCount } from '../lib/bip39.ts';
13
4
  import { AthsraClient } from '../lib/client.ts';
5
+ import { readPlain, writePlain } from '../lib/envelope.ts';
14
6
  import { setMasterPw, setToken } from '../lib/keyring.ts';
15
7
  import { promptConfirm, promptPassword } from '../lib/prompt.ts';
16
8
 
17
9
  /**
18
- * athsra rotate-master — master password 변경 (모든 projects re-encrypt).
10
+ * athsra rotate-master — master password 변경 (모든 projects re-encrypt as v2).
19
11
  *
20
12
  * 흐름:
21
- * 1. 옛 master pw (keyring) 로 모든 envelope decrypt
22
- * 2. POST /auth/rotate-master — 옛 proof 검증 + 새 proof 저장 + 모든 token 삭제 + 새 token 발급
23
- * 3. 새 master pw + 새 token 으로 모든 envelope re-encrypt + PUT
13
+ * 1. 옛 master pw 로 모든 envelope (v1 또는 v2) decrypt → plain (메모리)
14
+ * 2. POST /auth/rotate-master — 옛 proof 검증 + 새 proof 저장 + 모든 token 삭제 + 새 token
15
+ * 3. 새 master pw + 새 token 으로 모든 envelope re-encrypt + PUT (자동 v2)
24
16
  * 4. keyring 갱신
25
17
  *
26
- * 비-원자적 (step 3 도중 실패 시 일부 envelope 만 새 master pw 로). 다만 step 2
27
- * 이후에는 옛 master pw 와 옛 token 모두 invalid — 사용자 안내로 rotate-master 재시도 권장.
18
+ * 비-원자적: step 3 도중 실패 시 일부 envelope 만 새 master pw 로. step 2 이후
19
+ * 옛 master pw 와 옛 token 모두 invalid — rotate-master 재시도 필요.
20
+ *
21
+ * v2 자동 마이그레이션: rotate-master 는 모든 envelope 를 v2 로 재작성한다 —
22
+ * 기존 v1 envelope 도 한 번에 v2 로 갱신됨. 단 v2 의 service token recipient 들은
23
+ * 손실 (rotate-master 는 master pw 재확립 = 모든 scoped 신뢰 reset, 보안상
24
+ * 의도적). service token 은 rotate-master 후 다시 발급 필요.
28
25
  *
29
26
  * envvar:
30
27
  * ATHSRA_NEW_MASTER_PW — 새 master pw (non-interactive)
@@ -33,8 +30,12 @@ import { promptConfirm, promptPassword } from '../lib/prompt.ts';
33
30
  export async function rotateMasterCmd(_args: string[]): Promise<number> {
34
31
  console.log('athsra rotate-master\n');
35
32
 
36
- const ctx = loadAuthContext();
33
+ const ctx = await loadAuthContext();
37
34
  if (!ctx) return 1;
35
+ if (ctx.kind !== 'user') {
36
+ console.error('athsra rotate-master 는 user token (master pw) 가 필요합니다.');
37
+ return 1;
38
+ }
38
39
  const { masterPw: oldPw, client, config } = ctx;
39
40
 
40
41
  // 새 master pw 입력
@@ -85,20 +86,15 @@ export async function rotateMasterCmd(_args: string[]): Promise<number> {
85
86
  }
86
87
  }
87
88
 
88
- // 1. 모든 projects fetch + decrypt (옛 master pw)
89
+ // 1. 모든 projects decrypt (옛 master pw, v1+v2 dispatcher)
89
90
  const projects = await client.listProjects();
90
91
  console.log(`\n• Decrypting ${projects.length} projects with old master pw...`);
91
- const plaintexts: Record<string, string> = {};
92
+ const plaintexts: Record<string, Record<string, string>> = {};
92
93
  for (const p of projects) {
93
- const env = await client.getEnvelope(p);
94
- if (!env) continue;
95
- const key = deriveKey(oldPw, fromBase64(env.salt));
96
- const text = await decrypt(key, {
97
- ciphertext: fromBase64(env.ciphertext),
98
- nonce: fromBase64(env.nonce),
99
- });
100
- plaintexts[p] = text;
101
- console.log(` ✓ ${p} (${text.split('\n').filter((l) => l.includes('=')).length} keys)`);
94
+ const plain = await readPlain(ctx, p);
95
+ if (!plain) continue;
96
+ plaintexts[p] = plain;
97
+ console.log(` ✓ ${p} (${Object.keys(plain).length} keys)`);
102
98
  }
103
99
 
104
100
  // 2. POST /auth/rotate-master
@@ -114,30 +110,28 @@ export async function rotateMasterCmd(_args: string[]): Promise<number> {
114
110
  setMasterPw(config.machineId, newPw);
115
111
  setToken(config.machineId, rot.token);
116
112
  const newClient = new AthsraClient(config.workerUrl, rot.token);
113
+ const newCtx: UserAuthContext = {
114
+ kind: 'user',
115
+ config,
116
+ masterPw: newPw,
117
+ token: rot.token,
118
+ client: newClient,
119
+ };
117
120
 
118
- // 4. re-encrypt + PUT
119
- console.log(`\n• Re-encrypting ${Object.keys(plaintexts).length} projects with new master pw...`);
120
- for (const [p, text] of Object.entries(plaintexts)) {
121
- const newSalt = randomSalt();
122
- const newKey = deriveKey(newPw, newSalt);
123
- const blob = await encrypt(newKey, text);
124
- const envelope: SecretEnvelope = {
125
- version: 1,
126
- alg: 'aes-256-gcm',
127
- kdf: 'argon2id',
128
- kdf_params: DEFAULT_KDF,
129
- salt: toBase64(newSalt),
130
- nonce: toBase64(blob.nonce),
131
- ciphertext: toBase64(blob.ciphertext),
132
- version_id: `v${Date.now()}`,
133
- updated_at: new Date().toISOString(),
134
- };
135
- await newClient.putEnvelope(p, envelope);
121
+ // 4. re-encrypt + PUT (v2 envelope, 자동 마이그레이션)
122
+ console.log(
123
+ `\n• Re-encrypting ${Object.keys(plaintexts).length} projects with new master pw (as v2)...`,
124
+ );
125
+ for (const [p, plain] of Object.entries(plaintexts)) {
126
+ await writePlain(newCtx, p, plain);
136
127
  console.log(` ✓ ${p}`);
137
128
  }
138
129
 
139
130
  console.log(
140
- `\n✓ master password rotated. ${Object.keys(plaintexts).length} projects re-encrypted.`,
131
+ `\n✓ master password rotated. ${Object.keys(plaintexts).length} projects re-encrypted as v2.`,
132
+ );
133
+ console.log(
134
+ ' service token recipients (있다면) 손실 — `athsra service-token create` 로 재발급 필요.',
141
135
  );
142
136
  console.log(
143
137
  ' 다른 머신은 모두 token invalidated — 각 머신에서 athsra login (handoff) 재실행 필요.',
@@ -1,13 +1,27 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { decrypt, deriveKey, fromBase64 } from '@athsra/crypto';
3
2
  import { loadAuthContext } from '../lib/auth-context.ts';
4
- import { parseEnv } from '../lib/env-format.ts';
3
+ import { resolveProject } from '../lib/auto-project.ts';
4
+ import { partitionEnv } from '../lib/env-format.ts';
5
+ import { readPlain } from '../lib/envelope.ts';
6
+
7
+ const USAGE = [
8
+ 'usage: athsra run [<project>] -- <command> [args...]',
9
+ '',
10
+ '<project> 자동 감지 (cwd 기반): basename(cwd) > .athsra > package.json athsra.project',
11
+ '23 sibling repo 안에서: cd ~/code/<repo> && athsra run -- bun run dev',
12
+ ].join('\n');
5
13
 
6
14
  export async function runCmd(args: string[]): Promise<number> {
7
- const project = args[0];
8
15
  const sepIdx = args.indexOf('--');
9
- if (!project || sepIdx < 1) {
10
- console.error('usage: athsra run <project> -- <command> [args...]');
16
+ if (sepIdx < 0) {
17
+ console.error(USAGE);
18
+ return 2;
19
+ }
20
+ // -- 앞 영역에서 project 추출 (auto-detect 가능). -- 뒤는 cmd.
21
+ const beforeSep = args.slice(0, sepIdx);
22
+ const { project } = resolveProject(beforeSep);
23
+ if (!project) {
24
+ console.error(USAGE);
11
25
  return 2;
12
26
  }
13
27
  const cmd = args.slice(sepIdx + 1);
@@ -16,21 +30,21 @@ export async function runCmd(args: string[]): Promise<number> {
16
30
  return 2;
17
31
  }
18
32
 
19
- const ctx = loadAuthContext();
33
+ const ctx = await loadAuthContext();
20
34
  if (!ctx) return 1;
21
- const { masterPw, client } = ctx;
22
- const envelope = await client.getEnvelope(project);
23
- if (!envelope) {
35
+
36
+ const plain = await readPlain(ctx, project);
37
+ if (!plain) {
24
38
  console.error(`project not found: ${project}`);
25
39
  return 1;
26
40
  }
27
41
 
28
- const derived = deriveKey(masterPw, fromBase64(envelope.salt));
29
- const text = await decrypt(derived, {
30
- ciphertext: fromBase64(envelope.ciphertext),
31
- nonce: fromBase64(envelope.nonce),
32
- });
33
- const plain = parseEnv(text);
42
+ const { env, skipped } = buildChildEnv(process.env, plain);
43
+ if (skipped.length > 0) {
44
+ console.error(
45
+ `athsra: ${skipped.length} empty-value key${skipped.length > 1 ? 's' : ''} skipped — parent environment preserved: ${skipped.join(', ')}`,
46
+ );
47
+ }
34
48
 
35
49
  return await new Promise<number>((resolve) => {
36
50
  const cmdName = cmd[0];
@@ -39,7 +53,7 @@ export async function runCmd(args: string[]): Promise<number> {
39
53
  return;
40
54
  }
41
55
  const child = spawn(cmdName, cmd.slice(1), {
42
- env: { ...process.env, ...plain },
56
+ env,
43
57
  stdio: 'inherit',
44
58
  });
45
59
  child.on('close', (code) => resolve(code ?? 1));
@@ -49,3 +63,22 @@ export async function runCmd(args: string[]): Promise<number> {
49
63
  });
50
64
  });
51
65
  }
66
+
67
+ /**
68
+ * 자식 프로세스 env 를 만든다. 빈 값 secret 키는 inject 대상에서 제외해
69
+ * 부모 환경 변수를 보존한다.
70
+ *
71
+ * 키 이름만 등록되고 값이 빈 secret (migration scaffolding 산출물) 을
72
+ * `{ ...process.env, ...plain }` 로 합치면 부모 env 의 유효한 값 (예:
73
+ * ~/.zshrc 의 CLOUDFLARE_API_TOKEN) 이 빈 문자열로 덮어써져 배포 인증이
74
+ * 깨진다. 빈 값 키를 제외하면 부모 env 값이 그대로 살아남는다.
75
+ *
76
+ * @returns env — 자식 프로세스 env, skipped — 제외된 빈 값 키 (정렬됨)
77
+ */
78
+ export function buildChildEnv(
79
+ parentEnv: Record<string, string | undefined>,
80
+ plain: Record<string, string>,
81
+ ): { env: Record<string, string | undefined>; skipped: string[] } {
82
+ const { filled, emptyKeys } = partitionEnv(plain);
83
+ return { env: { ...parentEnv, ...filled }, skipped: emptyKeys };
84
+ }
@@ -0,0 +1,200 @@
1
+ import { addServiceRecipient, migrateV1ToV2, type SecretEnvelopeV2 } from '@athsra/crypto';
2
+ import { loadAuthContext } from '../lib/auth-context.ts';
3
+ import { resolveProject } from '../lib/auto-project.ts';
4
+
5
+ const USAGE = [
6
+ 'usage:',
7
+ ' athsra service-token create [<project>] --label=<name> [--read-only|--write] [--expires-days=N]',
8
+ ' athsra service-token list [<project>]',
9
+ ' athsra service-token revoke <token>',
10
+ '',
11
+ 'service token = scoped + revocable + master-pw 없이 envelope 복호 가능한 token.',
12
+ ' → headless 호스트 (NAS Docker, CI, 자동화) 용 — E2EE 유지, master pw 유출 X.',
13
+ ].join('\n');
14
+
15
+ async function createCmd(args: string[]): Promise<number> {
16
+ const { project, rest } = resolveProject(args);
17
+ if (!project) {
18
+ console.error(USAGE);
19
+ return 2;
20
+ }
21
+
22
+ let label: string | null = null;
23
+ let perms: 'read' | 'write' = 'read';
24
+ let expiresDays: number | undefined;
25
+ for (const a of rest) {
26
+ if (a.startsWith('--label=')) {
27
+ label = a.slice('--label='.length);
28
+ } else if (a === '--read-only') {
29
+ perms = 'read';
30
+ } else if (a === '--write') {
31
+ perms = 'write';
32
+ } else if (a.startsWith('--expires-days=')) {
33
+ const n = Number(a.slice('--expires-days='.length));
34
+ if (!Number.isFinite(n) || n <= 0) {
35
+ console.error('--expires-days must be a positive number');
36
+ return 2;
37
+ }
38
+ expiresDays = n;
39
+ } else if (a.startsWith('-')) {
40
+ console.error(`Unknown flag: ${a}`);
41
+ console.error(USAGE);
42
+ return 2;
43
+ }
44
+ }
45
+
46
+ if (!label) {
47
+ console.error('--label=<name> required (관리·감사 식별용)');
48
+ return 2;
49
+ }
50
+
51
+ const ctx = await loadAuthContext();
52
+ if (!ctx) return 1;
53
+ if (ctx.kind !== 'user') {
54
+ console.error('athsra service-token create 은 user token (master pw) 가 필요합니다.');
55
+ return 1;
56
+ }
57
+ const { masterPw, client } = ctx;
58
+
59
+ // envelope 확인 — 없으면 service token 발급 무의미
60
+ const envelope = await client.getEnvelope(project);
61
+ if (!envelope) {
62
+ console.error(
63
+ `project ${project} envelope 없음 — 먼저 \`athsra set ${project} KEY=value\` 실행.`,
64
+ );
65
+ return 1;
66
+ }
67
+
68
+ // worker 에 service token 발급
69
+ const created = await client.createServiceToken({
70
+ project,
71
+ label,
72
+ perms,
73
+ expiresInDays: expiresDays,
74
+ });
75
+
76
+ // envelope 에 recipient 추가 — v1 이면 v2 로 자동 migrate
77
+ let v2Envelope: SecretEnvelopeV2;
78
+ if (envelope.version === 1) {
79
+ v2Envelope = await migrateV1ToV2(envelope, masterPw);
80
+ } else {
81
+ v2Envelope = envelope;
82
+ }
83
+ const updated = await addServiceRecipient(
84
+ v2Envelope,
85
+ masterPw,
86
+ created.token,
87
+ created.recipient_id,
88
+ );
89
+ await client.putEnvelope(project, updated);
90
+
91
+ console.log('\n✓ service token 발급 완료\n');
92
+ console.log(` token: ${created.token}`);
93
+ console.log(` project: ${created.project}`);
94
+ console.log(` perms: ${created.perms}`);
95
+ console.log(` recipient_id: ${created.recipient_id}`);
96
+ if (created.expires_at) console.log(` expires_at: ${created.expires_at}`);
97
+ console.log('');
98
+ console.log('보관 (password manager / 0600 파일 / secret manager):');
99
+ console.log(` export ATHSRA_TOKEN='${created.token}'`);
100
+ console.log(` export ATHSRA_PROJECT='${project}'`);
101
+ console.log('');
102
+ console.log('headless 호스트 사용 (master pw 없이):');
103
+ console.log(` ATHSRA_TOKEN=... ATHSRA_PROJECT=${project} athsra run -- <cmd>`);
104
+ console.log('');
105
+ console.log(
106
+ 'revoke (분실·로테이션 시): `athsra service-token revoke ats_...` — worker auth 즉시 거부 (D1 strong consistency).',
107
+ );
108
+
109
+ return 0;
110
+ }
111
+
112
+ async function revokeCmd(args: string[]): Promise<number> {
113
+ const token = args[0];
114
+ if (!token) {
115
+ console.error('usage: athsra service-token revoke <token>');
116
+ return 2;
117
+ }
118
+ if (!token.startsWith('ats_')) {
119
+ console.error('service token must start with ats_');
120
+ return 2;
121
+ }
122
+ const ctx = await loadAuthContext();
123
+ if (!ctx) return 1;
124
+ const { client } = ctx;
125
+
126
+ const res = await client.revoke(token);
127
+ console.log(`✓ revoked: ${res.revoked}`);
128
+ console.log(
129
+ ' envelope 의 recipient entry 는 그대로 남지만 보안상 무해 (worker auth 가 hash 로 거부).',
130
+ );
131
+ return 0;
132
+ }
133
+
134
+ async function listCmd(args: string[]): Promise<number> {
135
+ const project = args[0] && !args[0].startsWith('-') ? args[0] : undefined;
136
+
137
+ const ctx = await loadAuthContext();
138
+ if (!ctx) return 1;
139
+ if (ctx.kind !== 'user') {
140
+ console.error('athsra service-token list 는 user token (master pw) 가 필요합니다.');
141
+ return 1;
142
+ }
143
+ const { client } = ctx;
144
+
145
+ const { tokens, count } = await client.listServiceTokens(project ? { project } : undefined);
146
+
147
+ if (count === 0) {
148
+ console.log(
149
+ project ? `(no service tokens scoped to project: ${project})` : '(no service tokens issued)',
150
+ );
151
+ return 0;
152
+ }
153
+
154
+ const header = project
155
+ ? `service tokens scoped to ${project} (${count}):`
156
+ : `service tokens (${count}):`;
157
+ console.log(header);
158
+ console.log('');
159
+ const now = Date.now();
160
+ for (const t of tokens) {
161
+ const expired = t.expires_at && new Date(t.expires_at).getTime() < now;
162
+ const expiresLabel = t.expires_at
163
+ ? expired
164
+ ? `expired ${t.expires_at}`
165
+ : `expires ${t.expires_at}`
166
+ : 'no expiry';
167
+ console.log(` ${t.label}`);
168
+ console.log(` project: ${t.project}`);
169
+ console.log(` perms: ${t.perms}`);
170
+ console.log(` recipient_id: ${t.recipient_id}`);
171
+ console.log(` hash: ${t.hash.slice(0, 16)}…`);
172
+ console.log(` issued by: ${t.machine_id}`);
173
+ console.log(` created: ${t.created_at}`);
174
+ console.log(` last seen: ${t.last_seen_at}`);
175
+ console.log(` ${expiresLabel}`);
176
+ console.log('');
177
+ }
178
+ console.log(
179
+ 'revoke: `athsra service-token revoke ats_...` (token 본문은 발급 시점에만 노출 — 분실 시 hash 로 식별 후 재발급).',
180
+ );
181
+ return 0;
182
+ }
183
+
184
+ export async function serviceTokenCmd(args: string[]): Promise<number> {
185
+ const action = args[0];
186
+ if (action === 'create') return createCmd(args.slice(1));
187
+ if (action === 'list') return listCmd(args.slice(1));
188
+ if (action === 'revoke') return revokeCmd(args.slice(1));
189
+ if (action === '--help' || action === '-h') {
190
+ console.log(USAGE);
191
+ return 0;
192
+ }
193
+ if (!action) {
194
+ console.error(USAGE);
195
+ return 2;
196
+ }
197
+ console.error(`Unknown action: ${action}`);
198
+ console.error(USAGE);
199
+ return 2;
200
+ }
@@ -1,21 +1,17 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import {
3
- DEFAULT_KDF,
4
- decrypt,
5
- deriveKey,
6
- encrypt,
7
- fromBase64,
8
- randomSalt,
9
- type SecretEnvelope,
10
- toBase64,
11
- } from '@athsra/crypto';
12
2
  import { loadAuthContext } from '../lib/auth-context.ts';
13
- import { parseEnv, serializeEnv } from '../lib/env-format.ts';
3
+ import { resolveProject } from '../lib/auto-project.ts';
4
+ import { parseEnv, partitionEnv } from '../lib/env-format.ts';
5
+ import { readPlain, writePlain } from '../lib/envelope.ts';
14
6
 
15
7
  const USAGE = [
16
- 'usage: athsra set <project> KEY=value [KEY2=value2 ...]',
17
- ' or: athsra set <project> --from-file <path> (.env 형식)',
18
- ' or: athsra set <project> --stdin (.env 형식 stdin)',
8
+ 'usage: athsra set [<project>] KEY=value [KEY2=value2 ...]',
9
+ ' or: athsra set [<project>] --from-file <path> (.env 형식)',
10
+ ' or: athsra set [<project>] --stdin (.env 형식 stdin)',
11
+ '',
12
+ '<project> 자동 감지 (cwd 기반):',
13
+ ' --project=<x> > positional > .athsra file > package.json athsra.project > basename(cwd)',
14
+ '23 sibling repo 안에서는 cd 한 후 project 인자 생략 가능 (basename 자동 사용).',
19
15
  ].join('\n');
20
16
 
21
17
  async function readStdin(): Promise<string> {
@@ -40,12 +36,15 @@ function parsePairsArg(pairs: string[]): Record<string, string> | null {
40
36
  }
41
37
 
42
38
  export async function setCmd(args: string[]): Promise<number> {
43
- const project = args[0];
39
+ const { project, rest, source } = resolveProject(args);
44
40
  if (!project) {
45
41
  console.error(USAGE);
46
42
  return 2;
47
43
  }
48
- const rest = args.slice(1);
44
+ // mutating 명령 — auto-detect 시점 source 안내 (실수 방지). positional/flag 면 silent.
45
+ if (source !== 'positional' && source !== 'flag') {
46
+ console.log(`(project=${project} auto-detected from ${source})`);
47
+ }
49
48
 
50
49
  let updates: Record<string, string>;
51
50
  if (rest[0] === '--from-file') {
@@ -75,44 +74,31 @@ export async function setCmd(args: string[]): Promise<number> {
75
74
  return 2;
76
75
  }
77
76
 
78
- const ctx = loadAuthContext();
79
- if (!ctx) return 1;
80
- const { masterPw, client } = ctx;
77
+ // 키는 migration scaffolding 의 흔적 — silently 저장하지 않고 경고한다.
78
+ const { emptyKeys } = partitionEnv(updates);
79
+ if (emptyKeys.length > 0) {
80
+ console.error(
81
+ `⚠ ${emptyKeys.length} key${emptyKeys.length > 1 ? 's' : ''} set to an empty value: ${emptyKeys.join(', ')}`,
82
+ );
83
+ console.error(
84
+ ' empty values are treated as unset — `athsra run` skips them; use `athsra unset` to remove a key.',
85
+ );
86
+ }
81
87
 
82
- // 기존 envelope fetch + decrypt (read-modify-write)
83
- let plain: Record<string, string> = {};
84
- const existing = await client.getEnvelope(project);
85
- if (existing) {
86
- const key = deriveKey(masterPw, fromBase64(existing.salt));
87
- const text = await decrypt(key, {
88
- ciphertext: fromBase64(existing.ciphertext),
89
- nonce: fromBase64(existing.nonce),
90
- });
91
- plain = parseEnv(text);
88
+ const ctx = await loadAuthContext();
89
+ if (!ctx) return 1;
90
+ if (ctx.kind !== 'user') {
91
+ console.error('athsra set 은 user token (master pw) 가 필요합니다.');
92
+ return 1;
92
93
  }
93
94
 
94
- // merge updates
95
+ // read-modify-write — v1/v2 dispatcher 가 read 처리, write 는 항상 v2.
96
+ const plain: Record<string, string> = (await readPlain(ctx, project)) ?? {};
95
97
  for (const [k, v] of Object.entries(updates)) {
96
98
  plain[k] = v;
97
99
  }
100
+ await writePlain(ctx, project, plain);
98
101
 
99
- // 새 envelope (새 salt + nonce 매번)
100
- const newSalt = randomSalt();
101
- const newKey = deriveKey(masterPw, newSalt);
102
- const blob = await encrypt(newKey, serializeEnv(plain));
103
- const envelope: SecretEnvelope = {
104
- version: 1,
105
- alg: 'aes-256-gcm',
106
- kdf: 'argon2id',
107
- kdf_params: DEFAULT_KDF,
108
- salt: toBase64(newSalt),
109
- nonce: toBase64(blob.nonce),
110
- ciphertext: toBase64(blob.ciphertext),
111
- version_id: `v${Date.now()}`,
112
- updated_at: new Date().toISOString(),
113
- };
114
-
115
- await client.putEnvelope(project, envelope);
116
102
  console.log(
117
103
  `✓ ${project}: ${updateCount} key${updateCount > 1 ? 's' : ''} set (${Object.keys(plain).length} total)`,
118
104
  );
@@ -1,15 +1,5 @@
1
- import {
2
- DEFAULT_KDF,
3
- decrypt,
4
- deriveKey,
5
- encrypt,
6
- fromBase64,
7
- randomSalt,
8
- type SecretEnvelope,
9
- toBase64,
10
- } from '@athsra/crypto';
11
1
  import { loadAuthContext } from '../lib/auth-context.ts';
12
- import { parseEnv, serializeEnv } from '../lib/env-format.ts';
2
+ import { readPlain, writePlain } from '../lib/envelope.ts';
13
3
 
14
4
  const USAGE =
15
5
  'usage: athsra unset <project> KEY1 [KEY2 ...] # 해당 keys 만 제거 (envelope 그대로, 다른 keys 유지)';
@@ -26,21 +16,18 @@ export async function unsetCmd(args: string[]): Promise<number> {
26
16
  return 2;
27
17
  }
28
18
 
29
- const ctx = loadAuthContext();
19
+ const ctx = await loadAuthContext();
30
20
  if (!ctx) return 1;
31
- const { masterPw, client } = ctx;
21
+ if (ctx.kind !== 'user') {
22
+ console.error('athsra unset 은 user token (master pw) 가 필요합니다.');
23
+ return 1;
24
+ }
32
25
 
33
- const existing = await client.getEnvelope(project);
34
- if (!existing) {
26
+ const plain = await readPlain(ctx, project);
27
+ if (!plain) {
35
28
  console.error(`project not found: ${project}`);
36
29
  return 1;
37
30
  }
38
- const oldKey = deriveKey(masterPw, fromBase64(existing.salt));
39
- const text = await decrypt(oldKey, {
40
- ciphertext: fromBase64(existing.ciphertext),
41
- nonce: fromBase64(existing.nonce),
42
- });
43
- const plain = parseEnv(text);
44
31
 
45
32
  const removed: string[] = [];
46
33
  const notFound: string[] = [];
@@ -58,23 +45,7 @@ export async function unsetCmd(args: string[]): Promise<number> {
58
45
  return 1;
59
46
  }
60
47
 
61
- // re-encrypt + PUT ( salt + nonce)
62
- const newSalt = randomSalt();
63
- const newKey = deriveKey(masterPw, newSalt);
64
- const blob = await encrypt(newKey, serializeEnv(plain));
65
- const envelope: SecretEnvelope = {
66
- version: 1,
67
- alg: 'aes-256-gcm',
68
- kdf: 'argon2id',
69
- kdf_params: DEFAULT_KDF,
70
- salt: toBase64(newSalt),
71
- nonce: toBase64(blob.nonce),
72
- ciphertext: toBase64(blob.ciphertext),
73
- version_id: `v${Date.now()}`,
74
- updated_at: new Date().toISOString(),
75
- };
76
-
77
- await client.putEnvelope(project, envelope);
48
+ await writePlain(ctx, project, plain);
78
49
  console.log(
79
50
  `✓ ${project}: ${removed.length} key${removed.length > 1 ? 's' : ''} removed (${Object.keys(plain).length} remaining)`,
80
51
  );