@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.
- package/package.json +1 -2
- package/src/commands/admin.ts +11 -33
- package/src/commands/adopt.ts +1 -5
- package/src/commands/dr.ts +217 -0
- package/src/commands/login.ts +223 -179
- package/src/commands/manifest.ts +3 -10
- package/src/commands/mcp.ts +69 -485
- package/src/commands/new-phrase.ts +1 -1
- package/src/commands/org.ts +363 -0
- package/src/commands/rotate-master.ts +26 -7
- package/src/commands/run.ts +2 -1
- package/src/commands/service-token.ts +17 -6
- package/src/index.ts +7 -0
- package/src/lib/auth-context.ts +77 -18
- package/src/lib/client.ts +396 -31
- package/src/lib/colors.ts +17 -0
- package/src/lib/config.ts +6 -0
- package/src/lib/env-format.ts +5 -53
- package/src/lib/envelope.ts +89 -3
- package/src/lib/identity-key.ts +59 -0
- package/src/lib/keyring.ts +26 -0
- package/src/lib/mcp-tools/args.ts +60 -0
- package/src/lib/mcp-tools/defs.ts +179 -0
- package/src/lib/mcp-tools/read.ts +156 -0
- package/src/lib/mcp-tools/result.ts +46 -0
- package/src/lib/mcp-tools/write.ts +127 -0
- package/src/lib/oidc-flow.ts +200 -0
- package/src/lib/org-rewrap.ts +230 -0
- package/src/lib/secrets-manifest.ts +1 -4
- package/src/lib/wrangler-scan.ts +2 -8
- package/src/lib/bip39.ts +0 -45
package/src/commands/login.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
|
|
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 {
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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(`✗
|
|
90
|
+
console.error(`✗ ${(err as Error).message}`);
|
|
197
91
|
return 1;
|
|
198
92
|
}
|
|
199
|
-
console.log('✓ callback received, exchanging code...');
|
|
200
93
|
|
|
201
|
-
//
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
411
|
-
|
|
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}`);
|
package/src/commands/manifest.ts
CHANGED
|
@@ -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>]`);
|