@athsra/cli 1.0.2 → 1.0.3

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.
@@ -1,15 +1,37 @@
1
- import { spawn } from 'node:child_process';
2
- import { createHash, randomBytes } from 'node:crypto';
3
- import { createServer, type IncomingMessage, type ServerResponse } from 'node:http';
4
1
  import { hostname } from 'node:os';
5
- import { deriveKey, fromBase64, toBase64 } from '@athsra/crypto';
6
- import { isValidPhrase, normalizePhrase, wordCount } from '../lib/bip39.ts';
2
+ import {
3
+ deriveProof,
4
+ fromBase64,
5
+ isValidPhrase,
6
+ normalizePhrase,
7
+ toBase64,
8
+ wordCount,
9
+ } from '@athsra/crypto';
10
+ import { resolveProject } from '../lib/auto-project.ts';
7
11
  import { AthsraClient } from '../lib/client.ts';
8
12
  import { type Config, loadConfig, saveConfig } from '../lib/config.ts';
9
- import { probeKeyring, setMasterPw, setToken } from '../lib/keyring.ts';
13
+ import { ensureKeypair } from '../lib/identity-key.ts';
14
+ import { probeKeyring, setDeviceToken, setMasterPw, setToken } from '../lib/keyring.ts';
10
15
  import { consumeLegacySession } from '../lib/legacy-session.ts';
16
+ import { openBrowser, runOidcPkceFlow, SSO_DEFAULTS } from '../lib/oidc-flow.ts';
11
17
  import { promptConfirm, promptPassword, promptText } from '../lib/prompt.ts';
12
18
 
19
+ /**
20
+ * Phase 4 Slice 3 — login 후 X25519 identity 키쌍 보장 (best-effort). 키쌍은 org 공유 시크릿
21
+ * 복호용. 실패해도 login 자체는 성공 유지(다음 login 에 재시도) — provisioning 은 substrate 이고
22
+ * 실제 공유는 Slice 6. master pw 로 wrap 된 privkey 만 server 보관(평문 미노출).
23
+ */
24
+ async function provisionIdentityKey(client: AthsraClient, masterPw: string): Promise<void> {
25
+ try {
26
+ const created = await ensureKeypair(client, masterPw);
27
+ console.log(created ? ' identity key: provisioned ✓' : ' identity key: present ✓');
28
+ } catch (err) {
29
+ console.warn(
30
+ ` identity key: provisioning deferred (${err instanceof Error ? err.message : String(err)})`,
31
+ );
32
+ }
33
+ }
34
+
13
35
  /**
14
36
  * Phase 2.8 (2026-05-26) — Connect SSO 로그인 (OIDC PKCE).
15
37
  *
@@ -22,112 +44,9 @@ import { promptConfirm, promptPassword, promptText } from '../lib/prompt.ts';
22
44
  * 6) keyring 저장 + master pw prompt (envelope decrypt 용)
23
45
  *
24
46
  * E2EE 정합: SSO 는 worker 인증 layer 만. master pw 는 envelope decrypt 전용.
47
+ * PKCE 플로우 기계 부분(step 1-4)은 lib/oidc-flow.ts 로 분리 (2026-06-02 리팩토링).
25
48
  */
26
49
 
27
- const SSO_DEFAULTS = {
28
- /** Connect authorize endpoint */
29
- authorizeUrl: 'https://login.modfolio.io/authorize',
30
- /** Connect token endpoint */
31
- tokenUrl: 'https://login.modfolio.io/token',
32
- /** Connect 측 CLI client (PKCE public, no secret). 사용자가 별도 등록. */
33
- clientId: 'athsra-cli',
34
- /** OIDC scope */
35
- scope: 'openid profile email',
36
- /** callback timeout (ms) — 사용자 browser 작업 대기 */
37
- callbackTimeoutMs: 5 * 60 * 1000,
38
- };
39
-
40
- interface CallbackResult {
41
- code: string;
42
- state: string;
43
- }
44
-
45
- /** Start ephemeral loopback HTTP callback server. Closes after first valid callback or timeout. */
46
- async function waitForCallback(
47
- expectedState: string,
48
- port: number,
49
- timeoutMs: number,
50
- ): Promise<CallbackResult> {
51
- return await new Promise<CallbackResult>((resolve, reject) => {
52
- const server = createServer((req: IncomingMessage, res: ServerResponse) => {
53
- const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
54
- if (url.pathname !== '/callback') {
55
- res.statusCode = 404;
56
- res.end('not found');
57
- return;
58
- }
59
- const code = url.searchParams.get('code');
60
- const state = url.searchParams.get('state');
61
- const error = url.searchParams.get('error');
62
- if (error) {
63
- res.statusCode = 400;
64
- res.end(`Authorization failed: ${error}. Return to terminal.`);
65
- server.close();
66
- reject(new Error(`Connect authorize error: ${error}`));
67
- return;
68
- }
69
- if (!code || !state) {
70
- res.statusCode = 400;
71
- res.end('Missing code or state. Return to terminal.');
72
- server.close();
73
- reject(new Error('Missing code or state in callback'));
74
- return;
75
- }
76
- if (state !== expectedState) {
77
- res.statusCode = 400;
78
- res.end('State mismatch — possible CSRF. Return to terminal.');
79
- server.close();
80
- reject(new Error('State mismatch'));
81
- return;
82
- }
83
- res.statusCode = 200;
84
- res.setHeader('content-type', 'text/html; charset=utf-8');
85
- res.end(
86
- '<!doctype html><html><body style="font-family:system-ui;padding:2rem;">' +
87
- '<h2>✓ athsra SSO 로그인 완료</h2>' +
88
- '<p>터미널로 돌아가세요. 이 창은 닫아도 됩니다.</p>' +
89
- '</body></html>',
90
- );
91
- server.close();
92
- resolve({ code, state });
93
- });
94
- server.listen(port, '127.0.0.1');
95
- setTimeout(() => {
96
- server.close();
97
- reject(new Error(`callback timeout after ${timeoutMs / 1000}s`));
98
- }, timeoutMs);
99
- });
100
- }
101
-
102
- function base64urlBuf(buf: Buffer): string {
103
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
104
- }
105
-
106
- function openBrowser(url: string): void {
107
- const platform = process.platform;
108
- const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
109
- const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
110
- spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
111
- }
112
-
113
- async function findFreePort(): Promise<number> {
114
- // 49152-65535 ephemeral range. createServer port=0 자동 할당.
115
- return await new Promise<number>((resolve, reject) => {
116
- const server = createServer();
117
- server.on('error', reject);
118
- server.listen(0, '127.0.0.1', () => {
119
- const address = server.address();
120
- if (typeof address !== 'object' || address === null) {
121
- server.close();
122
- reject(new Error('no port'));
123
- return;
124
- }
125
- const port = address.port;
126
- server.close(() => resolve(port));
127
- });
128
- });
129
- }
130
-
131
50
  async function ssoLoginCmd(): Promise<number> {
132
51
  console.log('athsra login --sso (modfolio-connect OIDC PKCE)\n');
133
52
 
@@ -155,78 +74,28 @@ async function ssoLoginCmd(): Promise<number> {
155
74
  return 1;
156
75
  }
157
76
 
158
- // 4. PKCE + state 생성
159
- const codeVerifier = base64urlBuf(randomBytes(48));
160
- const codeChallenge = base64urlBuf(createHash('sha256').update(codeVerifier).digest());
161
- const state = base64urlBuf(randomBytes(16));
162
-
163
- // 5. callback server start
164
- let port: number;
77
+ // 4. OIDC PKCE 플로우 (browser → Connect /authorize → callback → /token) → access_token.
78
+ // env override 해석 후 lib/oidc-flow.ts 에 위임. 실패 시 throw → `✗ ...` + exit 1.
79
+ let accessToken: string;
165
80
  try {
166
- port = await findFreePort();
167
- } catch (err) {
168
- console.error(`✗ cannot allocate callback port: ${(err as Error).message}`);
169
- return 1;
170
- }
171
- const redirectUri = `http://127.0.0.1:${port}/callback`;
172
-
173
- // 6. browser open
174
- const authorizeUrl = process.env.ATHSRA_SSO_AUTHORIZE_URL ?? SSO_DEFAULTS.authorizeUrl;
175
- const clientId = process.env.ATHSRA_SSO_CLIENT_ID ?? SSO_DEFAULTS.clientId;
176
- const params = new URLSearchParams({
177
- client_id: clientId,
178
- response_type: 'code',
179
- code_challenge: codeChallenge,
180
- code_challenge_method: 'S256',
181
- state,
182
- redirect_uri: redirectUri,
183
- scope: SSO_DEFAULTS.scope,
184
- });
185
- const authUrl = `${authorizeUrl}?${params.toString()}`;
186
- console.log(`opening browser: ${authUrl.slice(0, 80)}...`);
187
- console.log(`callback: ${redirectUri}`);
188
- console.log('(complete login in browser — terminal will resume when callback received)\n');
189
- openBrowser(authUrl);
190
-
191
- // 7. wait for callback
192
- let callback: CallbackResult;
193
- try {
194
- callback = await waitForCallback(state, port, SSO_DEFAULTS.callbackTimeoutMs);
81
+ const flow = await runOidcPkceFlow({
82
+ authorizeUrl: process.env.ATHSRA_SSO_AUTHORIZE_URL ?? SSO_DEFAULTS.authorizeUrl,
83
+ tokenUrl: process.env.ATHSRA_SSO_TOKEN_URL ?? SSO_DEFAULTS.tokenUrl,
84
+ clientId: process.env.ATHSRA_SSO_CLIENT_ID ?? SSO_DEFAULTS.clientId,
85
+ scope: SSO_DEFAULTS.scope,
86
+ callbackTimeoutMs: SSO_DEFAULTS.callbackTimeoutMs,
87
+ });
88
+ accessToken = flow.accessToken;
195
89
  } catch (err) {
196
- console.error(`✗ callback failed: ${(err as Error).message}`);
90
+ console.error(`✗ ${(err as Error).message}`);
197
91
  return 1;
198
92
  }
199
- console.log('✓ callback received, exchanging code...');
200
93
 
201
- // 8. token exchange (Connect /token)
202
- const tokenUrl = process.env.ATHSRA_SSO_TOKEN_URL ?? SSO_DEFAULTS.tokenUrl;
203
- const tokenRes = await fetch(tokenUrl, {
204
- method: 'POST',
205
- headers: { 'content-type': 'application/x-www-form-urlencoded' },
206
- body: new URLSearchParams({
207
- grant_type: 'authorization_code',
208
- code: callback.code,
209
- code_verifier: codeVerifier,
210
- client_id: clientId,
211
- redirect_uri: redirectUri,
212
- }).toString(),
213
- });
214
- if (!tokenRes.ok) {
215
- console.error(`✗ Connect token exchange failed: ${tokenRes.status} ${await tokenRes.text()}`);
216
- return 1;
217
- }
218
- const tokenBody = (await tokenRes.json()) as { access_token?: string };
219
- if (!tokenBody.access_token) {
220
- console.error('✗ Connect token response missing access_token');
221
- return 1;
222
- }
223
- console.log('✓ Connect access_token received');
224
-
225
- // 9. worker /auth/sso (Connect token → athsra Bearer)
94
+ // 5. worker /auth/sso (Connect token → athsra Bearer)
226
95
  const ssoRes = await fetch(`${workerUrl}/auth/sso`, {
227
96
  method: 'POST',
228
97
  headers: { 'content-type': 'application/json' },
229
- body: JSON.stringify({ access_token: tokenBody.access_token, label: machineId }),
98
+ body: JSON.stringify({ access_token: accessToken, label: machineId }),
230
99
  });
231
100
  if (!ssoRes.ok) {
232
101
  console.error(`✗ athsra worker SSO failed: ${ssoRes.status} ${await ssoRes.text()}`);
@@ -234,12 +103,13 @@ async function ssoLoginCmd(): Promise<number> {
234
103
  }
235
104
  const ssoBody = (await ssoRes.json()) as {
236
105
  token: string;
106
+ user_id: number;
237
107
  identifier: string;
238
108
  expires_at?: string;
239
109
  createdAt: string;
240
110
  };
241
111
 
242
- // 10. master pw prompt (envelope decrypt 전용 — worker 에 송신 X)
112
+ // 6. master pw prompt (envelope decrypt 전용 — worker 에 송신 X)
243
113
  console.log('\n● Master password — envelope decrypt (E2EE, worker 노출 X)');
244
114
  const envPw = process.env.ATHSRA_MASTER_PW;
245
115
  let masterPw: string;
@@ -259,7 +129,35 @@ async function ssoLoginCmd(): Promise<number> {
259
129
  masterPw = normalizePhrase(masterPw);
260
130
  }
261
131
 
262
- // 11. config + keyring 저장
132
+ // 6.5. master pw proof bootstrap-or-verify (POST /auth/proof) — Phase 3a.
133
+ // proof = Argon2id(masterPw, perUserSalt(user_id, GLOBAL_SALT)) 단방향 해시 — 평문 송신 X (E2EE).
134
+ // G-1: per-user salt 로 같은 pw 두 user 도 proof 가 유일. 첫 SSO = bootstrap, 재로그인 = 검증.
135
+ const info = await tempClient.info();
136
+ const proof = toBase64(deriveProof(masterPw, ssoBody.user_id, fromBase64(info.global_salt)));
137
+ try {
138
+ const pr = await new AthsraClient(workerUrl, ssoBody.token).setProof(
139
+ proof,
140
+ info.global_salt_version,
141
+ );
142
+ if (pr.version_reset) {
143
+ console.log('• Master password re-registered (GLOBAL_SALT changed).');
144
+ } else if (pr.bootstrap) {
145
+ console.log('• Master password set for this account ✓ (worker stores one-way proof only).');
146
+ } else {
147
+ console.log('• Master password verified ✓.');
148
+ }
149
+ } catch (err) {
150
+ const msg = (err as Error).message;
151
+ if (msg.includes('409')) {
152
+ console.error('✗ master password mismatch — 이 계정에 설정된 master pw 와 다릅니다.');
153
+ console.error(' 올바른 master pw 로 다시 로그인하세요 (틀린 pw 는 저장하지 않습니다).');
154
+ return 1;
155
+ }
156
+ console.error(`✗ master password proof failed: ${msg}`);
157
+ return 1;
158
+ }
159
+
160
+ // 7. config + keyring 저장
263
161
  const config: Config = {
264
162
  workerUrl,
265
163
  machineId,
@@ -268,6 +166,7 @@ async function ssoLoginCmd(): Promise<number> {
268
166
  saveConfig(config);
269
167
  setMasterPw(machineId, masterPw);
270
168
  setToken(machineId, ssoBody.token);
169
+ await provisionIdentityKey(new AthsraClient(workerUrl, ssoBody.token), masterPw);
271
170
 
272
171
  console.log(`\n✓ SSO logged in (machine: ${machineId})`);
273
172
  console.log(` identifier: ${ssoBody.identifier}`);
@@ -279,11 +178,149 @@ async function ssoLoginCmd(): Promise<number> {
279
178
  return 0;
280
179
  }
281
180
 
181
+ /** 비-인터랙티브 agent 기본 worker (config/env 없을 때). production athsra worker. */
182
+ const DEFAULT_WORKER_URL = 'https://athsra-worker.winterermod.workers.dev';
183
+
184
+ interface DeviceArgs {
185
+ project?: string;
186
+ perms: 'read' | 'write';
187
+ noBrowser: boolean;
188
+ }
189
+
190
+ function parseDeviceArgs(args: string[]): DeviceArgs {
191
+ let project: string | undefined;
192
+ let perms: 'read' | 'write' = 'read';
193
+ let noBrowser = false;
194
+ for (let i = 0; i < args.length; i++) {
195
+ const a = args[i];
196
+ if (a === '--project' || a === '-p') {
197
+ const v = args[i + 1];
198
+ if (v && !v.startsWith('-')) {
199
+ project = v;
200
+ i++;
201
+ }
202
+ } else if (a === '--write') {
203
+ perms = 'write';
204
+ } else if (a === '--perms') {
205
+ const v = args[i + 1];
206
+ if (v) {
207
+ perms = v === 'write' ? 'write' : 'read';
208
+ i++;
209
+ }
210
+ } else if (a === '--no-browser') {
211
+ noBrowser = true;
212
+ } else if (a && a !== '--device' && !a.startsWith('-') && !project) {
213
+ project = a;
214
+ }
215
+ }
216
+ return { project, perms, noBrowser };
217
+ }
218
+
219
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
220
+
221
+ /**
222
+ * Phase 3 P3 (2026-05-31) — device-login (RFC 8628 적응).
223
+ *
224
+ * 비-TTY agent 가 master pw 인터랙티브 프롬프트 없이 project-scoped 인증.
225
+ * `athsra login --device [--project <p>] [--write]` → user_code + URL 출력 → 사용자가
226
+ * athsra.com/device 에서 1-click 승인 → poll 로 ats_* 수령 → keyring 저장.
227
+ * 이후 `athsra run <p>` 가 master pw 없이 동작.
228
+ */
229
+ async function deviceLoginCmd(args: string[]): Promise<number> {
230
+ const { project: explicitProject, perms, noBrowser } = parseDeviceArgs(args);
231
+ // project: 명시 --project > cwd 자동 감지 (basename > .athsra > package.json)
232
+ const project = explicitProject ?? resolveProject([]).project ?? undefined;
233
+ if (!project) {
234
+ console.error(
235
+ 'project 미지정 — `athsra login --device --project <name>` 또는 project repo 안에서 실행.',
236
+ );
237
+ return 1;
238
+ }
239
+
240
+ const probe = probeKeyring();
241
+ if (!probe.ok) {
242
+ console.error(`✗ keyring backend unavailable: ${probe.error ?? 'unknown'}`);
243
+ return 1;
244
+ }
245
+
246
+ const existing = loadConfig();
247
+ const workerUrl = process.env.ATHSRA_WORKER_URL ?? existing?.workerUrl ?? DEFAULT_WORKER_URL;
248
+ const machineId = existing?.machineId ?? `${hostname()}-${Date.now().toString(36)}`;
249
+
250
+ const client = new AthsraClient(workerUrl);
251
+ if (!(await client.health())) {
252
+ console.error(`✗ worker unreachable: ${workerUrl}`);
253
+ return 1;
254
+ }
255
+
256
+ let dc: Awaited<ReturnType<typeof client.deviceCode>>;
257
+ try {
258
+ dc = await client.deviceCode({ project, perms, label: machineId });
259
+ } catch (err) {
260
+ console.error(`✗ device code 발급 실패: ${(err as Error).message}`);
261
+ return 1;
262
+ }
263
+
264
+ console.log('\n● athsra device-login — 브라우저에서 승인이 필요합니다\n');
265
+ console.log(` 1. 열기: ${dc.verification_uri_complete}`);
266
+ console.log(` 2. 코드: ${dc.user_code} (project: ${project}, perms: ${perms})`);
267
+ console.log('\n athsra.com 에 SSO 로그인 후 "승인" 클릭하면 자동 진행됩니다.');
268
+ console.log(' (master pw 는 그 브라우저에서 1회 — 이 기기엔 저장되지 않습니다)\n');
269
+ if (!noBrowser) openBrowser(dc.verification_uri_complete);
270
+
271
+ // poll
272
+ let interval = Math.max(1, dc.interval) * 1000;
273
+ const deadline = Date.now() + dc.expires_in * 1000;
274
+ process.stdout.write(' 대기 중');
275
+ while (Date.now() < deadline) {
276
+ await sleep(interval);
277
+ let result: Awaited<ReturnType<typeof client.devicePollToken>>;
278
+ try {
279
+ result = await client.devicePollToken(dc.device_code);
280
+ } catch {
281
+ process.stdout.write('.');
282
+ continue;
283
+ }
284
+ if (result.status === 'token') {
285
+ setDeviceToken(project, result.token);
286
+ saveConfig({
287
+ workerUrl,
288
+ machineId,
289
+ createdAt: existing?.createdAt ?? new Date().toISOString(),
290
+ });
291
+ console.log('\n\n✓ device-login 완료');
292
+ console.log(` project: ${result.project} (${result.perms})`);
293
+ console.log(' keyring: device token 저장 (master pw 불필요)');
294
+ console.log(` 사용: athsra run ${result.project} -- <command>\n`);
295
+ return 0;
296
+ }
297
+ // pending / terminal
298
+ if (result.error === 'access_denied') {
299
+ console.error('\n\n✗ 승인이 거부되었습니다.');
300
+ return 1;
301
+ }
302
+ if (result.error === 'expired_token') {
303
+ console.error('\n\n✗ 요청이 만료되었습니다. `athsra login --device` 재시도.');
304
+ return 1;
305
+ }
306
+ if (result.error === 'slow_down') {
307
+ interval += 5000;
308
+ }
309
+ process.stdout.write('.');
310
+ }
311
+ console.error('\n\n✗ 승인 대기 시간 초과. `athsra login --device` 재시도.');
312
+ return 1;
313
+ }
314
+
282
315
  const LOGIN_USAGE = [
283
- 'usage: athsra login [--sso]',
316
+ 'usage: athsra login [--sso | --device]',
284
317
  '',
285
318
  '기본 (master pw + paper backup): athsra login',
286
319
  'SSO (modfolio-connect OIDC PKCE — Phase 2.8): athsra login --sso',
320
+ 'device (비-TTY agent, master pw 불필요 — Phase 3 P3): athsra login --device [--project <p>] [--write]',
321
+ '',
322
+ 'device-login: agent 가 user_code 출력 → 사용자가 athsra.com/device 에서 1-click 승인 →',
323
+ ' project-scoped ats_* 수령 (keyring 저장). master pw 는 승인 브라우저에서만 사용.',
287
324
  '',
288
325
  '환경변수:',
289
326
  ' ATHSRA_WORKER_URL worker URL (config 우선)',
@@ -299,6 +336,9 @@ export async function loginCmd(args: string[]): Promise<number> {
299
336
  console.log(LOGIN_USAGE);
300
337
  return 0;
301
338
  }
339
+ if (args.includes('--device')) {
340
+ return deviceLoginCmd(args);
341
+ }
302
342
  if (args.includes('--sso')) {
303
343
  return ssoLoginCmd();
304
344
  }
@@ -407,8 +447,11 @@ export async function loginCmd(args: string[]): Promise<number> {
407
447
  console.log(' 동일 master password 로 재 register 진행합니다.\n');
408
448
  }
409
449
 
410
- // 7. master_pw_proof = Argon2id(pw + GLOBAL_SALT)
411
- const proofBytes = deriveKey(masterPw, fromBase64(info.global_salt));
450
+ // 7. master_pw_proof = Argon2id(pw, perUserSalt(user_id, GLOBAL_SALT)) — G-1 per-user salt.
451
+ // password register 는 founding singleton(user 1) 전용(SSO 외 유일 경로) — userId=1 고정.
452
+ // 다중 사용자 self-serve password register 는 out-of-scope(SSO-gated 유지).
453
+ const FOUNDING_USER_ID = 1;
454
+ const proofBytes = deriveProof(masterPw, FOUNDING_USER_ID, fromBase64(info.global_salt));
412
455
  const proofBase64 = toBase64(proofBytes);
413
456
 
414
457
  // 8. register → token
@@ -435,6 +478,7 @@ export async function loginCmd(args: string[]): Promise<number> {
435
478
  saveConfig(config);
436
479
  setMasterPw(machineId, masterPw);
437
480
  setToken(machineId, reg.token);
481
+ await provisionIdentityKey(new AthsraClient(workerUrl, reg.token), masterPw);
438
482
 
439
483
  console.log(`\n✓ logged in (machine: ${machineId})`);
440
484
  console.log(` worker: ${workerUrl}`);
@@ -20,15 +20,11 @@ import {
20
20
  applyManifest,
21
21
  createManifest,
22
22
  loadManifest,
23
- type SecretsManifest,
24
23
  manifestPath,
24
+ type SecretsManifest,
25
25
  saveManifest,
26
26
  } from '../lib/secrets-manifest.ts';
27
- import {
28
- describeConflicts,
29
- findConflicts,
30
- scanWranglerConfig,
31
- } from '../lib/wrangler-scan.ts';
27
+ import { describeConflicts, findConflicts, scanWranglerConfig } from '../lib/wrangler-scan.ts';
32
28
 
33
29
  const USAGE = [
34
30
  'usage: athsra manifest <subcommand> [options]',
@@ -335,10 +331,7 @@ async function cmdValidate(flags: ParsedFlags): Promise<number> {
335
331
  return applied.missing.length > 0 ? 1 : 0;
336
332
  }
337
333
 
338
- function modifyManifest(
339
- flags: ParsedFlags,
340
- op: 'add' | 'remove',
341
- ): number {
334
+ function modifyManifest(flags: ParsedFlags, op: 'add' | 'remove'): number {
342
335
  const keys = flags.positional;
343
336
  if (keys.length === 0) {
344
337
  console.error(`✗ usage: athsra manifest ${op} <KEY> [<KEY2>...] [--worker=<name>]`);