@3minapi/cli 0.22.2 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -6
- package/dist/cli.js +2 -0
- package/dist/commands/auth.js +393 -56
- package/dist/commands/guide.js +10 -2
- package/dist/core/api.js +12 -2
- package/dist/core/domain/contract.js +58 -0
- package/dist/core/host/session.js +1 -1
- package/dist/core/login/client-info.js +21 -0
- package/dist/core/login/device.js +56 -0
- package/dist/core/login/loopback.js +198 -0
- package/dist/core/login/pkce.js +18 -0
- package/dist/core/output.js +4 -0
- package/dist/core/redact.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,19 +20,22 @@ npm install -g @3minapi/cli
|
|
|
20
20
|
|
|
21
21
|
## Quick start
|
|
22
22
|
|
|
23
|
-
1. **
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
1. **Log in** — prints an approval link and opens it in your browser. Approve it while signed in to 3Min API;
|
|
24
|
+
the token is sent straight to this machine and saved there. Over SSH or without a display it prints a link and a
|
|
25
|
+
code to approve on another device (`--device` forces this).
|
|
26
26
|
```sh
|
|
27
27
|
3minapi login
|
|
28
28
|
```
|
|
29
|
-
|
|
29
|
+
For CI, create a host token on the dashboard
|
|
30
|
+
([3minapi.com/dashboard/tasks/local](https://3minapi.com/dashboard/tasks/local)) and pipe it in:
|
|
31
|
+
`echo "$TOKEN" | 3minapi login --with-token`.
|
|
32
|
+
2. **Create the project** (default `~/3minapi`) and a worker:
|
|
30
33
|
```sh
|
|
31
34
|
3minapi init
|
|
32
35
|
3minapi worker new hello
|
|
33
36
|
3minapi worker run hello --input '{"message":"hi"}' # runs locally, no API calls
|
|
34
37
|
```
|
|
35
|
-
|
|
38
|
+
3. **Start the host** — registers the workers and runs tasks until stopped:
|
|
36
39
|
```sh
|
|
37
40
|
3minapi start # or: 3minapi start --detach, then 3minapi stop
|
|
38
41
|
```
|
|
@@ -65,7 +68,8 @@ printed by:
|
|
|
65
68
|
3minapi guide
|
|
66
69
|
```
|
|
67
70
|
|
|
68
|
-
Every command takes `--json` for machine-readable output.
|
|
71
|
+
Every command takes `--json` for machine-readable output. An agent may run `3minapi login` and pass the printed
|
|
72
|
+
link (and code) to you to approve — the host token itself never belongs in a chat.
|
|
69
73
|
|
|
70
74
|
## Update
|
|
71
75
|
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ import { commands } from "./commands/index.js";
|
|
|
4
4
|
import { loadConfig } from "./core/config.js";
|
|
5
5
|
import { describeError, errorDocument } from "./core/error-report.js";
|
|
6
6
|
import { BIN, CliError, ExitCode, UsageError } from "./core/domain/index.js";
|
|
7
|
+
import { openBrowser } from "./core/open-browser.js";
|
|
7
8
|
import { Output } from "./core/output.js";
|
|
8
9
|
import { resolveDirs } from "./core/paths.js";
|
|
9
10
|
import { resolveProject, writeProjectRecord } from "./core/project.js";
|
|
@@ -105,6 +106,7 @@ async function dispatch(argv, io, out) {
|
|
|
105
106
|
now: io.now ?? Date.now,
|
|
106
107
|
signal: io.signal ?? new AbortController().signal,
|
|
107
108
|
claimInterrupt: () => io.claimInterrupt?.(),
|
|
109
|
+
openBrowser: io.openBrowser ?? ((url) => openBrowser(url, io.platform)),
|
|
108
110
|
chdir: io.chdir ?? ((dir) => process.chdir(dir)),
|
|
109
111
|
loadConfig: () => {
|
|
110
112
|
const config = loadConfig({
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import { statSync } from 'node:fs';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
2
3
|
import path from 'node:path';
|
|
3
|
-
import { deleteHostToken, isHostToken, resolveHostToken, saveHostToken } from "../core/credentials.js";
|
|
4
|
-
import { BIN, ENV, ExitCode, HOST_TOKEN_PREFIX, LOCAL_SERVER_TOKEN_FILE, MONTH_MS, StopError, UsageError, builtinResultsName, isReservedWorkerName } from "../core/domain/index.js";
|
|
4
|
+
import { credentialsPath, deleteHostToken, isHostToken, resolveHostToken, saveHostToken } from "../core/credentials.js";
|
|
5
|
+
import { BIN, CODE_CHALLENGE_METHOD, CliError, ConfigError, ENV, ExitCode, HOST_TOKEN_PREFIX, LOGIN_REQUEST_EXPIRES_S, LOCAL_SERVER_TOKEN_FILE, MONTH_MS, StopError, UsageError, builtinResultsName, isOpenableLoginUrl, isReservedWorkerName, loginResultUrl, normalizeUserCode } from "../core/domain/index.js";
|
|
6
|
+
import { chooseFlow, loginClient } from "../core/login/client-info.js";
|
|
7
|
+
import { LOGIN_DENIED_MESSAGE, LOGIN_EXPIRED_MESSAGE, pollDeviceToken } from "../core/login/device.js";
|
|
8
|
+
import { LoopbackUnavailable, startCallbackServer } from "../core/login/loopback.js";
|
|
9
|
+
import { createPkce, createState } from "../core/login/pkce.js";
|
|
5
10
|
import { readAllStdin, readSecret } from "../core/prompt.js";
|
|
6
11
|
import { readRunner, runnerMode } from "../core/host/runner-lock.js";
|
|
7
12
|
import { readDashboard } from "../modules/dashboard/lock.js";
|
|
@@ -15,9 +20,9 @@ import { HOST_META, cacheHostIdentity, hostChangedWarning } from "../modules/wor
|
|
|
15
20
|
import { ResultStore, resultsDbBytes, resultsDbPath } from "../modules/workers/results/store.js";
|
|
16
21
|
import { workerFiles } from "../modules/workers/workers.js";
|
|
17
22
|
import { parseSettings } from "../modules/workers/repeat-scheduler.js";
|
|
18
|
-
import { ApiError } from "../core/api.js";
|
|
23
|
+
import { ApiClient, ApiError, userAgent } from "../core/api.js";
|
|
19
24
|
import { client, noticeUpdate, workersPath } from "./common.js";
|
|
20
|
-
import { readLatest, updateInfo } from "../core/update-notice.js";
|
|
25
|
+
import { readLatest, rememberLatest, updateInfo } from "../core/update-notice.js";
|
|
21
26
|
function nextAfterLogin(ctx, config) {
|
|
22
27
|
const root = config.projectRoot;
|
|
23
28
|
if (!root) {
|
|
@@ -33,77 +38,409 @@ function nextAfterLogin(ctx, config) {
|
|
|
33
38
|
}
|
|
34
39
|
export const login = {
|
|
35
40
|
name: 'login',
|
|
36
|
-
summary: '
|
|
37
|
-
usage: '[--with-token]',
|
|
38
|
-
description: '
|
|
39
|
-
'
|
|
41
|
+
summary: 'Log this PC in to 3Min API by approving it in the browser',
|
|
42
|
+
usage: '[--device | --loopback] [--no-browser] [--force] [--paste | --with-token]',
|
|
43
|
+
description: 'Prints an approval link and waits. Approve it in a browser where you are signed in to 3Min API;\n' +
|
|
44
|
+
'the host token is then sent straight to this PC and saved — it never appears on screen.\n' +
|
|
45
|
+
'On this PC the link opens in the browser (loopback). Over SSH, on Linux without a display, or with\n' +
|
|
46
|
+
'--device, it prints a link and a code to approve on another device (phone, laptop) instead;\n' +
|
|
47
|
+
'--loopback keeps the same-PC flow there (e.g. a remote shell that forwards the callback port).\n' +
|
|
48
|
+
'If this PC is already logged in, nothing changes; --force approves again (the token is rotated).\n' +
|
|
49
|
+
'A token issued on the dashboard still works: --paste prompts for it, --with-token reads it from\n' +
|
|
50
|
+
'stdin, e.g. in CI: echo "$TOKEN" | 3minapi login --with-token\n' +
|
|
40
51
|
'The token is checked with the server (one API call) before it is saved.',
|
|
41
52
|
options: {
|
|
42
|
-
|
|
53
|
+
device: {
|
|
54
|
+
type: 'boolean',
|
|
55
|
+
description: 'Approve on another device with a code (no browser on this PC)'
|
|
56
|
+
},
|
|
57
|
+
loopback: {
|
|
58
|
+
type: 'boolean',
|
|
59
|
+
description: 'Use the same-PC flow even over SSH (the callback port must reach your browser)'
|
|
60
|
+
},
|
|
61
|
+
browser: {
|
|
62
|
+
type: 'boolean',
|
|
63
|
+
negated: true,
|
|
64
|
+
description: 'Print the approval link without opening a browser'
|
|
65
|
+
},
|
|
66
|
+
force: { type: 'boolean', description: 'Approve again even if this PC is already logged in' },
|
|
67
|
+
paste: { type: 'boolean', description: 'Paste a dashboard-issued token at a masked prompt' },
|
|
68
|
+
'with-token': { type: 'boolean', description: 'Read a dashboard-issued token from stdin' }
|
|
43
69
|
},
|
|
44
70
|
args: { min: 0, max: 0 },
|
|
45
71
|
async run(ctx) {
|
|
46
|
-
const {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
52
|
-
token = (await readAllStdin(ctx.stdin)).trim();
|
|
72
|
+
const { values } = ctx;
|
|
73
|
+
const manual = [values['with-token'], values.paste].filter(Boolean).length;
|
|
74
|
+
const browserOnly = values.device || values.loopback || values.browser === false || values.force;
|
|
75
|
+
if (manual > 1 || (manual === 1 && browserOnly)) {
|
|
76
|
+
throw new UsageError('--with-token and --paste take a token instead of browser approval — use one of them, without --device, --loopback, --no-browser or --force');
|
|
53
77
|
}
|
|
54
|
-
|
|
55
|
-
|
|
78
|
+
if (values.device && values.loopback) {
|
|
79
|
+
throw new UsageError('--device and --loopback choose opposite flows — use one of them');
|
|
56
80
|
}
|
|
57
|
-
if (
|
|
58
|
-
throw new UsageError(
|
|
81
|
+
if (values.force && envTokenSet(ctx)) {
|
|
82
|
+
throw new UsageError(`${ENV.token} is set — approving again would revoke the token it holds. Unset it first.`);
|
|
59
83
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
84
|
+
if (values['with-token'] || values.paste) {
|
|
85
|
+
const token = await readManualToken(ctx);
|
|
86
|
+
const session = openSession(ctx);
|
|
63
87
|
try {
|
|
64
|
-
|
|
88
|
+
return await saveVerifiedToken(ctx, session, token, null);
|
|
65
89
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
session.logger.warn('login rejected', { reason: err.reason });
|
|
69
|
-
throw new StopError(err.reason, `${err.message}\n${stopHint(err.reason)}`, err.resetAt);
|
|
70
|
-
}
|
|
71
|
-
throw err;
|
|
90
|
+
finally {
|
|
91
|
+
session.close();
|
|
72
92
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
session.logger.warn('host changed', { previous_host_id: previous, host_id: host.host.id });
|
|
93
|
+
}
|
|
94
|
+
const session = openSession(ctx);
|
|
95
|
+
try {
|
|
96
|
+
if (!values.force) {
|
|
97
|
+
const done = await alreadyLoggedIn(ctx, session);
|
|
98
|
+
if (done !== null)
|
|
99
|
+
return done;
|
|
81
100
|
}
|
|
82
|
-
|
|
83
|
-
|
|
101
|
+
ctx.claimInterrupt();
|
|
102
|
+
const { issued, replyBrowser } = await browserLogin(ctx, session);
|
|
103
|
+
try {
|
|
104
|
+
return await saveVerifiedToken(ctx, session, issued.token, issued, () => replyBrowser('ok'));
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
replyBrowser('failed');
|
|
84
108
|
}
|
|
85
|
-
const projectRoot = session.config.projectRoot;
|
|
86
|
-
out.result({
|
|
87
|
-
host: host.host,
|
|
88
|
-
workers: host.workers,
|
|
89
|
-
quota: host.quota,
|
|
90
|
-
credentials: file,
|
|
91
|
-
previousHostId: previous,
|
|
92
|
-
projectRoot
|
|
93
|
-
}, [
|
|
94
|
-
`${out.out.green('logged in')} ${stripControl(host.host.name)}`,
|
|
95
|
-
`token saved to ${file}`,
|
|
96
|
-
quotaLine(out, host, ctx.now()),
|
|
97
|
-
'',
|
|
98
|
-
nextAfterLogin(ctx, session.config)
|
|
99
|
-
].join('\n'));
|
|
100
|
-
return ExitCode.Ok;
|
|
101
109
|
}
|
|
102
110
|
finally {
|
|
103
111
|
session.close();
|
|
104
112
|
}
|
|
105
113
|
}
|
|
106
114
|
};
|
|
115
|
+
async function readManualToken(ctx) {
|
|
116
|
+
let token;
|
|
117
|
+
if (ctx.values['with-token']) {
|
|
118
|
+
if (ctx.stdin.isTTY) {
|
|
119
|
+
throw new UsageError(`--with-token reads the token from stdin. Pipe it in: echo "$TOKEN" | ${BIN} login --with-token`);
|
|
120
|
+
}
|
|
121
|
+
token = (await readAllStdin(ctx.stdin)).trim();
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
token = (await readSecret(ctx.stdin, ctx.out, `Paste your host token (${HOST_TOKEN_PREFIX}...): `)).trim();
|
|
125
|
+
}
|
|
126
|
+
if (!isHostToken(token)) {
|
|
127
|
+
throw new UsageError(`that is not a host token (expected ${HOST_TOKEN_PREFIX} followed by 48 hex characters)`);
|
|
128
|
+
}
|
|
129
|
+
return token;
|
|
130
|
+
}
|
|
131
|
+
async function alreadyLoggedIn(ctx, session) {
|
|
132
|
+
let token;
|
|
133
|
+
try {
|
|
134
|
+
token = resolveHostToken(ctx.dirs.config, ctx.env);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (envTokenSet(ctx) || !(err instanceof ConfigError))
|
|
138
|
+
throw err;
|
|
139
|
+
ctx.out.warn(`the saved token in ${credentialsPath(ctx.dirs.config)} cannot be used — approving replaces it.`);
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (!token)
|
|
143
|
+
return null;
|
|
144
|
+
let host;
|
|
145
|
+
try {
|
|
146
|
+
host = await client(ctx, session, token.token).getHost();
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
if (err instanceof StopError) {
|
|
150
|
+
if (err.reason === 'host_token_invalid' && token.source === 'env') {
|
|
151
|
+
throw new StopError(err.reason, `${err.message}\nThe token comes from ${ENV.token}, which overrides a saved login. Unset it (then run '${BIN} login') or set a valid token.`);
|
|
152
|
+
}
|
|
153
|
+
if (err.reason === 'host_token_invalid')
|
|
154
|
+
return null;
|
|
155
|
+
recordStop(session, err, ctx.now());
|
|
156
|
+
throw new StopError(err.reason, `${err.message}\n${stopHint(err.reason)}`, err.resetAt);
|
|
157
|
+
}
|
|
158
|
+
throw err;
|
|
159
|
+
}
|
|
160
|
+
clearStop(session);
|
|
161
|
+
const previous = cacheHostIdentity(session.db, token.token, host.host, ctx.now());
|
|
162
|
+
rememberQuota(session, host.quota, ctx.now());
|
|
163
|
+
if (previous !== null)
|
|
164
|
+
ctx.out.warn(hostChangedWarning(previous, host.host));
|
|
165
|
+
const fromEnv = token.source === 'env';
|
|
166
|
+
ctx.out.result({
|
|
167
|
+
alreadyLoggedIn: true,
|
|
168
|
+
confirmed: true,
|
|
169
|
+
host: host.host,
|
|
170
|
+
workers: host.workers,
|
|
171
|
+
quota: host.quota,
|
|
172
|
+
credentials: token.source === 'file' ? token.location : null,
|
|
173
|
+
previousHostId: previous,
|
|
174
|
+
projectRoot: session.config.projectRoot,
|
|
175
|
+
tokenSource: token.source
|
|
176
|
+
}, [
|
|
177
|
+
`${ctx.out.out.green('already logged in')} ${stripControl(host.host.name)}${fromEnv ? ctx.out.out.dim(` (token from ${ENV.token})`) : ''}`,
|
|
178
|
+
quotaLine(ctx.out, host, ctx.now()),
|
|
179
|
+
ctx.out.out.dim(`To log in again (the token is rotated): ${BIN} login --force`)
|
|
180
|
+
].join('\n'));
|
|
181
|
+
return ExitCode.Ok;
|
|
182
|
+
}
|
|
183
|
+
function envTokenSet(ctx) {
|
|
184
|
+
return (ctx.env[ENV.token] ?? '') !== '';
|
|
185
|
+
}
|
|
186
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
187
|
+
const NO_BROWSER_REPLY = () => { };
|
|
188
|
+
async function browserLogin(ctx, session) {
|
|
189
|
+
const apiUrl = session.config.values.apiUrl;
|
|
190
|
+
const api = new ApiClient({
|
|
191
|
+
apiUrl,
|
|
192
|
+
token: '',
|
|
193
|
+
userAgent: userAgent(ctx.version, ctx.platform),
|
|
194
|
+
signal: ctx.signal,
|
|
195
|
+
onServerCli: (cli) => rememberLatest(session.db, cli, ctx.now(), (m, a) => session.logger.debug(m, a))
|
|
196
|
+
});
|
|
197
|
+
const info = loginClient(hostname(), ctx.platform, process.arch, ctx.version);
|
|
198
|
+
let flow = chooseFlow({ device: ctx.values.device === true, loopback: ctx.values.loopback === true }, ctx.env, ctx.platform);
|
|
199
|
+
session.logger.info('login started', { flow });
|
|
200
|
+
try {
|
|
201
|
+
if (flow === 'loopback') {
|
|
202
|
+
try {
|
|
203
|
+
return await loopbackLogin(ctx, session, api, info, apiUrl);
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
if (!(err instanceof LoopbackUnavailable))
|
|
207
|
+
throw err;
|
|
208
|
+
ctx.out.warn(`${err.message} — approving on another device instead.`);
|
|
209
|
+
flow = 'device';
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return { issued: await deviceLogin(ctx, api, info, apiUrl), replyBrowser: NO_BROWSER_REPLY };
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
throw loginError(err, ctx.signal);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async function loopbackLogin(ctx, session, api, info, apiUrl) {
|
|
219
|
+
const pkce = createPkce();
|
|
220
|
+
const state = createState();
|
|
221
|
+
const server = await startCallbackServer({
|
|
222
|
+
state,
|
|
223
|
+
timeoutMs: LOGIN_REQUEST_EXPIRES_S * 1000,
|
|
224
|
+
signal: ctx.signal
|
|
225
|
+
});
|
|
226
|
+
let resultUrl = null;
|
|
227
|
+
try {
|
|
228
|
+
const previous = cachedHostId(session);
|
|
229
|
+
const started = await api.loginAuthorize({
|
|
230
|
+
client: info,
|
|
231
|
+
...(previous ? { previous_host_id: previous } : {}),
|
|
232
|
+
redirect_port: server.port,
|
|
233
|
+
state,
|
|
234
|
+
code_challenge: pkce.challenge,
|
|
235
|
+
code_challenge_method: CODE_CHALLENGE_METHOD
|
|
236
|
+
});
|
|
237
|
+
const url = openableUrl(started.authorize_url, apiUrl);
|
|
238
|
+
resultUrl = loginResultUrl(started.result_url, url, apiUrl);
|
|
239
|
+
if (started.result_url !== undefined && resultUrl === null) {
|
|
240
|
+
session.logger.debug('login result page not used', {});
|
|
241
|
+
}
|
|
242
|
+
ctx.out.event({
|
|
243
|
+
event: 'login_pending',
|
|
244
|
+
flow: 'loopback',
|
|
245
|
+
url,
|
|
246
|
+
expires_in: started.expires_in
|
|
247
|
+
});
|
|
248
|
+
ctx.out.info([
|
|
249
|
+
'Approve this PC in your browser:',
|
|
250
|
+
` ${url}`,
|
|
251
|
+
waitingLine(ctx, started.expires_in),
|
|
252
|
+
ctx.out.err.dim(`Browser on another device? Press Ctrl+C and run '${BIN} login --device'.`)
|
|
253
|
+
].join('\n'));
|
|
254
|
+
if (ctx.values.browser !== false && !(await ctx.openBrowser(url))) {
|
|
255
|
+
ctx.out.warn(`could not open a browser — open the link above yourself. If it never returns here, run '${BIN} login --device'.`);
|
|
256
|
+
}
|
|
257
|
+
const callback = await server.result;
|
|
258
|
+
if (callback.kind === 'denied') {
|
|
259
|
+
server.reply('denied', resultUrl);
|
|
260
|
+
throw new CliError(LOGIN_DENIED_MESSAGE);
|
|
261
|
+
}
|
|
262
|
+
const issued = issuedToken(await api.loginToken({
|
|
263
|
+
grant_type: 'authorization_code',
|
|
264
|
+
code: callback.code,
|
|
265
|
+
code_verifier: pkce.verifier
|
|
266
|
+
}));
|
|
267
|
+
return { issued, replyBrowser: (outcome) => server.reply(outcome, resultUrl) };
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
server.reply('failed', resultUrl);
|
|
271
|
+
server.close();
|
|
272
|
+
throw err;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function deviceLogin(ctx, api, info, apiUrl) {
|
|
276
|
+
const started = await api.loginDevice({ client: info });
|
|
277
|
+
const base = openableUrl(started.verification_uri, apiUrl);
|
|
278
|
+
const complete = started.verification_uri_complete === undefined
|
|
279
|
+
? null
|
|
280
|
+
: openableUrl(started.verification_uri_complete, apiUrl);
|
|
281
|
+
const userCode = typeof started.user_code === 'string' ? normalizeUserCode(started.user_code) : null;
|
|
282
|
+
const deviceCode = started.device_code;
|
|
283
|
+
if (userCode === null || typeof deviceCode !== 'string' || deviceCode === '') {
|
|
284
|
+
throw new CliError(UNEXPECTED_RESPONSE);
|
|
285
|
+
}
|
|
286
|
+
ctx.out.event({
|
|
287
|
+
event: 'login_pending',
|
|
288
|
+
flow: 'device',
|
|
289
|
+
url: complete ?? base,
|
|
290
|
+
verification_uri: base,
|
|
291
|
+
user_code: userCode,
|
|
292
|
+
expires_in: started.expires_in
|
|
293
|
+
});
|
|
294
|
+
ctx.out.info([
|
|
295
|
+
'On a device where you are signed in to 3Min API (phone, laptop), open:',
|
|
296
|
+
` ${complete ?? base}`,
|
|
297
|
+
complete === null ? 'and enter the code:' : `or go to ${base} and enter the code:`,
|
|
298
|
+
` ${ctx.out.err.bold(userCode)}`,
|
|
299
|
+
waitingLine(ctx, started.expires_in)
|
|
300
|
+
].join('\n'));
|
|
301
|
+
return issuedToken(await pollDeviceToken({
|
|
302
|
+
exchange: () => api.loginToken({ grant_type: 'device_code', device_code: deviceCode }),
|
|
303
|
+
intervalS: started.interval,
|
|
304
|
+
expiresInS: started.expires_in,
|
|
305
|
+
signal: ctx.signal
|
|
306
|
+
}));
|
|
307
|
+
}
|
|
308
|
+
const UNEXPECTED_RESPONSE = 'the server sent a login response this CLI does not understand. Update the CLI, or log in with --paste.';
|
|
309
|
+
function issuedToken(res) {
|
|
310
|
+
const host = res?.host;
|
|
311
|
+
if (typeof res?.token !== 'string' ||
|
|
312
|
+
typeof host?.id !== 'string' ||
|
|
313
|
+
typeof host.name !== 'string') {
|
|
314
|
+
throw new CliError(UNEXPECTED_RESPONSE);
|
|
315
|
+
}
|
|
316
|
+
return {
|
|
317
|
+
token: res.token,
|
|
318
|
+
created: res.created === true,
|
|
319
|
+
host: { id: host.id, name: host.name }
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
function waitingLine(ctx, expiresInS) {
|
|
323
|
+
const seconds = typeof expiresInS === 'number' && Number.isFinite(expiresInS) && expiresInS > 0
|
|
324
|
+
? Math.min(expiresInS, LOGIN_REQUEST_EXPIRES_S)
|
|
325
|
+
: LOGIN_REQUEST_EXPIRES_S;
|
|
326
|
+
const minutes = Math.max(1, Math.round(seconds / 60));
|
|
327
|
+
return ctx.out.err.dim(`Waiting for approval (expires in ${minutes} min). Approve only if you ran this command yourself.`);
|
|
328
|
+
}
|
|
329
|
+
function openableUrl(url, apiUrl) {
|
|
330
|
+
const href = isOpenableLoginUrl(url, apiUrl);
|
|
331
|
+
if (href === null) {
|
|
332
|
+
throw new CliError('the server returned an approval link this CLI will not open. Check the API URL, or log in with --paste.');
|
|
333
|
+
}
|
|
334
|
+
return href;
|
|
335
|
+
}
|
|
336
|
+
function cachedHostId(session) {
|
|
337
|
+
try {
|
|
338
|
+
const id = session.db.getMeta(HOST_META.id);
|
|
339
|
+
return id !== null && UUID.test(id) ? id.toLowerCase() : null;
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
function loginError(err, signal) {
|
|
346
|
+
if (signal.aborted && !(err instanceof StopError)) {
|
|
347
|
+
return new CliError('login cancelled.', ExitCode.Interrupted);
|
|
348
|
+
}
|
|
349
|
+
if (!(err instanceof ApiError))
|
|
350
|
+
return err;
|
|
351
|
+
switch (err.code) {
|
|
352
|
+
case 'access_denied':
|
|
353
|
+
return new CliError(LOGIN_DENIED_MESSAGE);
|
|
354
|
+
case 'expired_token':
|
|
355
|
+
return new CliError(LOGIN_EXPIRED_MESSAGE);
|
|
356
|
+
case 'invalid_grant':
|
|
357
|
+
return new CliError('the approval was not accepted (expired or already used). Run login again.');
|
|
358
|
+
case 'device_rate_limited':
|
|
359
|
+
return new CliError('too many login requests from this network. Try again in a few minutes.');
|
|
360
|
+
case 'service_unavailable':
|
|
361
|
+
return new CliError(`${err.message}\nRun login again in a moment.`);
|
|
362
|
+
}
|
|
363
|
+
if (err.status === 404 && err.code === null) {
|
|
364
|
+
return new CliError(`this server does not support browser login. Log in with a token from the dashboard instead: ${BIN} login --paste`);
|
|
365
|
+
}
|
|
366
|
+
return err;
|
|
367
|
+
}
|
|
368
|
+
async function saveVerifiedToken(ctx, session, token, issued, onSaved = () => { }) {
|
|
369
|
+
const { out, env } = ctx;
|
|
370
|
+
if (!isHostToken(token))
|
|
371
|
+
throw new CliError('the server returned a token in an unexpected format.');
|
|
372
|
+
let host = null;
|
|
373
|
+
let unconfirmed = null;
|
|
374
|
+
try {
|
|
375
|
+
host = await client(ctx, session, token).getHost();
|
|
376
|
+
}
|
|
377
|
+
catch (err) {
|
|
378
|
+
const rejected = err instanceof StopError && err.reason === 'host_token_invalid';
|
|
379
|
+
if (issued === null || rejected) {
|
|
380
|
+
if (err instanceof StopError) {
|
|
381
|
+
session.logger.warn('login rejected', { reason: err.reason });
|
|
382
|
+
throw new StopError(err.reason, `${err.message}\n${stopHint(err.reason)}`, err.resetAt);
|
|
383
|
+
}
|
|
384
|
+
throw err;
|
|
385
|
+
}
|
|
386
|
+
unconfirmed = err;
|
|
387
|
+
}
|
|
388
|
+
const file = saveHostToken(ctx.dirs.config, token, ctx.now());
|
|
389
|
+
onSaved();
|
|
390
|
+
const identity = host?.host ?? issued.host;
|
|
391
|
+
const previous = cacheHostIdentity(session.db, token, identity, ctx.now());
|
|
392
|
+
if (host) {
|
|
393
|
+
clearStop(session);
|
|
394
|
+
rememberQuota(session, host.quota, ctx.now());
|
|
395
|
+
}
|
|
396
|
+
const created = issued?.created ?? null;
|
|
397
|
+
session.logger.info('logged in', {
|
|
398
|
+
host_id: identity.id,
|
|
399
|
+
host_name: identity.name,
|
|
400
|
+
confirmed: host !== null,
|
|
401
|
+
...(created === null ? {} : { created })
|
|
402
|
+
});
|
|
403
|
+
if (previous !== null) {
|
|
404
|
+
out.warn(hostChangedWarning(previous, identity));
|
|
405
|
+
session.logger.warn('host changed', { previous_host_id: previous, host_id: identity.id });
|
|
406
|
+
}
|
|
407
|
+
if (env[ENV.token]) {
|
|
408
|
+
out.warn(`${ENV.token} is set and takes precedence over the saved token.`);
|
|
409
|
+
}
|
|
410
|
+
if (unconfirmed !== null) {
|
|
411
|
+
const saved = `logged in as ${stripControl(identity.name)} — token saved to ${file}`;
|
|
412
|
+
if (unconfirmed instanceof StopError) {
|
|
413
|
+
recordStop(session, unconfirmed, ctx.now());
|
|
414
|
+
throw new StopError(unconfirmed.reason, `${saved}, but ${unconfirmed.message}\n${stopHint(unconfirmed.reason)}`, unconfirmed.resetAt);
|
|
415
|
+
}
|
|
416
|
+
if (ctx.signal.aborted) {
|
|
417
|
+
out.warn(`${saved}; the check was cancelled. Run '${BIN} status' to confirm it.`);
|
|
418
|
+
return ExitCode.Interrupted;
|
|
419
|
+
}
|
|
420
|
+
out.warn(`${saved}, but it could not be checked: ${unconfirmed.message}\nRun '${BIN} status' to confirm it.`);
|
|
421
|
+
}
|
|
422
|
+
const projectRoot = session.config.projectRoot;
|
|
423
|
+
const how = created === null ? '' : out.out.dim(created ? ' (new host)' : ' (token rotated)');
|
|
424
|
+
out.result({
|
|
425
|
+
alreadyLoggedIn: false,
|
|
426
|
+
confirmed: host !== null,
|
|
427
|
+
tokenSource: 'file',
|
|
428
|
+
host: identity,
|
|
429
|
+
workers: host?.workers ?? null,
|
|
430
|
+
quota: host?.quota ?? null,
|
|
431
|
+
credentials: file,
|
|
432
|
+
previousHostId: previous,
|
|
433
|
+
projectRoot,
|
|
434
|
+
...(created === null ? {} : { created })
|
|
435
|
+
}, [
|
|
436
|
+
`${out.out.green('logged in')} ${stripControl(identity.name)}${how}`,
|
|
437
|
+
`token saved to ${file}`,
|
|
438
|
+
...(host ? [quotaLine(out, host, ctx.now())] : []),
|
|
439
|
+
'',
|
|
440
|
+
nextAfterLogin(ctx, session.config)
|
|
441
|
+
].join('\n'));
|
|
442
|
+
return ExitCode.Ok;
|
|
443
|
+
}
|
|
107
444
|
export const logout = {
|
|
108
445
|
name: 'logout',
|
|
109
446
|
summary: 'Remove the saved host token from this machine',
|
package/dist/commands/guide.js
CHANGED
|
@@ -21,6 +21,14 @@ those workers. Every command below works from any folder and takes \`--json\` fo
|
|
|
21
21
|
- **\`package.json\`** — only when a worker needs a library. \`npm init -y\` writes \`"type": "commonjs"\`, which
|
|
22
22
|
breaks \`.js\`/\`.ts\` workers that use \`export\`: keep workers as \`.mjs\`/\`.mts\`, or set \`"type": "module"\`.
|
|
23
23
|
|
|
24
|
+
## Log in
|
|
25
|
+
|
|
26
|
+
Run \`${BIN} login\` yourself when \`${BIN} status\` says this machine is not logged in. It prints an approval link
|
|
27
|
+
(over SSH or without a display, a link and a code for another device) and waits up to 15 minutes. Pass the link — and
|
|
28
|
+
the code, if there is one — to the user exactly as printed, ask them to approve it in their browser, and keep the
|
|
29
|
+
command running until it exits. If the machine is already logged in it exits at once without changing anything.
|
|
30
|
+
With \`--json\`, the first line (\`"event":"login_pending"\`) carries the link before the result.
|
|
31
|
+
|
|
24
32
|
## Run
|
|
25
33
|
|
|
26
34
|
| Command | Use |
|
|
@@ -41,8 +49,8 @@ Handler code and \`${DOTENV_FILE}\` edits apply to the next task with no reload.
|
|
|
41
49
|
|
|
42
50
|
## Do not touch
|
|
43
51
|
|
|
44
|
-
- **\`${CREDENTIALS_FILE}\`** (the host token) — never read, print or copy it.
|
|
45
|
-
|
|
52
|
+
- **\`${CREDENTIALS_FILE}\`** (the host token) — never read, print or copy it. \`${BIN} login\` saves it after the
|
|
53
|
+
user approves in the browser; never ask for the token in the chat.
|
|
46
54
|
- **\`${CONTROL_DB_FILE}\`, \`runner.*\`, \`${PROJECT_RECORD_FILE}\`** — internal state. Use \`${BIN} tasks prune\` to clear history.
|
|
47
55
|
- **\`${LOCAL_SERVER_TOKEN_FILE}\`** — only for a local script that calls \`${BIN} start --serve\`.
|
|
48
56
|
- **Tables starting with \`${RESULT_TABLE_PREFIX}\` in \`${USER_DB_FILE}\`** — read them, never write them.
|
package/dist/core/api.js
CHANGED
|
@@ -65,17 +65,27 @@ export class ApiClient {
|
|
|
65
65
|
}
|
|
66
66
|
return this.request('POST', target.href, body);
|
|
67
67
|
}
|
|
68
|
-
|
|
68
|
+
loginAuthorize(body) {
|
|
69
|
+
return this.request('POST', this.opts.apiUrl + '/api/v1/cli/authorize', body, false);
|
|
70
|
+
}
|
|
71
|
+
loginDevice(body) {
|
|
72
|
+
return this.request('POST', this.opts.apiUrl + '/api/v1/cli/device', body, false);
|
|
73
|
+
}
|
|
74
|
+
loginToken(body) {
|
|
75
|
+
return this.request('POST', this.opts.apiUrl + '/api/v1/cli/token', body, false);
|
|
76
|
+
}
|
|
77
|
+
async request(method, url, payload, auth = true) {
|
|
69
78
|
if (this.stop)
|
|
70
79
|
throw this.stop;
|
|
71
80
|
const signals = [AbortSignal.timeout(API_TIMEOUT_MS)];
|
|
72
81
|
if (this.opts.signal)
|
|
73
82
|
signals.push(this.opts.signal);
|
|
74
83
|
const headers = {
|
|
75
|
-
authorization: `Bearer ${this.opts.token}`,
|
|
76
84
|
accept: 'application/json',
|
|
77
85
|
'user-agent': this.opts.userAgent
|
|
78
86
|
};
|
|
87
|
+
if (auth)
|
|
88
|
+
headers.authorization = `Bearer ${this.opts.token}`;
|
|
79
89
|
if (payload !== undefined)
|
|
80
90
|
headers['content-type'] = 'application/json';
|
|
81
91
|
let res;
|
|
@@ -13,6 +13,14 @@ export const OTHER_CODES = [
|
|
|
13
13
|
'unsupported_media_type',
|
|
14
14
|
'service_unavailable'
|
|
15
15
|
];
|
|
16
|
+
export const LOGIN_CODES = [
|
|
17
|
+
'authorization_pending',
|
|
18
|
+
'slow_down',
|
|
19
|
+
'access_denied',
|
|
20
|
+
'expired_token',
|
|
21
|
+
'invalid_grant',
|
|
22
|
+
'device_rate_limited'
|
|
23
|
+
];
|
|
16
24
|
export function isStopCode(code) {
|
|
17
25
|
return typeof code === 'string' && STOP_CODES.includes(code);
|
|
18
26
|
}
|
|
@@ -76,6 +84,56 @@ export function isTrustedLiveUrl(liveUrl, apiUrl) {
|
|
|
76
84
|
live.username === '' &&
|
|
77
85
|
live.password === '');
|
|
78
86
|
}
|
|
87
|
+
export const LOGIN_REQUEST_EXPIRES_S = 900;
|
|
88
|
+
export const DEVICE_POLL_INTERVAL_S = 5;
|
|
89
|
+
export const DEVICE_SLOW_DOWN_STEP_S = 5;
|
|
90
|
+
export const DEVICE_POLL_INTERVAL_MAX_S = 60;
|
|
91
|
+
export const LOOPBACK_REDIRECT_HOST = '127.0.0.1';
|
|
92
|
+
export const LOOPBACK_CALLBACK_PATH = '/callback';
|
|
93
|
+
export const CODE_CHALLENGE_METHOD = 'S256';
|
|
94
|
+
export const USER_CODE_ALPHABET = 'BCDFGHJKLMNPQRSTVWXZ';
|
|
95
|
+
export const USER_CODE_PATTERN = /^[BCDFGHJKLMNPQRSTVWXZ]{4}-[BCDFGHJKLMNPQRSTVWXZ]{4}$/;
|
|
96
|
+
export function normalizeUserCode(input) {
|
|
97
|
+
const raw = input.toUpperCase().replace(/[\s-]/g, '');
|
|
98
|
+
if (raw.length !== 8)
|
|
99
|
+
return null;
|
|
100
|
+
const code = `${raw.slice(0, 4)}-${raw.slice(4)}`;
|
|
101
|
+
return USER_CODE_PATTERN.test(code) ? code : null;
|
|
102
|
+
}
|
|
103
|
+
export function isOpenableLoginUrl(url, apiUrl) {
|
|
104
|
+
if (typeof url !== 'string')
|
|
105
|
+
return null;
|
|
106
|
+
let parsed;
|
|
107
|
+
let api;
|
|
108
|
+
try {
|
|
109
|
+
parsed = new URL(url);
|
|
110
|
+
api = new URL(apiUrl);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
const secure = parsed.protocol === 'https:';
|
|
116
|
+
const loopback = parsed.protocol === 'http:' &&
|
|
117
|
+
api.protocol === 'http:' &&
|
|
118
|
+
LOOPBACK_HOSTNAMES.includes(api.hostname);
|
|
119
|
+
if (!secure && !loopback)
|
|
120
|
+
return null;
|
|
121
|
+
if (parsed.username !== '' || parsed.password !== '')
|
|
122
|
+
return null;
|
|
123
|
+
return LOGIN_URL_SAFE_CHARS.test(parsed.href) ? parsed.href : null;
|
|
124
|
+
}
|
|
125
|
+
export function loginResultUrl(resultUrl, authorizeUrl, apiUrl) {
|
|
126
|
+
const href = isOpenableLoginUrl(resultUrl, apiUrl);
|
|
127
|
+
if (href === null)
|
|
128
|
+
return null;
|
|
129
|
+
const parsed = new URL(href);
|
|
130
|
+
if (parsed.origin !== new URL(authorizeUrl).origin)
|
|
131
|
+
return null;
|
|
132
|
+
if (/[?#]/.test(href))
|
|
133
|
+
return null;
|
|
134
|
+
return href;
|
|
135
|
+
}
|
|
136
|
+
export const LOGIN_URL_SAFE_CHARS = /^[A-Za-z0-9\-._~:/?#@&=+,;]+$/;
|
|
79
137
|
export const SYNC_WORKERS_MAX = 200;
|
|
80
138
|
export const WORKER_DESCRIPTION_MAX_CHARS = 500;
|
|
81
139
|
export const PULL_WORKER_NAME = /^[a-z0-9_-]{1,50}$/;
|
|
@@ -73,7 +73,7 @@ export function rememberQuota(session, quota, now) {
|
|
|
73
73
|
export function stopHint(reason) {
|
|
74
74
|
switch (reason) {
|
|
75
75
|
case 'host_token_invalid':
|
|
76
|
-
return "The host token was revoked or the host was deleted.
|
|
76
|
+
return "The host token was revoked or the host was deleted. Run '3minapi login' to approve this PC again (if THREEMINAPI_TOKEN is set, unset it or set a valid token).";
|
|
77
77
|
case 'no_subscription':
|
|
78
78
|
return 'There is no active subscription for this account. Subscribe on the dashboard, then run the command again.';
|
|
79
79
|
case 'quota_exhausted':
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const NAME_MAX = 50;
|
|
2
|
+
const FALLBACK_NAME = '3minapi-host';
|
|
3
|
+
export function loginClient(hostname, platform, arch, version) {
|
|
4
|
+
const trimmed = [...hostname.trim()].slice(0, NAME_MAX).join('').trim();
|
|
5
|
+
return {
|
|
6
|
+
name: trimmed === '' ? FALLBACK_NAME : trimmed,
|
|
7
|
+
platform: `${platform}-${arch}`,
|
|
8
|
+
cli_version: version
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export function chooseFlow(choice, env, platform) {
|
|
12
|
+
if (choice.device)
|
|
13
|
+
return 'device';
|
|
14
|
+
if (choice.loopback)
|
|
15
|
+
return 'loopback';
|
|
16
|
+
if (env.SSH_CONNECTION)
|
|
17
|
+
return 'device';
|
|
18
|
+
if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY)
|
|
19
|
+
return 'device';
|
|
20
|
+
return 'loopback';
|
|
21
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { setTimeout as sleepFor } from 'node:timers/promises';
|
|
2
|
+
import { ApiError } from "../api.js";
|
|
3
|
+
import { CliError, DEVICE_POLL_INTERVAL_MAX_S, DEVICE_POLL_INTERVAL_S, DEVICE_SLOW_DOWN_STEP_S, ExitCode, LOGIN_REQUEST_EXPIRES_S } from "../domain/index.js";
|
|
4
|
+
export const LOGIN_EXPIRED_MESSAGE = 'the login request expired before it was approved. Run login again.';
|
|
5
|
+
export const LOGIN_DENIED_MESSAGE = 'the login request was denied in the browser. Nothing was changed.';
|
|
6
|
+
export async function pollDeviceToken(opts) {
|
|
7
|
+
const now = opts.now ?? Date.now;
|
|
8
|
+
const sleep = opts.sleep ?? ((ms, signal) => sleepFor(ms, undefined, { signal }));
|
|
9
|
+
const deadline = now() + clampSeconds(opts.expiresInS, LOGIN_REQUEST_EXPIRES_S, LOGIN_REQUEST_EXPIRES_S) * 1000;
|
|
10
|
+
let intervalS = clampSeconds(opts.intervalS, DEVICE_POLL_INTERVAL_S, DEVICE_POLL_INTERVAL_MAX_S);
|
|
11
|
+
let waitS = intervalS;
|
|
12
|
+
for (;;) {
|
|
13
|
+
try {
|
|
14
|
+
await sleep(waitS * 1000, opts.signal);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new CliError('login cancelled.', ExitCode.Interrupted);
|
|
18
|
+
}
|
|
19
|
+
if (now() >= deadline)
|
|
20
|
+
throw new CliError(LOGIN_EXPIRED_MESSAGE);
|
|
21
|
+
try {
|
|
22
|
+
return await opts.exchange();
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
if (opts.signal.aborted)
|
|
26
|
+
throw new CliError('login cancelled.', ExitCode.Interrupted);
|
|
27
|
+
if (!(err instanceof ApiError))
|
|
28
|
+
throw err;
|
|
29
|
+
switch (err.code) {
|
|
30
|
+
case 'authorization_pending':
|
|
31
|
+
waitS = intervalS;
|
|
32
|
+
continue;
|
|
33
|
+
case 'slow_down':
|
|
34
|
+
intervalS = Math.min(intervalS + DEVICE_SLOW_DOWN_STEP_S, DEVICE_POLL_INTERVAL_MAX_S);
|
|
35
|
+
waitS = intervalS;
|
|
36
|
+
continue;
|
|
37
|
+
case 'access_denied':
|
|
38
|
+
throw new CliError(LOGIN_DENIED_MESSAGE);
|
|
39
|
+
case 'expired_token':
|
|
40
|
+
throw new CliError(LOGIN_EXPIRED_MESSAGE);
|
|
41
|
+
}
|
|
42
|
+
const transient = err.code === 'service_unavailable' ||
|
|
43
|
+
(err.code === null && (err.status === null || err.status >= 500));
|
|
44
|
+
if (transient) {
|
|
45
|
+
waitS = Math.min(waitS * 2, DEVICE_POLL_INTERVAL_MAX_S);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
throw err;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function clampSeconds(value, fallback, max) {
|
|
53
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0)
|
|
54
|
+
return fallback;
|
|
55
|
+
return Math.min(value, max);
|
|
56
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { createServer } from 'node:http';
|
|
3
|
+
import { CliError, ExitCode, LOOPBACK_CALLBACK_PATH, LOOPBACK_REDIRECT_HOST } from "../domain/index.js";
|
|
4
|
+
import { sameSecret } from "./pkce.js";
|
|
5
|
+
export class LoopbackUnavailable extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = 'LoopbackUnavailable';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const MIN_PORT = 1024;
|
|
12
|
+
const MAX_CODE_LENGTH = 512;
|
|
13
|
+
export const CALLBACK_HOLD_MS = 10_000;
|
|
14
|
+
export function startCallbackServer(opts) {
|
|
15
|
+
let settle;
|
|
16
|
+
let fail;
|
|
17
|
+
const result = new Promise((resolve, reject) => {
|
|
18
|
+
settle = resolve;
|
|
19
|
+
fail = reject;
|
|
20
|
+
});
|
|
21
|
+
result.catch(() => { });
|
|
22
|
+
let port = 0;
|
|
23
|
+
let done = false;
|
|
24
|
+
let timer;
|
|
25
|
+
let held = null;
|
|
26
|
+
let holdTimer;
|
|
27
|
+
const server = createServer((req, res) => {
|
|
28
|
+
const outcome = handle(req, port, opts.state, done);
|
|
29
|
+
if (!outcome.result) {
|
|
30
|
+
send(res, 400, PAGES.bad);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
done = true;
|
|
34
|
+
settle(outcome.result);
|
|
35
|
+
clearTimeout(timer);
|
|
36
|
+
server.close();
|
|
37
|
+
server.closeIdleConnections();
|
|
38
|
+
held = res;
|
|
39
|
+
res.once('close', () => {
|
|
40
|
+
held = null;
|
|
41
|
+
finish();
|
|
42
|
+
});
|
|
43
|
+
holdTimer = setTimeout(() => reply(null, null), opts.holdMs ?? CALLBACK_HOLD_MS);
|
|
44
|
+
holdTimer.unref();
|
|
45
|
+
});
|
|
46
|
+
server.on('clientError', (_err, socket) => socket.destroy());
|
|
47
|
+
function reply(outcome, resultUrl) {
|
|
48
|
+
const res = held;
|
|
49
|
+
held = null;
|
|
50
|
+
clearTimeout(holdTimer);
|
|
51
|
+
if (res === null || res.headersSent)
|
|
52
|
+
return;
|
|
53
|
+
if (outcome !== null && resultUrl !== null) {
|
|
54
|
+
redirect(res, `${resultUrl}?result=${outcome}`);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
send(res, 200, PAGES[outcome ?? 'unknown']);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const onAbort = () => {
|
|
61
|
+
if (done) {
|
|
62
|
+
reply(null, null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
finish();
|
|
66
|
+
fail(new CliError('login cancelled.', ExitCode.Interrupted));
|
|
67
|
+
};
|
|
68
|
+
let finished = false;
|
|
69
|
+
function finish() {
|
|
70
|
+
if (finished)
|
|
71
|
+
return;
|
|
72
|
+
finished = true;
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
clearTimeout(holdTimer);
|
|
75
|
+
opts.signal.removeEventListener('abort', onAbort);
|
|
76
|
+
server.close();
|
|
77
|
+
server.closeAllConnections();
|
|
78
|
+
}
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const onListenError = (err) => {
|
|
81
|
+
reject(new LoopbackUnavailable(`cannot listen on ${LOOPBACK_REDIRECT_HOST}: ${err.message}`));
|
|
82
|
+
};
|
|
83
|
+
server.once('error', onListenError);
|
|
84
|
+
server.listen(0, LOOPBACK_REDIRECT_HOST, () => {
|
|
85
|
+
server.off('error', onListenError);
|
|
86
|
+
server.on('error', (err) => {
|
|
87
|
+
finish();
|
|
88
|
+
fail(new CliError(`the login callback server failed: ${err.message}`));
|
|
89
|
+
});
|
|
90
|
+
const address = server.address();
|
|
91
|
+
port = typeof address === 'object' && address !== null ? address.port : 0;
|
|
92
|
+
if (port < MIN_PORT) {
|
|
93
|
+
server.close();
|
|
94
|
+
reject(new LoopbackUnavailable(`unusable callback port ${port}`));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (opts.signal.aborted) {
|
|
98
|
+
onAbort();
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
opts.signal.addEventListener('abort', onAbort, { once: true });
|
|
102
|
+
timer = setTimeout(() => {
|
|
103
|
+
finish();
|
|
104
|
+
fail(new CliError('the login request expired before it was approved. Run login again.'));
|
|
105
|
+
}, opts.timeoutMs);
|
|
106
|
+
}
|
|
107
|
+
resolve({
|
|
108
|
+
port,
|
|
109
|
+
result,
|
|
110
|
+
reply,
|
|
111
|
+
close: () => {
|
|
112
|
+
if (held !== null) {
|
|
113
|
+
reply(null, null);
|
|
114
|
+
}
|
|
115
|
+
else if (!done) {
|
|
116
|
+
done = true;
|
|
117
|
+
finish();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
function handle(req, port, state, done) {
|
|
125
|
+
const bad = {};
|
|
126
|
+
if ((req.headers.host ?? '').toLowerCase() !== `${LOOPBACK_REDIRECT_HOST}:${port}`)
|
|
127
|
+
return bad;
|
|
128
|
+
if (req.method !== 'GET')
|
|
129
|
+
return bad;
|
|
130
|
+
const url = new URL(req.url ?? '/', `http://${LOOPBACK_REDIRECT_HOST}:${port}`);
|
|
131
|
+
if (url.pathname !== LOOPBACK_CALLBACK_PATH)
|
|
132
|
+
return bad;
|
|
133
|
+
if (done)
|
|
134
|
+
return bad;
|
|
135
|
+
if (!sameSecret(url.searchParams.get('state'), state))
|
|
136
|
+
return bad;
|
|
137
|
+
const error = url.searchParams.get('error');
|
|
138
|
+
if (error !== null) {
|
|
139
|
+
return error === 'access_denied' ? { result: { kind: 'denied' } } : bad;
|
|
140
|
+
}
|
|
141
|
+
const code = url.searchParams.get('code');
|
|
142
|
+
if (code === null || code === '' || code.length > MAX_CODE_LENGTH)
|
|
143
|
+
return bad;
|
|
144
|
+
return { result: { kind: 'code', code } };
|
|
145
|
+
}
|
|
146
|
+
const PAGES = {
|
|
147
|
+
ok: {
|
|
148
|
+
title: 'Logged in',
|
|
149
|
+
body: 'This PC is connected to 3Min API. You can close this tab — the terminal shows the details.'
|
|
150
|
+
},
|
|
151
|
+
denied: { title: 'Login denied', body: 'Nothing was changed. You can close this tab.' },
|
|
152
|
+
failed: {
|
|
153
|
+
title: 'Login failed',
|
|
154
|
+
body: 'The approval could not be completed. Check the terminal, then run login again.'
|
|
155
|
+
},
|
|
156
|
+
unknown: {
|
|
157
|
+
title: 'Check the terminal',
|
|
158
|
+
body: 'The approval reached this PC. The terminal shows whether logging in finished. You can close this tab.'
|
|
159
|
+
},
|
|
160
|
+
bad: {
|
|
161
|
+
title: 'Link not valid',
|
|
162
|
+
body: 'This login link is not valid here. Return to the terminal.'
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const STYLE = ':root{color-scheme:light dark}body{margin:0;min-height:100vh;display:grid;place-items:center;' +
|
|
166
|
+
'font:16px/1.5 system-ui,sans-serif;background:#fafafa;color:#18181b}' +
|
|
167
|
+
'main{max-width:28rem;margin:1rem;padding:2rem;border:1px solid #e4e4e7;border-radius:12px;background:#fff}' +
|
|
168
|
+
'h1{margin:0 0 .5rem;font-size:1.25rem}p{margin:0;color:#52525b}' +
|
|
169
|
+
'@media (prefers-color-scheme:dark){body{background:#09090b;color:#fafafa}' +
|
|
170
|
+
'main{background:#18181b;border-color:#27272a}p{color:#a1a1aa}}';
|
|
171
|
+
const STYLE_HASH = createHash('sha256').update(STYLE).digest('base64');
|
|
172
|
+
const BASE_HEADERS = {
|
|
173
|
+
'cache-control': 'no-store',
|
|
174
|
+
'referrer-policy': 'no-referrer',
|
|
175
|
+
'x-content-type-options': 'nosniff',
|
|
176
|
+
connection: 'close'
|
|
177
|
+
};
|
|
178
|
+
function send(res, status, page) {
|
|
179
|
+
const html = '<!doctype html><html lang="en"><meta charset="utf-8">' +
|
|
180
|
+
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
181
|
+
`<title>3Min API CLI — ${page.title}</title><style>${STYLE}</style>` +
|
|
182
|
+
`<main><h1>${page.title}</h1><p>${page.body}</p></main>\n`;
|
|
183
|
+
res.writeHead(status, {
|
|
184
|
+
...BASE_HEADERS,
|
|
185
|
+
'content-type': 'text/html; charset=utf-8',
|
|
186
|
+
'content-security-policy': `default-src 'none'; style-src 'sha256-${STYLE_HASH}'`
|
|
187
|
+
});
|
|
188
|
+
res.end(html);
|
|
189
|
+
}
|
|
190
|
+
function redirect(res, location) {
|
|
191
|
+
res.writeHead(302, {
|
|
192
|
+
...BASE_HEADERS,
|
|
193
|
+
location,
|
|
194
|
+
'content-security-policy': "default-src 'none'",
|
|
195
|
+
'content-length': '0'
|
|
196
|
+
});
|
|
197
|
+
res.end();
|
|
198
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
export function createPkce() {
|
|
3
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
4
|
+
return { verifier, challenge: s256(verifier) };
|
|
5
|
+
}
|
|
6
|
+
export function s256(verifier) {
|
|
7
|
+
return createHash('sha256').update(verifier, 'ascii').digest('base64url');
|
|
8
|
+
}
|
|
9
|
+
export function createState() {
|
|
10
|
+
return randomBytes(32).toString('base64url');
|
|
11
|
+
}
|
|
12
|
+
export function sameSecret(given, expected) {
|
|
13
|
+
if (given === null)
|
|
14
|
+
return false;
|
|
15
|
+
const a = Buffer.from(given);
|
|
16
|
+
const b = Buffer.from(expected);
|
|
17
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
18
|
+
}
|
package/dist/core/output.js
CHANGED
|
@@ -42,6 +42,10 @@ export class Output {
|
|
|
42
42
|
this.writeOut(text.endsWith('\n') ? text : text + '\n');
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
|
+
event(data) {
|
|
46
|
+
if (this.json)
|
|
47
|
+
this.stdout.write(JSON.stringify(data) + '\n');
|
|
48
|
+
}
|
|
45
49
|
print(text) {
|
|
46
50
|
this.writeOut(text.endsWith('\n') ? text : text + '\n');
|
|
47
51
|
}
|
package/dist/core/redact.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { truncate } from "./text.js";
|
|
2
2
|
export const REDACTED = '<redacted>';
|
|
3
|
-
const SECRET_KEY = /authorization|token|secret|password|passwd|api[-_]?key|signature|cookie|credential/i;
|
|
3
|
+
const SECRET_KEY = /authorization|token|secret|password|passwd|api[-_]?key|signature|cookie|credential|verifier|device_code/i;
|
|
4
4
|
const SECRET_VALUES = [
|
|
5
5
|
/tm_(?:host|live|test|mcp|task)_[0-9a-f]{8,}/gi,
|
|
6
6
|
/whsec_[A-Za-z0-9+/=]{8,}/g,
|
package/package.json
CHANGED