@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
@@ -9,6 +9,8 @@ import { loadAuthContext } from '../auth-context.ts';
9
9
  import { readPlain } from '../envelope.ts';
10
10
  import { applyManifest, loadManifest } from '../secrets-manifest.ts';
11
11
  import { pickWorker, readBoolean, readString } from './args.ts';
12
+ import { requireConfirm } from './confirm.ts';
13
+ import { maskValue } from './mask.ts';
12
14
  import { isAuthError, jsonError, jsonOk, notLoggedIn, type ToolTextResult } from './result.ts';
13
15
 
14
16
  /**
@@ -69,11 +71,12 @@ export async function handleGetProjectKeys(
69
71
  ): Promise<ToolTextResult> {
70
72
  const project = readString(args, 'project');
71
73
  if (!project) return jsonError('missing required arg: project');
74
+ const config = readString(args, 'config') ?? 'default';
72
75
  const ctx = await loadAuthContext();
73
76
  if (!ctx) {
74
77
  return notLoggedIn();
75
78
  }
76
- const env = await readPlain(ctx, project);
79
+ const env = await readPlain(ctx, project, config);
77
80
  if (!env) {
78
81
  return jsonError(`envelope '${project}' not found`);
79
82
  }
@@ -84,6 +87,44 @@ export async function handleGetProjectKeys(
84
87
  return jsonOk({ project, keys, count: keys.length });
85
88
  }
86
89
 
90
+ /**
91
+ * athsra_get_secret_value — value tier (ATHSRA_MCP_READ_VALUES). 기본 마스킹(maskValue:
92
+ * prefix+length+sha256, 역산 불가)으로 존재/형태/무결성만 확인. full=true 시 평문 — auth
93
+ * (네트워크·keyring) 전에 confirm=<project> 검증(조기 차단) + dispatch 의 value 게이트.
94
+ * 평문 노출 시 cross-server 경고 동반 — 호출측 agent 가 외부로 전송하지 않도록.
95
+ */
96
+ export async function handleGetSecretValue(
97
+ args: Record<string, unknown> | undefined,
98
+ ): Promise<ToolTextResult> {
99
+ const project = readString(args, 'project');
100
+ const key = readString(args, 'key');
101
+ if (!project || !key) return jsonError('missing required args: project, key');
102
+ const config = readString(args, 'config') ?? 'default';
103
+ const full = readBoolean(args, 'full') ?? false;
104
+ if (full) {
105
+ const denied = requireConfirm(args, project);
106
+ if (denied) return denied;
107
+ }
108
+ const ctx = await loadAuthContext(project);
109
+ if (!ctx) return notLoggedIn();
110
+ const env = await readPlain(ctx, project, config);
111
+ if (!env) return jsonError(`envelope '${project}' not found`);
112
+ if (!(key in env)) return jsonError(`key '${key}' not found in '${project}'`);
113
+ const value = env[key] ?? '';
114
+ if (!full) {
115
+ return jsonOk(maskValue(key, value));
116
+ }
117
+ return jsonOk({
118
+ project,
119
+ key,
120
+ value,
121
+ _audit: 'reveal recorded server-side (envelope GET)',
122
+ warning:
123
+ 'SECRET VALUE EXPOSED — do not paste into any outward tool (Slack/Gmail/email/web). ' +
124
+ 'Store securely; never commit.',
125
+ });
126
+ }
127
+
87
128
  export function handleShowManifest(args: Record<string, unknown> | undefined): ToolTextResult {
88
129
  const at = readString(args, 'cwd') ?? process.cwd();
89
130
  const worker = readString(args, 'worker');
@@ -154,3 +195,76 @@ export async function handleValidateManifest(
154
195
  },
155
196
  });
156
197
  }
198
+
199
+ /** athsra_versions — project envelope 의 버전 이력 + tombstone (rollback 대상 식별). 메타만(값 X). */
200
+ export async function handleVersions(
201
+ args: Record<string, unknown> | undefined,
202
+ ): Promise<ToolTextResult> {
203
+ const project = readString(args, 'project');
204
+ if (!project) return jsonError('missing required arg: project');
205
+ const config = readString(args, 'config') ?? 'default';
206
+ const ctx = await loadAuthContext(project);
207
+ if (!ctx) return notLoggedIn();
208
+ return jsonOk(await ctx.client.listVersions(project, config));
209
+ }
210
+
211
+ /** athsra_audit — audit 로그(cursor passthrough + 선택 필터). 메타만(actor/action/path/status, 값 X). */
212
+ export async function handleAudit(
213
+ args: Record<string, unknown> | undefined,
214
+ ): Promise<ToolTextResult> {
215
+ const ctx = await loadAuthContext();
216
+ if (!ctx) return notLoggedIn();
217
+ return jsonOk(
218
+ await ctx.client.queryAudit({
219
+ cursor: readString(args, 'cursor'),
220
+ action: readString(args, 'action'),
221
+ actor: readString(args, 'actor'),
222
+ }),
223
+ );
224
+ }
225
+
226
+ /** athsra_list_orgs — 내가 속한 org 목록 + 현재 org. */
227
+ export async function handleListOrgs(): Promise<ToolTextResult> {
228
+ const ctx = await loadAuthContext();
229
+ if (!ctx) return notLoggedIn();
230
+ return jsonOk(await ctx.client.listOrgs());
231
+ }
232
+
233
+ /** athsra_org_info — 현재 org 의 멤버 + org 목록(역할·상태). 시크릿 X. */
234
+ export async function handleOrgInfo(): Promise<ToolTextResult> {
235
+ const ctx = await loadAuthContext();
236
+ if (!ctx) return notLoggedIn();
237
+ const [orgs, members] = await Promise.all([ctx.client.listOrgs(), ctx.client.listOrgMembers()]);
238
+ return jsonOk({
239
+ current_org_id: orgs.current_org_id,
240
+ orgs: orgs.orgs,
241
+ members: members.members,
242
+ });
243
+ }
244
+
245
+ /**
246
+ * athsra_doctor — 인증·세션 진단 (AI 가 막혔을 때 원인 자가 점검). 시크릿 미접근.
247
+ * --audit 전수복호 류 무거운 점검은 비포함 (read tier 경량 유지).
248
+ */
249
+ export async function handleDoctor(): Promise<ToolTextResult> {
250
+ const checks: { name: string; ok: boolean; detail: string }[] = [];
251
+ const ctx = await loadAuthContext();
252
+ checks.push({
253
+ name: 'authenticated',
254
+ ok: ctx !== null,
255
+ detail: ctx ? `${ctx.kind} mode` : 'not logged in — run `athsra login`',
256
+ });
257
+ if (ctx) {
258
+ try {
259
+ const me = await ctx.client.whoami();
260
+ checks.push({ name: 'session', ok: true, detail: `user #${me.userId ?? '?'}` });
261
+ } catch (err) {
262
+ checks.push({
263
+ name: 'session',
264
+ ok: false,
265
+ detail: isAuthError(err) ? 'expired/idle — re-login' : (err as Error).message,
266
+ });
267
+ }
268
+ }
269
+ return jsonOk({ checks, healthy: checks.every((c) => c.ok) });
270
+ }
@@ -25,14 +25,14 @@ export function jsonError(message: string): ToolTextResult {
25
25
  }
26
26
 
27
27
  /**
28
- * 미인증 시 AI 가 곧바로 행동할 수 있는 안내. `athsra login --sso` 브라우저를 열어 modfolio
29
- * Connect 가입/로그인 토큰/master pw OS keyring 저장한다. AI-온보딩의 인증 부트스트랩 신호.
28
+ * 미인증 시 AI 가 곧바로 행동할 수 있는 안내. 1순위 = in-chat 로그인 (athsra_login_start
29
+ * 터미널 불필요, Phase 5 B5). 폴백 = 터미널 `athsra login` (browser 승인). AI-온보딩 부트스트랩 신호.
30
30
  */
31
31
  export function notLoggedIn(): ToolTextResult {
32
32
  return jsonError(
33
- 'not authenticated on this machine. Run `athsra login --sso` in a terminal a browser opens ' +
34
- 'to sign up or log in (modfolio Connect SSO). Then retry this tool. ' +
35
- '(headless/CI: set ATHSRA_TOKEN=ats_… service token instead.)',
33
+ 'not authenticated on this machine. Call athsra_login_start to begin a browser login right ' +
34
+ 'from chat no terminal needed. (Alternative: run `athsra login` in a terminal.) ' +
35
+ 'Then retry this tool. (headless/CI: set ATHSRA_TOKEN=ats_… service token instead.)',
36
36
  );
37
37
  }
38
38
 
@@ -0,0 +1,101 @@
1
+ /**
2
+ * mcp-tools/run.ts — athsra_run (value tier). envelope secret 을 자식 프로세스 env 로
3
+ * 주입해 명령 실행 — 값을 응답에 노출하지 않음(`athsra run` 의 MCP 등가). 출력 캡처 시
4
+ * 알려진 secret 값 scrub.
5
+ *
6
+ * 방어: allowlist(빌드/런타임 도구) 외 명령은 confirm=<project> 강제(deliberate-exec),
7
+ * stdin 차단, stdout/stderr pipe 캡처(MCP protocol stdout 오염 방지), timeout, secret scrub.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { loadAuthContext } from '../auth-context.ts';
11
+ import { buildChildEnv } from '../env-format.ts';
12
+ import { readPlain } from '../envelope.ts';
13
+ import { readBoolean, readInteger, readString, readStringArray } from './args.ts';
14
+ import { requireConfirm } from './confirm.ts';
15
+ import { scrubSecrets } from './mask.ts';
16
+ import { jsonError, jsonOk, notLoggedIn, type ToolTextResult } from './result.ts';
17
+
18
+ /** confirm 없이 실행 허용하는 빌드/런타임 도구 — 그 외는 deliberate-exec confirm=<project>. */
19
+ const RUN_ALLOWLIST = new Set([
20
+ 'bun',
21
+ 'bunx',
22
+ 'node',
23
+ 'npm',
24
+ 'npx',
25
+ 'pnpm',
26
+ 'yarn',
27
+ 'wrangler',
28
+ 'vite',
29
+ 'tsx',
30
+ ]);
31
+
32
+ const DEFAULT_TIMEOUT_MS = 120_000;
33
+ const KILL_GRACE_MS = 2_000;
34
+ const OUTPUT_CAP = 64 * 1024;
35
+
36
+ export async function handleRun(
37
+ args: Record<string, unknown> | undefined,
38
+ ): Promise<ToolTextResult> {
39
+ const project = readString(args, 'project');
40
+ const command = readString(args, 'command');
41
+ if (!project || !command) return jsonError('missing required args: project, command');
42
+ const config = readString(args, 'config') ?? 'default';
43
+ const cmdArgs = readStringArray(args, 'args') ?? [];
44
+ const cwd = readString(args, 'cwd');
45
+ const returnOutput = readBoolean(args, 'return_output') ?? false;
46
+ const timeoutMs = readInteger(args, 'timeout_ms') ?? DEFAULT_TIMEOUT_MS;
47
+
48
+ // allowlist 외 명령은 auth(네트워크·keyring) 전에 confirm=<project> 강제 — 임의 실행 차단.
49
+ if (!RUN_ALLOWLIST.has(command)) {
50
+ const denied = requireConfirm(args, project);
51
+ if (denied) return denied;
52
+ }
53
+
54
+ const ctx = await loadAuthContext(project);
55
+ if (!ctx) return notLoggedIn();
56
+ const plain = await readPlain(ctx, project, config);
57
+ if (!plain) return jsonError(`envelope '${project}' not found`);
58
+
59
+ const { env, skipped } = buildChildEnv(process.env, plain);
60
+
61
+ return await new Promise<ToolTextResult>((resolve) => {
62
+ const child = spawn(command, cmdArgs, {
63
+ env,
64
+ cwd: cwd ?? process.cwd(),
65
+ stdio: ['ignore', 'pipe', 'pipe'], // stdin 차단, stdout/stderr 캡처
66
+ });
67
+ let stdout = '';
68
+ let stderr = '';
69
+ let timedOut = false;
70
+ const timer = setTimeout(() => {
71
+ timedOut = true;
72
+ child.kill('SIGTERM');
73
+ setTimeout(() => child.kill('SIGKILL'), KILL_GRACE_MS);
74
+ }, timeoutMs);
75
+ child.stdout?.on('data', (d: Buffer) => {
76
+ if (stdout.length < OUTPUT_CAP) stdout += d.toString('utf8');
77
+ });
78
+ child.stderr?.on('data', (d: Buffer) => {
79
+ if (stderr.length < OUTPUT_CAP) stderr += d.toString('utf8');
80
+ });
81
+ child.on('error', (err) => {
82
+ clearTimeout(timer);
83
+ resolve(jsonError(`spawn error: ${err.message}`));
84
+ });
85
+ child.on('close', (code) => {
86
+ clearTimeout(timer);
87
+ const base: Record<string, unknown> = {
88
+ project,
89
+ command,
90
+ exit_code: timedOut ? null : code,
91
+ };
92
+ if (timedOut) base.timed_out = timeoutMs;
93
+ if (skipped.length > 0) base.skipped_empty_keys = skipped;
94
+ if (returnOutput) {
95
+ base.stdout = scrubSecrets(stdout, plain);
96
+ base.stderr = scrubSecrets(stderr, plain);
97
+ }
98
+ resolve(jsonOk(base));
99
+ });
100
+ });
101
+ }
@@ -8,7 +8,8 @@ import { inferAdoptContext } from '../adopt-context.ts';
8
8
  import { loadAuthContext } from '../auth-context.ts';
9
9
  import { readPlain, writePlain } from '../envelope.ts';
10
10
  import { createManifest, loadManifest, saveManifest } from '../secrets-manifest.ts';
11
- import { pickWorker, readString, readStringArray } from './args.ts';
11
+ import { pickWorker, readRecordOfStrings, readString, readStringArray } from './args.ts';
12
+ import { requireConfirm } from './confirm.ts';
12
13
  import { jsonError, jsonOk, notLoggedIn, type ToolTextResult } from './result.ts';
13
14
 
14
15
  export async function handleSetSecret(
@@ -20,14 +21,15 @@ export async function handleSetSecret(
20
21
  if (!project || !key || value === undefined) {
21
22
  return jsonError('missing required args: project, key, value');
22
23
  }
24
+ const config = readString(args, 'config') ?? 'default';
23
25
  const ctx = await loadAuthContext();
24
26
  if (!ctx) return notLoggedIn();
25
27
  if (ctx.kind !== 'user') {
26
28
  return jsonError('service token cannot write — user token (master pw) required');
27
29
  }
28
- const existing = (await readPlain(ctx, project)) ?? {};
30
+ const existing = (await readPlain(ctx, project, config)) ?? {};
29
31
  const updated: Record<string, string> = { ...existing, [key]: value };
30
- await writePlain(ctx, project, updated);
32
+ await writePlain(ctx, project, updated, { config });
31
33
  // 보안: value 는 응답에 절대 포함 X — 키 이름만 confirm
32
34
  return jsonOk({ project, key, action: 'set', total_keys: Object.keys(updated).length });
33
35
  }
@@ -38,19 +40,20 @@ export async function handleUnsetSecret(
38
40
  const project = readString(args, 'project');
39
41
  const key = readString(args, 'key');
40
42
  if (!project || !key) return jsonError('missing required args: project, key');
43
+ const config = readString(args, 'config') ?? 'default';
41
44
  const ctx = await loadAuthContext();
42
45
  if (!ctx) return notLoggedIn();
43
46
  if (ctx.kind !== 'user') {
44
47
  return jsonError('service token cannot write — user token (master pw) required');
45
48
  }
46
- const existing = await readPlain(ctx, project);
49
+ const existing = await readPlain(ctx, project, config);
47
50
  if (!existing) return jsonError(`envelope '${project}' not found`);
48
51
  if (!(key in existing)) {
49
52
  return jsonOk({ project, key, action: 'noop', reason: 'key not present' });
50
53
  }
51
54
  const updated: Record<string, string> = { ...existing };
52
55
  delete updated[key];
53
- await writePlain(ctx, project, updated);
56
+ await writePlain(ctx, project, updated, { config });
54
57
  return jsonOk({ project, key, action: 'unset', total_keys: Object.keys(updated).length });
55
58
  }
56
59
 
@@ -125,3 +128,79 @@ export function handleManifestModify(args: Record<string, unknown> | undefined):
125
128
  return jsonError(`createManifest failed: ${(err as Error).message}`);
126
129
  }
127
130
  }
131
+
132
+ /** 공통 — user(master pw) 컨텍스트 가드. service/identity 는 거부 (envelope 변경 불가). */
133
+ function ensureUserCtx(
134
+ ctx: Awaited<ReturnType<typeof loadAuthContext>>,
135
+ ): ctx is Extract<NonNullable<Awaited<ReturnType<typeof loadAuthContext>>>, { kind: 'user' }> {
136
+ return ctx?.kind === 'user';
137
+ }
138
+
139
+ /** athsra_rollback — project 를 특정 version 으로 되돌림. confirm=project 필수(되돌리기 어려움). */
140
+ export async function handleRollback(
141
+ args: Record<string, unknown> | undefined,
142
+ ): Promise<ToolTextResult> {
143
+ const project = readString(args, 'project');
144
+ const version = readString(args, 'version');
145
+ if (!project || !version) return jsonError('missing required args: project, version');
146
+ const config = readString(args, 'config') ?? 'default';
147
+ const denied = requireConfirm(args, project);
148
+ if (denied) return denied;
149
+ const ctx = await loadAuthContext();
150
+ if (!ctx) return notLoggedIn();
151
+ if (!ensureUserCtx(ctx)) return jsonError('rollback requires a user token (master pw)');
152
+ return jsonOk(await ctx.client.rollbackProject(project, version, config));
153
+ }
154
+
155
+ /** athsra_delete_project — soft delete (복구 가능). confirm=project 필수. hard delete 는 MCP 미노출. */
156
+ export async function handleDeleteProject(
157
+ args: Record<string, unknown> | undefined,
158
+ ): Promise<ToolTextResult> {
159
+ const project = readString(args, 'project');
160
+ if (!project) return jsonError('missing required arg: project');
161
+ const config = readString(args, 'config') ?? 'default';
162
+ const denied = requireConfirm(args, project);
163
+ if (denied) return denied;
164
+ const ctx = await loadAuthContext();
165
+ if (!ctx) return notLoggedIn();
166
+ if (!ensureUserCtx(ctx)) return jsonError('delete requires a user token (master pw)');
167
+ return jsonOk(await ctx.client.deleteProject(project, { config }));
168
+ }
169
+
170
+ /** athsra_restore_project — soft-deleted project 복구. confirm 불요(비파괴 — 직전 상태 회복). */
171
+ export async function handleRestoreProject(
172
+ args: Record<string, unknown> | undefined,
173
+ ): Promise<ToolTextResult> {
174
+ const project = readString(args, 'project');
175
+ if (!project) return jsonError('missing required arg: project');
176
+ const config = readString(args, 'config') ?? 'default';
177
+ const ctx = await loadAuthContext();
178
+ if (!ctx) return notLoggedIn();
179
+ if (!ensureUserCtx(ctx)) return jsonError('restore requires a user token (master pw)');
180
+ return jsonOk(await ctx.client.restoreProject(project, config));
181
+ }
182
+
183
+ /** athsra_bulk_set — 여러 키를 1회 read→merge→write (버전 1 bump). 값은 응답 미노출(키 이름만). */
184
+ export async function handleBulkSet(
185
+ args: Record<string, unknown> | undefined,
186
+ ): Promise<ToolTextResult> {
187
+ const project = readString(args, 'project');
188
+ const secrets = readRecordOfStrings(args, 'secrets');
189
+ if (!project || !secrets) {
190
+ return jsonError('missing required args: project, secrets (object of string→string)');
191
+ }
192
+ if (Object.keys(secrets).length === 0) return jsonError('secrets must be a non-empty object');
193
+ const config = readString(args, 'config') ?? 'default';
194
+ const ctx = await loadAuthContext();
195
+ if (!ctx) return notLoggedIn();
196
+ if (!ensureUserCtx(ctx)) return jsonError('bulk_set requires a user token (master pw)');
197
+ const existing = (await readPlain(ctx, project, config)) ?? {};
198
+ const merged: Record<string, string> = { ...existing, ...secrets };
199
+ await writePlain(ctx, project, merged, { config });
200
+ return jsonOk({
201
+ project,
202
+ action: 'bulk_set',
203
+ set_keys: Object.keys(secrets).sort(),
204
+ total_keys: Object.keys(merged).length,
205
+ });
206
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { spawn } from 'node:child_process';
12
12
  import { createHash, randomBytes } from 'node:crypto';
13
+ import { readFileSync } from 'node:fs';
13
14
  import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
14
15
 
15
16
  /** OIDC PKCE 플로우 엔드포인트 + 파라미터 (env override 는 호출부에서 해석해 주입). */
@@ -103,9 +104,50 @@ function base64urlBuf(buf: Buffer): string {
103
104
 
104
105
  export function openBrowser(url: string): void {
105
106
  const platform = process.platform;
107
+ // WSL — Linux 플랫폼이지만 xdg-open 이 호스트 브라우저로 연결 안 됨. wslview/powershell 폴백.
108
+ if (platform === 'linux' && isWsl()) {
109
+ openWsl(url);
110
+ return;
111
+ }
106
112
  const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
107
113
  const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
108
- spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
114
+ spawnQuiet(cmd, args);
115
+ }
116
+
117
+ /** WSL(Windows Subsystem for Linux) 호스트 감지 — env 우선, /proc/version 폴백. */
118
+ function isWsl(): boolean {
119
+ if (process.env.WSL_DISTRO_NAME) return true;
120
+ try {
121
+ return readFileSync('/proc/version', 'utf8').toLowerCase().includes('microsoft');
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /** detached spawn + error 무음 — 자동 열기 실패해도 URL 은 호출 전 출력됨(사용자 수동 복사). */
128
+ function spawnQuiet(cmd: string, args: string[]): void {
129
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
130
+ child.on('error', () => {
131
+ /* URL 이미 출력됨 */
132
+ });
133
+ child.unref();
134
+ }
135
+
136
+ /** WSL — Windows 호스트 기본 브라우저. wslview(wslu) 우선, 실패 시 powershell.exe Start-Process. */
137
+ function openWsl(url: string): void {
138
+ const child = spawn('wslview', [url], { detached: true, stdio: 'ignore' });
139
+ child.on('error', () => {
140
+ const ps = spawn(
141
+ 'powershell.exe',
142
+ ['-NoProfile', '-Command', `Start-Process '${url.replace(/'/g, "''")}'`],
143
+ { detached: true, stdio: 'ignore' },
144
+ );
145
+ ps.on('error', () => {
146
+ /* 둘 다 실패 — URL 이미 출력됨 */
147
+ });
148
+ ps.unref();
149
+ });
150
+ child.unref();
109
151
  }
110
152
 
111
153
  async function findFreePort(): Promise<number> {
@@ -40,13 +40,14 @@ interface AdderIdentity {
40
40
  * 호출자가 **owner**(master pw 로 master recipient unwrap)면 addMemberRecipient, **admin 멤버**
41
41
  * (master 불가)면 본인 X25519 키로 addMemberRecipientAsMember (server recipient-continuity 가
42
42
  * add=manager 강제). `replace`=true 면 기존 member recipient 를 제거 후 재포장(key reset 복구의
43
- * stale wrap 교체 — recipient id set 불변).
43
+ * stale wrap 교체 — recipient id set 불변). `projects` 명시 시 그 프로젝트만 대상
44
+ * (Phase 5 B4 — MCP athsra_project_share 의 단건 공유; 기본 = listProjects 전체).
44
45
  */
45
46
  export async function grantOrgAccess(
46
47
  client: AthsraClient,
47
48
  masterPw: string,
48
49
  memberUserId: number,
49
- opts?: { replace?: boolean },
50
+ opts?: { replace?: boolean; dryRun?: boolean; projects?: string[] },
50
51
  ): Promise<GrantResult> {
51
52
  const pub = await client.getPublicKey(memberUserId);
52
53
  if (!pub) {
@@ -102,7 +103,7 @@ export async function grantOrgAccess(
102
103
  }
103
104
  };
104
105
 
105
- const projects = await client.listProjects();
106
+ const projects = opts?.projects ?? (await client.listProjects());
106
107
  const result: GrantResult = { granted: [], skipped: [], legacyV1: [], failed: [] };
107
108
  for (const project of projects) {
108
109
  try {
@@ -120,6 +121,11 @@ export async function grantOrgAccess(
120
121
  result.skipped.push(project);
121
122
  continue;
122
123
  }
124
+ if (opts?.dryRun) {
125
+ // survey 만 — 실제 crypto 연산·PUT 없이 "추가 예정" 으로 집계 (migrate --self dry-run).
126
+ result.granted.push(project);
127
+ continue;
128
+ }
123
129
  // replace: 기존(stale) recipient 제거 후 재포장. 신규: 그냥 추가.
124
130
  const baseEnv = has ? removeMemberRecipient(env, memberUserId) : env;
125
131
  const updated = await addRecipient(baseEnv);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * service-tokens.ts — Phase 5 B4. service token 발급 + envelope recipient 동반 추가의 공용 코어.
3
+ *
4
+ * `commands/service-token.ts` createCmd 에서 추출 — CLI 와 MCP(athsra_service_token_create)가
5
+ * 같은 경로를 소비해 드리프트를 막는다. 발급(worker) → v1→v2 migrate(필요 시) →
6
+ * addServiceRecipient(DEK 를 token secret 으로 wrap) → putEnvelope. master pw 필수
7
+ * (recipient 추가에 master recipient DEK unwrap 이 필요) — identity 디바이스 불가.
8
+ */
9
+ import { addServiceRecipient, migrateV1ToV2, type SecretEnvelopeV2 } from '@athsra/crypto';
10
+ import type { UserAuthContext } from './auth-context.ts';
11
+
12
+ export interface CreatedServiceToken {
13
+ token: string;
14
+ recipient_id: string;
15
+ project: string;
16
+ perms: string;
17
+ expires_at: string | null;
18
+ }
19
+
20
+ /**
21
+ * 발급 + envelope recipient 추가 일체. envelope 부재 시 throw (발급 무의미 — 먼저 `athsra set`).
22
+ * 반환되는 `token` 은 이 시점 1회만 노출 — 보관 안내는 호출자(CLI 출력 / MCP warning) 책임.
23
+ */
24
+ export async function createServiceTokenWithRecipient(
25
+ ctx: Pick<UserAuthContext, 'client' | 'masterPw'>,
26
+ args: { project: string; label: string; perms: 'read' | 'write'; expiresInDays?: number },
27
+ ): Promise<CreatedServiceToken> {
28
+ const { client, masterPw } = ctx;
29
+
30
+ // envelope 확인 — 없으면 service token 발급 무의미
31
+ const envelope = await client.getEnvelope(args.project);
32
+ if (!envelope) {
33
+ throw new Error(
34
+ `project ${args.project} envelope 없음 — 먼저 \`athsra set ${args.project} KEY=value\` 실행.`,
35
+ );
36
+ }
37
+
38
+ // worker 에 service token 발급
39
+ const created = await client.createServiceToken({
40
+ project: args.project,
41
+ label: args.label,
42
+ perms: args.perms,
43
+ expiresInDays: args.expiresInDays,
44
+ });
45
+
46
+ // envelope 에 recipient 추가 — v1 이면 v2 로 자동 migrate
47
+ let v2Envelope: SecretEnvelopeV2;
48
+ if (envelope.version === 1) {
49
+ v2Envelope = await migrateV1ToV2(envelope, masterPw);
50
+ } else {
51
+ v2Envelope = envelope;
52
+ }
53
+ const updated = await addServiceRecipient(
54
+ v2Envelope,
55
+ masterPw,
56
+ created.token,
57
+ created.recipient_id,
58
+ );
59
+ await client.putEnvelope(args.project, updated);
60
+
61
+ return created;
62
+ }