@3minapi/cli 0.0.0-stage → 0.22.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/LICENSE +21 -0
- package/README.md +82 -2
- package/dist/bin.js +54 -0
- package/dist/cli.js +241 -0
- package/dist/commands/auth.js +573 -0
- package/dist/commands/common.js +48 -0
- package/dist/commands/dashboard.js +298 -0
- package/dist/commands/delete.js +167 -0
- package/dist/commands/doctor.js +326 -0
- package/dist/commands/guide.js +84 -0
- package/dist/commands/index.js +28 -0
- package/dist/commands/init.js +248 -0
- package/dist/commands/logs.js +154 -0
- package/dist/commands/new.js +109 -0
- package/dist/commands/paths.js +64 -0
- package/dist/commands/reload.js +32 -0
- package/dist/commands/results.js +445 -0
- package/dist/commands/start.js +443 -0
- package/dist/commands/stop.js +68 -0
- package/dist/commands/tasks.js +93 -0
- package/dist/commands/worker.js +178 -0
- package/dist/core/api.js +132 -0
- package/dist/core/config.js +188 -0
- package/dist/core/control-db.js +462 -0
- package/dist/core/credentials.js +75 -0
- package/dist/core/domain/constants.js +121 -0
- package/dist/core/domain/contract.js +102 -0
- package/dist/core/domain/errors.js +57 -0
- package/dist/core/domain/index.js +3 -0
- package/dist/core/domain/types.js +1 -0
- package/dist/core/error-report.js +103 -0
- package/dist/core/host/detach.js +49 -0
- package/dist/core/host/live-connection.js +270 -0
- package/dist/core/host/live-receiver.js +264 -0
- package/dist/core/host/module.js +1 -0
- package/dist/core/host/poller.js +113 -0
- package/dist/core/host/pull-step.js +54 -0
- package/dist/core/host/receiver.js +1 -0
- package/dist/core/host/reload.js +34 -0
- package/dist/core/host/runner-lock.js +245 -0
- package/dist/core/host/session.js +84 -0
- package/dist/core/host/usage.js +20 -0
- package/dist/core/log-read.js +59 -0
- package/dist/core/logger.js +206 -0
- package/dist/core/names.js +27 -0
- package/dist/core/network.js +10 -0
- package/dist/core/open-browser.js +26 -0
- package/dist/core/output.js +68 -0
- package/dist/core/paths.js +26 -0
- package/dist/core/project.js +85 -0
- package/dist/core/prompt.js +140 -0
- package/dist/core/redact.js +45 -0
- package/dist/core/secure-fs.js +50 -0
- package/dist/core/text-file.js +12 -0
- package/dist/core/text.js +27 -0
- package/dist/core/time.js +84 -0
- package/dist/core/update-notice.js +142 -0
- package/dist/core/version.js +16 -0
- package/dist/core/warnings.js +10 -0
- package/dist/help.js +144 -0
- package/dist/modules/dashboard/assets.js +411 -0
- package/dist/modules/dashboard/data.js +565 -0
- package/dist/modules/dashboard/guard.js +75 -0
- package/dist/modules/dashboard/lock.js +93 -0
- package/dist/modules/dashboard/server.js +295 -0
- package/dist/modules/dashboard/views/html.js +114 -0
- package/dist/modules/dashboard/views/layout.js +59 -0
- package/dist/modules/dashboard/views/logs.js +122 -0
- package/dist/modules/dashboard/views/overview.js +316 -0
- package/dist/modules/dashboard/views/runs.js +486 -0
- package/dist/modules/dashboard/views/workers.js +157 -0
- package/dist/modules/workers/builtin/host-identity.js +59 -0
- package/dist/modules/workers/builtin/results-worker.js +37 -0
- package/dist/modules/workers/local-server.js +244 -0
- package/dist/modules/workers/module.js +98 -0
- package/dist/modules/workers/reload-result.js +16 -0
- package/dist/modules/workers/repeat-scheduler.js +165 -0
- package/dist/modules/workers/results/missing-paths.js +66 -0
- package/dist/modules/workers/results/query.js +175 -0
- package/dist/modules/workers/results/reader-input.js +134 -0
- package/dist/modules/workers/results/reader.js +430 -0
- package/dist/modules/workers/results/results-child.js +64 -0
- package/dist/modules/workers/results/shape.js +212 -0
- package/dist/modules/workers/results/store.js +336 -0
- package/dist/modules/workers/results/substitute.js +30 -0
- package/dist/modules/workers/runtime/env.js +43 -0
- package/dist/modules/workers/runtime/error-chain.js +109 -0
- package/dist/modules/workers/runtime/executor.js +289 -0
- package/dist/modules/workers/runtime/inspect.js +53 -0
- package/dist/modules/workers/runtime/protocol.js +1 -0
- package/dist/modules/workers/runtime/resolve.js +30 -0
- package/dist/modules/workers/runtime/schema.js +85 -0
- package/dist/modules/workers/runtime/thread.js +117 -0
- package/dist/modules/workers/start.js +265 -0
- package/dist/modules/workers/sync.js +241 -0
- package/dist/modules/workers/task-runner.js +892 -0
- package/dist/modules/workers/template.js +100 -0
- package/dist/modules/workers/worker-set.js +122 -0
- package/dist/modules/workers/workers.js +66 -0
- package/package.json +48 -5
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
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";
|
|
5
|
+
import { readAllStdin, readSecret } from "../core/prompt.js";
|
|
6
|
+
import { readRunner, runnerMode } from "../core/host/runner-lock.js";
|
|
7
|
+
import { readDashboard } from "../modules/dashboard/lock.js";
|
|
8
|
+
import { looseMode } from "../core/secure-fs.js";
|
|
9
|
+
import { defaultProjectRoot } from "../core/project.js";
|
|
10
|
+
import { clearStop, openSession, recordStop, rememberQuota, stopHint } from "../core/host/session.js";
|
|
11
|
+
import { hostUsage } from "../core/host/usage.js";
|
|
12
|
+
import { formatBytes, formatCount, stripControl } from "../core/text.js";
|
|
13
|
+
import { describeAt, formatLocal, formatThroughput, maxTasksPerMinute, relative } from "../core/time.js";
|
|
14
|
+
import { HOST_META, cacheHostIdentity, hostChangedWarning } from "../modules/workers/builtin/host-identity.js";
|
|
15
|
+
import { ResultStore, resultsDbBytes, resultsDbPath } from "../modules/workers/results/store.js";
|
|
16
|
+
import { workerFiles } from "../modules/workers/workers.js";
|
|
17
|
+
import { parseSettings } from "../modules/workers/repeat-scheduler.js";
|
|
18
|
+
import { ApiError } from "../core/api.js";
|
|
19
|
+
import { client, noticeUpdate, workersPath } from "./common.js";
|
|
20
|
+
import { readLatest, updateInfo } from "../core/update-notice.js";
|
|
21
|
+
function nextAfterLogin(ctx, config) {
|
|
22
|
+
const root = config.projectRoot;
|
|
23
|
+
if (!root) {
|
|
24
|
+
return `Next: ${BIN} init ${ctx.out.out.dim(`(creates ${defaultProjectRoot(ctx.home)})`)}`;
|
|
25
|
+
}
|
|
26
|
+
let hasWorkers = false;
|
|
27
|
+
try {
|
|
28
|
+
hasWorkers = workerFiles(workersPath(root, config)).size > 0;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
}
|
|
32
|
+
return hasWorkers ? `Next: ${BIN} start` : `Next: ${BIN} worker new <name>`;
|
|
33
|
+
}
|
|
34
|
+
export const login = {
|
|
35
|
+
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' +
|
|
40
|
+
'The token is checked with the server (one API call) before it is saved.',
|
|
41
|
+
options: {
|
|
42
|
+
'with-token': { type: 'boolean', description: 'Read the token from stdin' }
|
|
43
|
+
},
|
|
44
|
+
args: { min: 0, max: 0 },
|
|
45
|
+
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();
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
token = (await readSecret(ctx.stdin, out, `Paste your host token (${HOST_TOKEN_PREFIX}...): `)).trim();
|
|
56
|
+
}
|
|
57
|
+
if (!isHostToken(token)) {
|
|
58
|
+
throw new UsageError(`that is not a host token (expected ${HOST_TOKEN_PREFIX} followed by 48 hex characters)`);
|
|
59
|
+
}
|
|
60
|
+
const session = openSession(ctx);
|
|
61
|
+
try {
|
|
62
|
+
let host;
|
|
63
|
+
try {
|
|
64
|
+
host = await client(ctx, session, token).getHost();
|
|
65
|
+
}
|
|
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;
|
|
72
|
+
}
|
|
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 });
|
|
81
|
+
}
|
|
82
|
+
if (env[ENV.token]) {
|
|
83
|
+
out.warn(`${ENV.token} is set and takes precedence over the saved token.`);
|
|
84
|
+
}
|
|
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
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
session.close();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
export const logout = {
|
|
108
|
+
name: 'logout',
|
|
109
|
+
summary: 'Remove the saved host token from this machine',
|
|
110
|
+
usage: '',
|
|
111
|
+
description: 'Only deletes the token saved on this machine. To revoke it, delete the token on the dashboard.',
|
|
112
|
+
options: {},
|
|
113
|
+
args: { min: 0, max: 0 },
|
|
114
|
+
async run({ dirs, env, out }) {
|
|
115
|
+
const removed = deleteHostToken(dirs.config);
|
|
116
|
+
if (env[ENV.token]) {
|
|
117
|
+
out.warn(`${ENV.token} is still set — unset it to stop using that token.`);
|
|
118
|
+
}
|
|
119
|
+
out.result({ removed }, removed ? 'Logged out.' : 'No saved token. Already logged out.');
|
|
120
|
+
return ExitCode.Ok;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
export const status = {
|
|
124
|
+
name: 'status',
|
|
125
|
+
summary: "Show this host: whether 'start' is running, its workers, and remaining quota",
|
|
126
|
+
usage: '',
|
|
127
|
+
description: 'Checks the token with the server, which counts as one API call. Also shows how much disk this\n' +
|
|
128
|
+
'host uses (task history, saved results, logs), and how it fetches tasks: live (connection state)\n' +
|
|
129
|
+
'or poll (interval, and at most concurrency x polls per minute). If the server cannot be reached (network), shows\n' +
|
|
130
|
+
'what this PC knows — including the quota from the last server response — and exits with 1.',
|
|
131
|
+
options: {},
|
|
132
|
+
args: { min: 0, max: 0 },
|
|
133
|
+
async run(ctx) {
|
|
134
|
+
const { out } = ctx;
|
|
135
|
+
const token = resolveHostToken(ctx.dirs.config, ctx.env);
|
|
136
|
+
if (!token) {
|
|
137
|
+
const board = readDashboard(ctx.dirs.data);
|
|
138
|
+
out.result({ loggedIn: false, dashboard: dashboardJson(board) }, [
|
|
139
|
+
`Not logged in. Run '${BIN} login'.`,
|
|
140
|
+
...(board.state === 'stopped'
|
|
141
|
+
? []
|
|
142
|
+
: [row('dashboard', dashboardText(out, board, ctx.now()))])
|
|
143
|
+
].join('\n'));
|
|
144
|
+
return ExitCode.Failure;
|
|
145
|
+
}
|
|
146
|
+
warnLooseToken(out, token);
|
|
147
|
+
const session = openSession(ctx);
|
|
148
|
+
try {
|
|
149
|
+
const overrides = overridesIn(ctx, token);
|
|
150
|
+
let host;
|
|
151
|
+
try {
|
|
152
|
+
host = await client(ctx, session, token.token).getHost();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
if (err instanceof StopError) {
|
|
156
|
+
recordStop(session, err, ctx.now());
|
|
157
|
+
throw new StopError(err.reason, `${err.message}\n${stopHint(err.reason)}`, err.resetAt);
|
|
158
|
+
}
|
|
159
|
+
if (!(err instanceof ApiError) || err.status !== null || ctx.signal.aborted)
|
|
160
|
+
throw err;
|
|
161
|
+
offlineStatus(ctx, session, token, overrides, err);
|
|
162
|
+
return ExitCode.Failure;
|
|
163
|
+
}
|
|
164
|
+
clearStop(session);
|
|
165
|
+
const previousHost = cacheHostIdentity(session.db, token.token, host.host, ctx.now());
|
|
166
|
+
rememberQuota(session, host.quota, ctx.now());
|
|
167
|
+
if (previousHost !== null)
|
|
168
|
+
out.warn(hostChangedWarning(previousHost, host.host));
|
|
169
|
+
const now = ctx.now();
|
|
170
|
+
const runner = readRunner(ctx.dirs.data);
|
|
171
|
+
const board = readDashboard(ctx.dirs.data);
|
|
172
|
+
const update = noticeUpdate(ctx, session, runner.state === 'running');
|
|
173
|
+
const compareDir = runner.state === 'running' && runner.info.workersDir
|
|
174
|
+
? runner.info.workersDir
|
|
175
|
+
: projectWorkersDir(session.config);
|
|
176
|
+
const local = compareDir === null ? null : workerNamesIn(compareDir);
|
|
177
|
+
const registered = new Set(host.workers.map((w) => w.name));
|
|
178
|
+
const unregistered = local?.filter((n) => !registered.has(n) && !isReservedWorkerName(n)) ?? [];
|
|
179
|
+
const localSet = new Set(local ?? []);
|
|
180
|
+
const builtin = builtinResultsName(host.host.id);
|
|
181
|
+
const serverOnly = local === null
|
|
182
|
+
? []
|
|
183
|
+
: host.workers.map((w) => w.name).filter((n) => !localSet.has(n) && n !== builtin);
|
|
184
|
+
const lastEnd = runner.state === 'stopped' ? readEnd(session) : null;
|
|
185
|
+
const shell = session.config.values;
|
|
186
|
+
const running = runner.state === 'running' ? runner.info : null;
|
|
187
|
+
const apiUrl = running?.apiUrl ?? shell.apiUrl;
|
|
188
|
+
const urlMismatch = running?.apiUrl !== undefined && running.apiUrl !== shell.apiUrl;
|
|
189
|
+
const mode = running ? runnerMode(running) : shell.receiveMode;
|
|
190
|
+
const intervalMs = running?.intervalMs ?? shell.pollIntervalMs;
|
|
191
|
+
const effectiveMs = mode === 'poll' ? Math.max(intervalMs, host.poll.min_interval_ms) : null;
|
|
192
|
+
const concurrency = running?.concurrency ?? shell.concurrency;
|
|
193
|
+
const liveState = running && mode === 'live' ? readLiveState(session.db) : null;
|
|
194
|
+
const results = savedResults(session.config.projectRoot);
|
|
195
|
+
const usage = hostUsage(session.db, ctx.dirs.logs);
|
|
196
|
+
const repeats = readRepeats(session.db, session.config.projectRoot);
|
|
197
|
+
const data = {
|
|
198
|
+
loggedIn: true,
|
|
199
|
+
token: { source: token.source, location: token.location },
|
|
200
|
+
apiUrl: shell.apiUrl,
|
|
201
|
+
mode,
|
|
202
|
+
pollIntervalMs: mode === 'poll' ? shell.pollIntervalMs : null,
|
|
203
|
+
effectivePollIntervalMs: effectiveMs,
|
|
204
|
+
concurrency,
|
|
205
|
+
maxTasksPerMinute: effectiveMs === null ? null : maxTasksPerMinute(concurrency, effectiveMs),
|
|
206
|
+
liveState,
|
|
207
|
+
host: host.host,
|
|
208
|
+
workers: host.workers,
|
|
209
|
+
quota: host.quota,
|
|
210
|
+
poll: host.poll,
|
|
211
|
+
localWorkers: local,
|
|
212
|
+
unregisteredWorkers: unregistered,
|
|
213
|
+
serverOnlyWorkers: serverOnly,
|
|
214
|
+
running: runner.state === 'running',
|
|
215
|
+
runner: runner.state === 'running' ? runner.info : null,
|
|
216
|
+
repeats,
|
|
217
|
+
dashboard: dashboardJson(board),
|
|
218
|
+
lastEnd: lastEnd && { ...lastEnd, endedAt: new Date(lastEnd.endedAt).toISOString() },
|
|
219
|
+
results,
|
|
220
|
+
usage: usageJson(usage, session.config.projectRoot, results),
|
|
221
|
+
overrides,
|
|
222
|
+
update
|
|
223
|
+
};
|
|
224
|
+
const workers = host.workers.map((w) => w.name);
|
|
225
|
+
out.result(data, [
|
|
226
|
+
row('host', `${stripControl(host.host.name)} ${out.out.dim(`(${stripControl(host.host.id)})`)}`),
|
|
227
|
+
row('token', token.source === 'env' ? `from ${token.location}` : `saved in ${token.location}`),
|
|
228
|
+
...overrideRows(out, overrides),
|
|
229
|
+
row('remote', `${apiUrl}${urlMismatch ? out.out.dim(' (runner)') : ''}`),
|
|
230
|
+
...(urlMismatch
|
|
231
|
+
? [
|
|
232
|
+
row('', `${out.out.yellow('warn')} this shell uses ${shell.apiUrl} — host, workers and quota here come from that server`)
|
|
233
|
+
]
|
|
234
|
+
: []),
|
|
235
|
+
row('workers', workers.length === 0
|
|
236
|
+
? 'none registered yet'
|
|
237
|
+
: `${workers.length} (${workers.join(', ')})`),
|
|
238
|
+
...(unregistered.length > 0
|
|
239
|
+
? [
|
|
240
|
+
row('', `${out.out.yellow('not registered')} ${unregistered.join(', ')} ${out.out.dim(runner.state !== 'running'
|
|
241
|
+
? `· the next '${BIN} start' tries to register them`
|
|
242
|
+
: runner.info.reload
|
|
243
|
+
? `· '${BIN} reload' to register`
|
|
244
|
+
: `· restart '${BIN} start' to register`)}`)
|
|
245
|
+
]
|
|
246
|
+
: []),
|
|
247
|
+
...serverOnly.map((n) => row('', `${out.out.yellow('server only')} ${n} ${out.out.dim(`· no file in ${compareDir} — '${BIN} worker delete ${n}' removes it from 3Min API`)}`)),
|
|
248
|
+
quotaLine(out, host, now),
|
|
249
|
+
effectiveMs === null
|
|
250
|
+
? row('mode', liveText(out, running !== null, liveState, concurrency, now))
|
|
251
|
+
: row('poll', `every ${secs(effectiveMs)}${running ? '' : ' when started'}, ${formatThroughput(concurrency, effectiveMs)} ${out.out.dim(`(server minimum ${secs(host.poll.min_interval_ms)}) · about ${formatCount(Math.round(MONTH_MS / effectiveMs))} calls per month` +
|
|
252
|
+
(running && running.intervalMs !== shell.pollIntervalMs
|
|
253
|
+
? ` · runner setting; this shell has ${secs(shell.pollIntervalMs)}`
|
|
254
|
+
: ''))}`),
|
|
255
|
+
row('runner', runnerText(out, runner, now, lastEnd)),
|
|
256
|
+
...repeatRows(out, repeats, concurrency, runner.state === 'running'),
|
|
257
|
+
row('dashboard', dashboardText(out, board, now)),
|
|
258
|
+
...storageRows(out, usage, results),
|
|
259
|
+
...(running && running.port !== null
|
|
260
|
+
? [row('listen', serveText(out, running.port, running.public, ctx.dirs.config))]
|
|
261
|
+
: [])
|
|
262
|
+
].join('\n'));
|
|
263
|
+
return ExitCode.Ok;
|
|
264
|
+
}
|
|
265
|
+
finally {
|
|
266
|
+
session.close();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
function overridesIn(ctx, token) {
|
|
271
|
+
const home = ctx.env[ENV.home];
|
|
272
|
+
return {
|
|
273
|
+
home: home && path.isAbsolute(home) ? home : null,
|
|
274
|
+
token: token.source === 'env'
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function overrideRows(out, o) {
|
|
278
|
+
const rows = [];
|
|
279
|
+
if (o.home !== null) {
|
|
280
|
+
rows.push(row('', `${out.out.yellow('note')} ${ENV.home} is set — not this PC's default host folder (for development and tests)`));
|
|
281
|
+
}
|
|
282
|
+
if (o.token) {
|
|
283
|
+
rows.push(row('', `${out.out.yellow('note')} ${ENV.token} is set — not the token saved by '${BIN} login' (for development, CI and tests)`));
|
|
284
|
+
}
|
|
285
|
+
return rows;
|
|
286
|
+
}
|
|
287
|
+
function usageJson(usage, root, results) {
|
|
288
|
+
return {
|
|
289
|
+
controlDb: usage.controlDb,
|
|
290
|
+
dataDb: root && results
|
|
291
|
+
? { file: resultsDbPath(root), bytes: results.dbBytes, results: results.count }
|
|
292
|
+
: null,
|
|
293
|
+
logs: usage.logs
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
function storageRows(out, usage, results) {
|
|
297
|
+
const rows = [];
|
|
298
|
+
if (usage.controlDb) {
|
|
299
|
+
const { tasks, unfinished, bytes } = usage.controlDb;
|
|
300
|
+
rows.push(row('history', `${formatCount(tasks)} task(s)${unfinished > 0 ? `, ${formatCount(unfinished)} not finished` : ''} · control.db ${formatBytes(bytes)} ${out.out.dim(`('${BIN} tasks prune')`)}`));
|
|
301
|
+
}
|
|
302
|
+
if (results) {
|
|
303
|
+
rows.push(row('results', `${formatCount(results.count)} saved · data.db ${formatBytes(results.dbBytes)} ${out.out.dim(`('${BIN} worker results' · never deleted automatically — '${BIN} worker results prune')`)}`));
|
|
304
|
+
}
|
|
305
|
+
rows.push(row('logs', `${formatCount(usage.logs.files)} file(s) · ${formatBytes(usage.logs.bytes)} ${out.out.dim(`('${BIN} logs')`)}`));
|
|
306
|
+
return rows;
|
|
307
|
+
}
|
|
308
|
+
function offlineStatus(ctx, session, token, overrides, err) {
|
|
309
|
+
const { out } = ctx;
|
|
310
|
+
const now = ctx.now();
|
|
311
|
+
const runner = readRunner(ctx.dirs.data);
|
|
312
|
+
const board = readDashboard(ctx.dirs.data);
|
|
313
|
+
let cached = null;
|
|
314
|
+
let last = null;
|
|
315
|
+
try {
|
|
316
|
+
const id = session.db.getMeta(HOST_META.id);
|
|
317
|
+
const name = session.db.getMeta(HOST_META.name);
|
|
318
|
+
cached = id && name ? { id, name } : null;
|
|
319
|
+
last = session.db.getQuota();
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
}
|
|
323
|
+
const results = savedResults(session.config.projectRoot);
|
|
324
|
+
const usage = hostUsage(session.db, ctx.dirs.logs);
|
|
325
|
+
const repeats = readRepeats(session.db, session.config.projectRoot);
|
|
326
|
+
const running = runner.state === 'running' ? runner.info : null;
|
|
327
|
+
const shell = session.config.values;
|
|
328
|
+
const mode = running ? runnerMode(running) : shell.receiveMode;
|
|
329
|
+
const concurrency = running?.concurrency ?? shell.concurrency;
|
|
330
|
+
const liveState = running && mode === 'live' ? readLiveState(session.db) : null;
|
|
331
|
+
const intervalMs = mode === 'poll' ? (running?.intervalMs ?? shell.pollIntervalMs) : null;
|
|
332
|
+
out.warn(`cannot reach 3Min API (${err.message}) — showing what this PC knows.`);
|
|
333
|
+
out.result({
|
|
334
|
+
loggedIn: true,
|
|
335
|
+
offline: true,
|
|
336
|
+
error: err.message,
|
|
337
|
+
token: { source: token.source, location: token.location },
|
|
338
|
+
apiUrl: session.config.values.apiUrl,
|
|
339
|
+
host: cached,
|
|
340
|
+
lastQuota: last && { ...last.quota, at: new Date(last.at).toISOString() },
|
|
341
|
+
mode,
|
|
342
|
+
pollIntervalMs: intervalMs,
|
|
343
|
+
liveState,
|
|
344
|
+
running: runner.state === 'running',
|
|
345
|
+
runner: runner.state === 'running' ? runner.info : null,
|
|
346
|
+
repeats,
|
|
347
|
+
dashboard: dashboardJson(board),
|
|
348
|
+
results,
|
|
349
|
+
usage: usageJson(usage, session.config.projectRoot, results),
|
|
350
|
+
overrides,
|
|
351
|
+
update: updateInfo(ctx.version, readLatest(session.db), runner.state === 'running')
|
|
352
|
+
}, [
|
|
353
|
+
row('host', cached
|
|
354
|
+
? `${cached.name} ${out.out.dim(`(${cached.id}) · last known`)}`
|
|
355
|
+
: out.out.dim('unknown')),
|
|
356
|
+
row('token', token.source === 'env' ? `from ${token.location}` : `saved in ${token.location}`),
|
|
357
|
+
...overrideRows(out, overrides),
|
|
358
|
+
row('remote', `${session.config.values.apiUrl} ${out.out.yellow('(not reachable)')}`),
|
|
359
|
+
last
|
|
360
|
+
? row('quota', `${formatCount(last.quota.used)} / ${formatCount(last.quota.limit)} calls used ${out.out.dim(`· last checked ${formatLocal(last.at)} (${relative(last.at, now)}) · resets ${describeAt(last.quota.reset_at, now, { local: true })}`)}`)
|
|
361
|
+
: row('quota', out.out.dim('unknown — no server response saved yet')),
|
|
362
|
+
intervalMs === null
|
|
363
|
+
? row('mode', liveText(out, running !== null, liveState, concurrency, now))
|
|
364
|
+
: row('poll', `every ${secs(intervalMs)}${running ? '' : ' when started'} ${out.out.dim('(setting — the server minimum is not known offline)')}`),
|
|
365
|
+
row('runner', runnerText(out, runner, now, null)),
|
|
366
|
+
...repeatRows(out, repeats, (runner.state === 'running' ? runner.info.concurrency : undefined) ??
|
|
367
|
+
session.config.values.concurrency, runner.state === 'running'),
|
|
368
|
+
row('dashboard', dashboardText(out, board, now)),
|
|
369
|
+
...storageRows(out, usage, results)
|
|
370
|
+
].join('\n'));
|
|
371
|
+
}
|
|
372
|
+
function readRepeats(db, root) {
|
|
373
|
+
let rows;
|
|
374
|
+
try {
|
|
375
|
+
rows = db.repeatTasks();
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
const saved = savedRuns(root, rows.map((r) => r.task_id));
|
|
381
|
+
return rows.flatMap((row) => {
|
|
382
|
+
const settings = parseSettings(row.repeat);
|
|
383
|
+
if (!settings)
|
|
384
|
+
return [];
|
|
385
|
+
return [
|
|
386
|
+
{
|
|
387
|
+
task_id: row.task_id,
|
|
388
|
+
worker: row.worker,
|
|
389
|
+
runs: row.runs ?? 0,
|
|
390
|
+
failed: row.failed ?? 0,
|
|
391
|
+
max_runs: settings.max_runs ?? null,
|
|
392
|
+
interval_seconds: settings.interval_seconds,
|
|
393
|
+
next_at: row.next_at == null ? null : new Date(row.next_at).toISOString(),
|
|
394
|
+
results: saved === null ? null : (saved.get(row.task_id)?.runs ?? 0)
|
|
395
|
+
}
|
|
396
|
+
];
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
function savedRuns(root, ids) {
|
|
400
|
+
if (!root || ids.length === 0 || resultsDbBytes(root) === null)
|
|
401
|
+
return null;
|
|
402
|
+
try {
|
|
403
|
+
const store = ResultStore.openReadOnly(root);
|
|
404
|
+
if (!store)
|
|
405
|
+
return new Map();
|
|
406
|
+
try {
|
|
407
|
+
return store.runCounts(ids);
|
|
408
|
+
}
|
|
409
|
+
finally {
|
|
410
|
+
store.close();
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function repeatRows(out, repeats, concurrency, running) {
|
|
418
|
+
if (repeats.length === 0)
|
|
419
|
+
return [];
|
|
420
|
+
return [
|
|
421
|
+
row('repeating', `${repeats.length} ${out.out.dim(`· runs share the ${concurrency} slot(s) with single tasks`)}`),
|
|
422
|
+
...repeats.map((r) => {
|
|
423
|
+
const failed = r.failed > 0 ? `, ${r.failed} failed` : '';
|
|
424
|
+
const next = !running || r.next_at === null
|
|
425
|
+
? `resumes on '${BIN} start'`
|
|
426
|
+
: `next ${formatLocal(Date.parse(r.next_at))}`;
|
|
427
|
+
return row('', `${r.worker} ${r.task_id.length > 13 ? r.task_id.slice(-12) : r.task_id} ` +
|
|
428
|
+
`${r.runs}/${r.max_runs ?? '∞'}${failed} ${out.out.dim(`· every ${r.interval_seconds}s · ${next}` +
|
|
429
|
+
(r.results === null ? '' : ` · ${formatCount(r.results)} result row(s) saved`))}`);
|
|
430
|
+
})
|
|
431
|
+
];
|
|
432
|
+
}
|
|
433
|
+
function quotaLine(out, host, now) {
|
|
434
|
+
const { used, limit, reset_at } = host.quota;
|
|
435
|
+
const when = describeAt(reset_at, now, { local: true });
|
|
436
|
+
const usage = `${formatCount(used)} / ${formatCount(limit)} calls used`;
|
|
437
|
+
return row('quota', `${usage} ${out.out.dim(`· resets ${when}`)}`);
|
|
438
|
+
}
|
|
439
|
+
function runnerText(out, runner, now, lastEnd) {
|
|
440
|
+
if (runner.state === 'starting')
|
|
441
|
+
return 'starting';
|
|
442
|
+
if (runner.state === 'stopped' && lastEnd) {
|
|
443
|
+
const deleted = lastEnd.deletedWorkers;
|
|
444
|
+
const why = deleted.length > 0
|
|
445
|
+
? `${deleted.join(', ')} ${deleted.length === 1 ? 'was' : 'were'} deleted on the server`
|
|
446
|
+
: 'no worker could be registered';
|
|
447
|
+
return (`not running ${out.out.dim(`· ended ${formatLocal(lastEnd.endedAt)} with no active workers (${why})`)}\n` +
|
|
448
|
+
row('', out.out.dim(`'${BIN} start' registers the workers whose files are still there`)));
|
|
449
|
+
}
|
|
450
|
+
if (runner.state === 'stopped')
|
|
451
|
+
return `not running ${out.out.dim(`· '${BIN} start' to run`)}`;
|
|
452
|
+
const { pid, startedAt, detached } = runner.info;
|
|
453
|
+
const where = detached ? 'in the background' : 'in a terminal';
|
|
454
|
+
return `running ${where} (pid ${pid}) ${out.out.dim(`· since ${formatLocal(startedAt)} (${relative(startedAt, now)})`)}`;
|
|
455
|
+
}
|
|
456
|
+
function elapsed(since, now) {
|
|
457
|
+
const ago = relative(Math.min(since, now), now);
|
|
458
|
+
return ago.endsWith(' ago') ? ago.slice(0, -' ago'.length) : 'less than a minute';
|
|
459
|
+
}
|
|
460
|
+
function readLiveState(db) {
|
|
461
|
+
try {
|
|
462
|
+
return db.getLiveState();
|
|
463
|
+
}
|
|
464
|
+
catch {
|
|
465
|
+
return null;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
function liveText(out, running, state, concurrency, now) {
|
|
469
|
+
const what = `live, up to ${concurrency} at a time ${out.out.dim('· fetches when 3Min API signals; idle ≈ 0 calls')}`;
|
|
470
|
+
if (!running)
|
|
471
|
+
return `${what} ${out.out.dim('(when started)')}`;
|
|
472
|
+
if (state === null || state.since === null)
|
|
473
|
+
return `${what}\n${row('', 'connecting…')}`;
|
|
474
|
+
const since = Math.min(Date.parse(state.since), now);
|
|
475
|
+
const reconnects = state.reconnects > 0
|
|
476
|
+
? ` · ${state.reconnects} reconnect${state.reconnects === 1 ? '' : 's'}`
|
|
477
|
+
: '';
|
|
478
|
+
if (state.connected) {
|
|
479
|
+
return `${what}\n${row('', `${out.out.green('connected')} ${out.out.dim(`since ${formatLocal(since)} (${relative(since, now)})${reconnects}`)}`)}`;
|
|
480
|
+
}
|
|
481
|
+
const lines = [
|
|
482
|
+
`${out.out.yellow('disconnected')} for ${elapsed(since, now)} ${out.out.dim(`— no tasks are fetched until it reconnects${reconnects}`)}`,
|
|
483
|
+
...(state.lastError ? [out.out.dim(state.lastError)] : []),
|
|
484
|
+
...(state.everConnected
|
|
485
|
+
? []
|
|
486
|
+
: [
|
|
487
|
+
`never connected this run — on a company network, run '${BIN} doctor' or restart with '${BIN} start --mode poll'`
|
|
488
|
+
])
|
|
489
|
+
];
|
|
490
|
+
return [what, ...lines.map((l) => row('', l))].join('\n');
|
|
491
|
+
}
|
|
492
|
+
function dashboardJson(board) {
|
|
493
|
+
return {
|
|
494
|
+
running: board.state === 'running',
|
|
495
|
+
info: board.state === 'running' ? board.info : null
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
function dashboardText(out, board, now) {
|
|
499
|
+
if (board.state === 'starting')
|
|
500
|
+
return 'starting';
|
|
501
|
+
if (board.state === 'stopped')
|
|
502
|
+
return `not running ${out.out.dim(`· '${BIN} dashboard' to open`)}`;
|
|
503
|
+
const { pid, startedAt, detached, lan, origins } = board.info;
|
|
504
|
+
const where = detached ? 'in the background' : 'in a terminal';
|
|
505
|
+
const scope = lan ? ` · ${out.out.yellow('LAN (plain HTTP)')}` : '';
|
|
506
|
+
const address = origins.length > 0 ? ` · ${origins[lan && origins.length > 1 ? 1 : 0]}` : '';
|
|
507
|
+
return (`running ${where} (pid ${pid})${scope}${address} ${out.out.dim(`· since ${formatLocal(startedAt)} (${relative(startedAt, now)})`)}\n` +
|
|
508
|
+
row('', out.out.dim(`'${BIN} dashboard url' for the link · '${BIN} dashboard stop' to stop`)));
|
|
509
|
+
}
|
|
510
|
+
function serveText(out, port, isPublic, configDir) {
|
|
511
|
+
if (isPublic === undefined)
|
|
512
|
+
return `port ${port}`;
|
|
513
|
+
if (isPublic)
|
|
514
|
+
return `port ${port} on all interfaces ${out.out.dim('(public workers, signed requests only)')}`;
|
|
515
|
+
return `http://127.0.0.1:${port} ${out.out.dim(`(token in ${path.join(configDir, LOCAL_SERVER_TOKEN_FILE)})`)}`;
|
|
516
|
+
}
|
|
517
|
+
function secs(ms) {
|
|
518
|
+
return ms % 1000 === 0 ? `${ms / 1000}s` : `${ms} ms`;
|
|
519
|
+
}
|
|
520
|
+
function projectWorkersDir(config) {
|
|
521
|
+
return config.projectRoot ? workersPath(config.projectRoot, config) : null;
|
|
522
|
+
}
|
|
523
|
+
function workerNamesIn(dir) {
|
|
524
|
+
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory())
|
|
525
|
+
return null;
|
|
526
|
+
try {
|
|
527
|
+
return [...workerFiles(dir).keys()];
|
|
528
|
+
}
|
|
529
|
+
catch {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function savedResults(root) {
|
|
534
|
+
if (!root)
|
|
535
|
+
return null;
|
|
536
|
+
const dbBytes = resultsDbBytes(root);
|
|
537
|
+
if (dbBytes === null)
|
|
538
|
+
return null;
|
|
539
|
+
try {
|
|
540
|
+
const store = ResultStore.openReadOnly(root);
|
|
541
|
+
if (!store)
|
|
542
|
+
return { count: 0, bytes: 0, dbBytes };
|
|
543
|
+
try {
|
|
544
|
+
return { ...store.usage(), dbBytes };
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
store.close();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
catch {
|
|
551
|
+
return null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
function readEnd(session) {
|
|
555
|
+
try {
|
|
556
|
+
return session.db.getEnd();
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
const STATUS_LABEL_WIDTH = 11;
|
|
563
|
+
function row(label, value) {
|
|
564
|
+
return `${label.padEnd(STATUS_LABEL_WIDTH)}${value}`;
|
|
565
|
+
}
|
|
566
|
+
function warnLooseToken(out, token) {
|
|
567
|
+
if (token.source !== 'file')
|
|
568
|
+
return;
|
|
569
|
+
const mode = looseMode(token.location);
|
|
570
|
+
if (mode !== null) {
|
|
571
|
+
out.warn(`${token.location} is readable by other users (mode ${mode.toString(8)}). Run: chmod 600 "${token.location}"`);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ApiClient, userAgent } from "../core/api.js";
|
|
4
|
+
import { resolveHostToken } from "../core/credentials.js";
|
|
5
|
+
import { BIN, CliError, ConfigError, PROJECT_CONFIG_FILE } from "../core/domain/index.js";
|
|
6
|
+
import { initHint } from "../core/project.js";
|
|
7
|
+
import { claimUpdateNotice, readLatest, rememberLatest, updateInfo, updateNoticeLine } from "../core/update-notice.js";
|
|
8
|
+
export function client(ctx, session, token) {
|
|
9
|
+
return new ApiClient({
|
|
10
|
+
apiUrl: session.config.values.apiUrl,
|
|
11
|
+
token,
|
|
12
|
+
userAgent: userAgent(ctx.version, ctx.platform),
|
|
13
|
+
signal: ctx.signal,
|
|
14
|
+
onServerCli: (cli) => rememberLatest(session.db, cli, ctx.now(), (m, a) => session.logger.debug(m, a))
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export function noticeUpdate(ctx, session, running) {
|
|
18
|
+
const log = (m, a) => session.logger.debug(m, a);
|
|
19
|
+
const info = updateInfo(ctx.version, readLatest(session.db, log), running);
|
|
20
|
+
if (claimUpdateNotice(session.db, info, ctx.now(), ctx.env, log)) {
|
|
21
|
+
ctx.out.warn(updateNoticeLine(info));
|
|
22
|
+
}
|
|
23
|
+
return info;
|
|
24
|
+
}
|
|
25
|
+
export function requireToken(ctx) {
|
|
26
|
+
const token = resolveHostToken(ctx.dirs.config, ctx.env);
|
|
27
|
+
if (!token)
|
|
28
|
+
throw new CliError(`Not logged in. Run '${BIN} login' first.`);
|
|
29
|
+
return token;
|
|
30
|
+
}
|
|
31
|
+
export function requireProject(config) {
|
|
32
|
+
if (!config.projectRoot) {
|
|
33
|
+
throw new ConfigError(`this host has no project (no ${PROJECT_CONFIG_FILE} recorded or found in this folder or above). ` +
|
|
34
|
+
initHint());
|
|
35
|
+
}
|
|
36
|
+
return config.projectRoot;
|
|
37
|
+
}
|
|
38
|
+
export function workersPath(root, config) {
|
|
39
|
+
return path.resolve(root, config.values.workersDir);
|
|
40
|
+
}
|
|
41
|
+
export function requireWorkersDir(config) {
|
|
42
|
+
const root = requireProject(config);
|
|
43
|
+
const dir = workersPath(root, config);
|
|
44
|
+
if (!statSync(dir, { throwIfNoEntry: false })?.isDirectory()) {
|
|
45
|
+
throw new CliError(`workers folder ${dir} does not exist. Run '${BIN} init' to create it.`);
|
|
46
|
+
}
|
|
47
|
+
return { root, dir };
|
|
48
|
+
}
|