@3minapi/cli 0.22.1 → 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 CHANGED
@@ -20,19 +20,22 @@ npm install -g @3minapi/cli
20
20
 
21
21
  ## Quick start
22
22
 
23
- 1. **Create a host token** on the dashboard: [3minapi.com/dashboard/tasks/local](https://3minapi.com/dashboard/tasks/local).
24
- It is shown once.
25
- 2. **Log in** — the prompt is masked; the token is saved only on this machine.
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
- 3. **Create the project** (default `~/3minapi`) and a worker:
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
- 4. **Start the host** — registers the workers and runs tasks until stopped:
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. Log in yourself — never paste the host token into a chat.
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({
@@ -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: 'Save the host token issued on the 3Min API dashboard',
37
- usage: '[--with-token]',
38
- description: 'Prompts for the host token (tm_host_...). Use --with-token to read it from stdin instead,\n' +
39
- 'e.g. in CI: echo "$TOKEN" | 3minapi login --with-token\n' +
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
- 'with-token': { type: 'boolean', description: 'Read the token from stdin' }
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 { out, env, values } = ctx;
47
- let token;
48
- if (values['with-token']) {
49
- if (ctx.stdin.isTTY) {
50
- throw new UsageError(`--with-token reads the token from stdin. Pipe it in: echo "$TOKEN" | ${BIN} login --with-token`);
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
- else {
55
- token = (await readSecret(ctx.stdin, out, `Paste your host token (${HOST_TOKEN_PREFIX}...): `)).trim();
78
+ if (values.device && values.loopback) {
79
+ throw new UsageError('--device and --loopback choose opposite flows — use one of them');
56
80
  }
57
- if (!isHostToken(token)) {
58
- throw new UsageError(`that is not a host token (expected ${HOST_TOKEN_PREFIX} followed by 48 hex characters)`);
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
- const session = openSession(ctx);
61
- try {
62
- let host;
84
+ if (values['with-token'] || values.paste) {
85
+ const token = await readManualToken(ctx);
86
+ const session = openSession(ctx);
63
87
  try {
64
- host = await client(ctx, session, token).getHost();
88
+ return await saveVerifiedToken(ctx, session, token, null);
65
89
  }
66
- catch (err) {
67
- if (err instanceof StopError) {
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
- const file = saveHostToken(ctx.dirs.config, token, ctx.now());
74
- clearStop(session);
75
- const previous = cacheHostIdentity(session.db, token, host.host, ctx.now());
76
- rememberQuota(session, host.quota, ctx.now());
77
- session.logger.info('logged in', { host_id: host.host.id, host_name: host.host.name });
78
- if (previous !== null) {
79
- out.warn(hostChangedWarning(previous, host.host));
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
- if (env[ENV.token]) {
83
- out.warn(`${ENV.token} is set and takes precedence over the saved token.`);
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',
@@ -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. The user runs \`${BIN} login\` and types
45
- the token at its masked prompt; never ask for the token in the chat.
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
- async request(method, url, payload) {
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. Issue a new token on the dashboard and run '3minapi login'.";
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
+ }
@@ -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
  }
@@ -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,
@@ -62,5 +62,5 @@ export function missingPathsWarning(missing) {
62
62
  const shown = missing.slice(0, MISSING_PATHS_MAX);
63
63
  const more = missing.length - shown.length;
64
64
  return (`No result in the scope has ${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}, so ` +
65
- `${missing.length === 1 ? 'it reads' : 'they read'} as NULL — op "describe" lists the paths the results have.`);
65
+ `${missing.length === 1 ? 'it reads' : 'they read'} as NULL — task_result_describe lists the paths the results have.`);
66
66
  }
@@ -1,8 +1,7 @@
1
1
  import { BUILTIN_TASK_IDS_MAX, READ_LIMIT_MAX } from "../../../core/domain/index.js";
2
- export const READER_USAGE = 'input is one of: {"op":"describe","task_id":"…","run_no?":1} | {"op":"describe","worker":"…","since?":"ISO date"} | ' +
3
- '{"op":"query","sql":"SELECT … FROM results …","worker?":"…","task_id?":"…","task_ids?":["…"],"since?":"ISO date","expect_shape?":"…"} ' +
4
- '(at least one of worker, task_id, task_ids, since) | {"op":"read","task_id":"…","run_no?":1,"path?":"$.items","offset?":0,"limit?":100} ' +
5
- '(run_no picks a run of a repeating task; the latest run when left out)';
2
+ export const READER_USAGE = 'Use task_result_describe with task_id (and run_no) or worker (and since); task_result_query with sql and at least ' +
3
+ 'one of worker, task_id, task_ids, since (and expect_shape); or task_result_read with task_id (and run_no, path, ' +
4
+ 'offset, limit). run_no picks a run of a repeating task; the latest run when left out';
6
5
  const TASK_ID = /^[0-9A-Za-z-]{1,64}$/;
7
6
  const SHAPE_HASH = /^[0-9a-f]{16}$/;
8
7
  const WORKER_MAX_CHARS = 50;
@@ -79,7 +79,7 @@ function describeOne(db, taskId, hostId, runNo) {
79
79
  ? out
80
80
  : {
81
81
  ...out,
82
- error: { message: 'the saved error is too large to show; read it with op "read"' }
82
+ error: { message: 'the saved error is too large to show; read it with task_result_read' }
83
83
  };
84
84
  }
85
85
  return {
@@ -382,7 +382,7 @@ function readText(base, text, offset, limit) {
382
382
  };
383
383
  }
384
384
  function missing(path) {
385
- return new Failure('read_out_of_range', `${path} does not exist in this result — check the paths with op "describe"`);
385
+ return new Failure('read_out_of_range', `${path} does not exist in this result — check the paths with task_result_describe`);
386
386
  }
387
387
  export function parsePath(path) {
388
388
  const steps = [];
@@ -3,9 +3,9 @@ import { formatCount } from "../../../core/text.js";
3
3
  import { fitShape } from "./shape.js";
4
4
  export const SUBSTITUTE_READ = "The full result is too large to send and is stored on the user's PC. Do not present this summary as the result. " +
5
5
  '`shape` summarizes its structure: the paths it has, with types, counts and sample values — an overview; ' +
6
- 'deeper levels may be left out (`omitted`), and {"op":"describe","task_id":"<task_id>"} returns them. ' +
7
- 'Compute the answer over those paths by creating a task for the worker in `reader` with input ' +
8
- '{"op":"query","task_id":"<task_id>","sql":"SELECT … FROM results"} — help(tasks) explains the view and examples.';
6
+ 'deeper levels may be left out (`omitted`), and task_result_describe with this task_id returns them. ' +
7
+ 'Compute the answer over those paths with task_result_query for this task_id and SQL over the view `results` — ' +
8
+ 'help(tasks) explains the view and examples.';
9
9
  export const SUBSTITUTE_READ_LOCAL_ONLY = "The full result is too large to send and is stored on the user's PC. The user can see it with " +
10
10
  "'3minapi worker results show <task_id>' or query it with '3minapi worker results query'. " +
11
11
  '`shape` summarizes its structure: paths, types, counts and sample values — an overview; deeper ' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3minapi/cli",
3
- "version": "0.22.1",
3
+ "version": "0.24.0",
4
4
  "description": "Run your machine as a 3Min API host — the CLI that fetches tasks from your AI and runs them with your own worker files",
5
5
  "keywords": [
6
6
  "3minapi",