@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,326 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { BIN, ConfigError, NewerSchemaError, DOCTOR_API_TIMEOUT_MS, ENV, ExitCode, MIN_NODE_VERSION, PROXY_ENV_VARS, isTrustedLiveUrl } from "../core/domain/index.js";
|
|
4
|
+
import { ControlDb, LIVE_META } from "../core/control-db.js";
|
|
5
|
+
import { resolveHostToken } from "../core/credentials.js";
|
|
6
|
+
import { looseMode } from "../core/secure-fs.js";
|
|
7
|
+
import { satisfiesMinNode } from "../core/version.js";
|
|
8
|
+
import { initHint } from "../core/project.js";
|
|
9
|
+
import { recordedLatest, updateCommand, updateInfo } from "../core/update-notice.js";
|
|
10
|
+
import { readRunner } from "../core/host/runner-lock.js";
|
|
11
|
+
export const doctor = {
|
|
12
|
+
name: 'doctor',
|
|
13
|
+
summary: 'Check Node.js, config, network and OS settings',
|
|
14
|
+
usage: '',
|
|
15
|
+
description: 'Does not send your token anywhere and does not count against your quota. The live check\n' +
|
|
16
|
+
'opens a WebSocket to the address the last start recorded, without the token.\n' +
|
|
17
|
+
'Exits with code 3 if any check fails.',
|
|
18
|
+
options: {},
|
|
19
|
+
args: { min: 0, max: 0 },
|
|
20
|
+
async run(ctx) {
|
|
21
|
+
const checks = [checkVersion(ctx), checkNode(), await checkSqlite()];
|
|
22
|
+
let config;
|
|
23
|
+
try {
|
|
24
|
+
config = ctx.loadConfig();
|
|
25
|
+
checks.push(...checkConfig(config, ctx.home));
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
if (!(err instanceof ConfigError))
|
|
29
|
+
throw err;
|
|
30
|
+
checks.push({ name: 'config', status: 'fail', detail: err.message });
|
|
31
|
+
}
|
|
32
|
+
checks.push(checkDirs(ctx), checkToken(ctx), checkProxy(ctx.env));
|
|
33
|
+
if (config)
|
|
34
|
+
checks.push(await checkApi(config), await checkLive(ctx, config));
|
|
35
|
+
if (ctx.platform === 'win32')
|
|
36
|
+
checks.push(checkExecutionPolicy());
|
|
37
|
+
const failed = checks.some((c) => c.status === 'fail');
|
|
38
|
+
ctx.out.result({ ok: !failed, checks }, render(checks, ctx));
|
|
39
|
+
return failed ? ExitCode.Environment : ExitCode.Ok;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
function checkVersion(ctx) {
|
|
43
|
+
const name = 'version';
|
|
44
|
+
let latest = null;
|
|
45
|
+
try {
|
|
46
|
+
const db = ControlDb.openReadOnly(ctx.dirs.data);
|
|
47
|
+
try {
|
|
48
|
+
latest = db ? recordedLatest(db) : null;
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
db?.close();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err instanceof NewerSchemaError) {
|
|
56
|
+
return { name, status: 'warn', detail: err.message, fix: `Run: ${updateCommand()}` };
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
name,
|
|
60
|
+
status: 'warn',
|
|
61
|
+
detail: `${BIN} ${ctx.version} — could not read the recorded latest version: ${err.message}`
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
let running;
|
|
65
|
+
try {
|
|
66
|
+
running = readRunner(ctx.dirs.data).state === 'running';
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
running = null;
|
|
70
|
+
}
|
|
71
|
+
const info = updateInfo(ctx.version, latest, running);
|
|
72
|
+
if (info.available) {
|
|
73
|
+
return {
|
|
74
|
+
name,
|
|
75
|
+
status: 'warn',
|
|
76
|
+
detail: `${BIN} ${info.current} (latest ${info.latest})`,
|
|
77
|
+
fix: `Run: ${info.command}${info.note ? ` — ${info.note}` : ''}`
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return latest === null
|
|
81
|
+
? {
|
|
82
|
+
name,
|
|
83
|
+
status: 'info',
|
|
84
|
+
detail: `${BIN} ${info.current} (latest unknown until the next server call)`
|
|
85
|
+
}
|
|
86
|
+
: { name: 'version', status: 'ok', detail: `${BIN} ${info.current} (latest)` };
|
|
87
|
+
}
|
|
88
|
+
function checkNode() {
|
|
89
|
+
const current = process.versions.node;
|
|
90
|
+
return satisfiesMinNode(current)
|
|
91
|
+
? { name: 'node', status: 'ok', detail: `v${current}` }
|
|
92
|
+
: {
|
|
93
|
+
name: 'node',
|
|
94
|
+
status: 'fail',
|
|
95
|
+
detail: `v${current} (needs ${MIN_NODE_VERSION}+)`,
|
|
96
|
+
fix: 'Install the current Node.js 24 LTS from https://nodejs.org'
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
async function checkSqlite() {
|
|
100
|
+
try {
|
|
101
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
102
|
+
new DatabaseSync(':memory:').close();
|
|
103
|
+
return { name: 'sqlite', status: 'ok', detail: 'node:sqlite available' };
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return {
|
|
107
|
+
name: 'sqlite',
|
|
108
|
+
status: 'fail',
|
|
109
|
+
detail: `node:sqlite unavailable: ${err.message}`,
|
|
110
|
+
fix: `Use the official Node.js ${MIN_NODE_VERSION}+ build`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
function checkConfig(config, home) {
|
|
115
|
+
const checks = [
|
|
116
|
+
{
|
|
117
|
+
name: 'config',
|
|
118
|
+
status: 'ok',
|
|
119
|
+
detail: config.projectFile
|
|
120
|
+
? `project ${config.projectFile}`
|
|
121
|
+
: `this host has no project. ${initHint(home)}`
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: 'api url',
|
|
125
|
+
status: 'info',
|
|
126
|
+
detail: `${config.values.apiUrl} (from ${config.sources.apiUrl})`
|
|
127
|
+
}
|
|
128
|
+
];
|
|
129
|
+
for (const warning of config.warnings)
|
|
130
|
+
checks.push({ name: 'config', status: 'warn', detail: warning });
|
|
131
|
+
return checks;
|
|
132
|
+
}
|
|
133
|
+
function checkDirs({ dirs, env }) {
|
|
134
|
+
const home = env[ENV.home];
|
|
135
|
+
const moved = home && path.isAbsolute(home)
|
|
136
|
+
? ` (${ENV.home} is set — not this PC's default host folder; for development and tests)`
|
|
137
|
+
: '';
|
|
138
|
+
return {
|
|
139
|
+
name: 'folders',
|
|
140
|
+
status: 'info',
|
|
141
|
+
detail: `config ${dirs.config} · data ${dirs.data} · logs ${dirs.logs}${moved}`
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
function checkToken({ dirs, env }) {
|
|
145
|
+
let token;
|
|
146
|
+
try {
|
|
147
|
+
token = resolveHostToken(dirs.config, env);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
return { name: 'token', status: 'fail', detail: err.message };
|
|
151
|
+
}
|
|
152
|
+
if (!token) {
|
|
153
|
+
return { name: 'token', status: 'info', detail: "not logged in (run '3minapi login')" };
|
|
154
|
+
}
|
|
155
|
+
if (token.source === 'env')
|
|
156
|
+
return {
|
|
157
|
+
name: 'token',
|
|
158
|
+
status: 'ok',
|
|
159
|
+
detail: `from ${token.location} — not the token saved by '3minapi login' (for development, CI and tests)`
|
|
160
|
+
};
|
|
161
|
+
const mode = looseMode(token.location);
|
|
162
|
+
return mode === null
|
|
163
|
+
? { name: 'token', status: 'ok', detail: `saved in ${token.location}` }
|
|
164
|
+
: {
|
|
165
|
+
name: 'token',
|
|
166
|
+
status: 'warn',
|
|
167
|
+
detail: `${token.location} is readable by other users (mode ${mode.toString(8)})`,
|
|
168
|
+
fix: `Run: chmod 600 "${token.location}"`
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
function checkProxy(env) {
|
|
172
|
+
const name = PROXY_ENV_VARS.find((v) => env[v]);
|
|
173
|
+
if (!name)
|
|
174
|
+
return { name: 'proxy', status: 'ok', detail: 'no proxy configured' };
|
|
175
|
+
const shown = `${name}=${redactUrl(env[name])}`;
|
|
176
|
+
const enabled = env.NODE_USE_ENV_PROXY === '1' || process.execArgv.includes('--use-env-proxy');
|
|
177
|
+
return enabled
|
|
178
|
+
? { name: 'proxy', status: 'ok', detail: `${shown} (NODE_USE_ENV_PROXY=1)` }
|
|
179
|
+
: {
|
|
180
|
+
name: 'proxy',
|
|
181
|
+
status: 'warn',
|
|
182
|
+
detail: `${shown} is set, but Node.js ignores it`,
|
|
183
|
+
fix: 'Set NODE_USE_ENV_PROXY=1 so connections go through the proxy'
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function redactUrl(value) {
|
|
187
|
+
try {
|
|
188
|
+
const u = new URL(value);
|
|
189
|
+
if (u.username || u.password) {
|
|
190
|
+
u.username = '***';
|
|
191
|
+
u.password = '';
|
|
192
|
+
}
|
|
193
|
+
return u.toString();
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return '(unparseable)';
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
async function checkApi(config) {
|
|
200
|
+
const url = config.values.apiUrl;
|
|
201
|
+
try {
|
|
202
|
+
const res = await fetch(url, {
|
|
203
|
+
redirect: 'manual',
|
|
204
|
+
signal: AbortSignal.timeout(DOCTOR_API_TIMEOUT_MS)
|
|
205
|
+
});
|
|
206
|
+
await res.body?.cancel();
|
|
207
|
+
return { name: 'api', status: 'ok', detail: `${url} reachable (HTTP ${res.status})` };
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
const e = err;
|
|
211
|
+
const reason = e.name === 'TimeoutError'
|
|
212
|
+
? `no response within ${DOCTOR_API_TIMEOUT_MS / 1000}s`
|
|
213
|
+
: (e.cause?.code ?? e.cause?.message ?? e.message);
|
|
214
|
+
return {
|
|
215
|
+
name: 'api',
|
|
216
|
+
status: 'fail',
|
|
217
|
+
detail: `${url} unreachable: ${reason}`,
|
|
218
|
+
fix: 'Check your network, firewall or proxy settings'
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function checkLive(ctx, config) {
|
|
223
|
+
const name = 'live';
|
|
224
|
+
let url = null;
|
|
225
|
+
try {
|
|
226
|
+
const db = ControlDb.openReadOnly(ctx.dirs.data);
|
|
227
|
+
try {
|
|
228
|
+
url = db?.getMeta(LIVE_META.url) ?? null;
|
|
229
|
+
}
|
|
230
|
+
finally {
|
|
231
|
+
db?.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
return {
|
|
236
|
+
name,
|
|
237
|
+
status: 'warn',
|
|
238
|
+
detail: `could not read the recorded live address: ${err.message}`,
|
|
239
|
+
fix: "Run '3minapi doctor' again after 'start' stops, or check the permissions of the data folder ('3minapi paths')"
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (url === null) {
|
|
243
|
+
return {
|
|
244
|
+
name,
|
|
245
|
+
status: 'info',
|
|
246
|
+
detail: `not checked yet — '3minapi start' records the address`
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
if (!isTrustedLiveUrl(url, config.values.apiUrl)) {
|
|
250
|
+
return {
|
|
251
|
+
name,
|
|
252
|
+
status: 'info',
|
|
253
|
+
detail: `not checked — the recorded address is for another API URL`
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
const opened = await new Promise((resolve) => {
|
|
257
|
+
let ws;
|
|
258
|
+
try {
|
|
259
|
+
ws = new WebSocket(url);
|
|
260
|
+
}
|
|
261
|
+
catch {
|
|
262
|
+
resolve(false);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const timer = setTimeout(() => {
|
|
266
|
+
done(false);
|
|
267
|
+
}, DOCTOR_API_TIMEOUT_MS);
|
|
268
|
+
const done = (ok) => {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
try {
|
|
271
|
+
ws.close();
|
|
272
|
+
}
|
|
273
|
+
catch {
|
|
274
|
+
}
|
|
275
|
+
resolve(ok);
|
|
276
|
+
};
|
|
277
|
+
ws.addEventListener('open', () => done(true), { once: true });
|
|
278
|
+
ws.addEventListener('close', () => done(false), { once: true });
|
|
279
|
+
});
|
|
280
|
+
if (opened)
|
|
281
|
+
return { name, status: 'ok', detail: `${url} reachable (WebSocket)` };
|
|
282
|
+
const proxy = PROXY_ENV_VARS.find((v) => ctx.env[v]);
|
|
283
|
+
return {
|
|
284
|
+
name,
|
|
285
|
+
status: 'warn',
|
|
286
|
+
detail: `could not open a WebSocket to ${url} — a proxy, firewall or the network may block it` +
|
|
287
|
+
(proxy ? ` (proxy ${proxy} is set)` : ''),
|
|
288
|
+
fix: "Run '3minapi start --mode poll' to fetch tasks at an interval instead"
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
function checkExecutionPolicy() {
|
|
292
|
+
const { PSModulePath: _inherited, ...env } = process.env;
|
|
293
|
+
const r = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', 'Get-ExecutionPolicy'], { encoding: 'utf8', timeout: 10_000, windowsHide: true, env });
|
|
294
|
+
const policy = r.stdout?.trim();
|
|
295
|
+
if (r.status !== 0 || !policy) {
|
|
296
|
+
const reason = (r.error?.message ?? r.stderr?.trim().split(/\r?\n/)[0]) || `exit ${r.status}`;
|
|
297
|
+
return {
|
|
298
|
+
name: 'powershell',
|
|
299
|
+
status: 'info',
|
|
300
|
+
detail: `could not read the execution policy (${reason})`
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (policy === 'Restricted' || policy === 'AllSigned') {
|
|
304
|
+
return {
|
|
305
|
+
name: 'powershell',
|
|
306
|
+
status: 'warn',
|
|
307
|
+
detail: `execution policy is ${policy}: '3minapi' will not start from PowerShell`,
|
|
308
|
+
fix: "Run 'Set-ExecutionPolicy -Scope CurrentUser RemoteSigned', or use '3minapi.cmd'"
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return { name: 'powershell', status: 'ok', detail: `execution policy ${policy}` };
|
|
312
|
+
}
|
|
313
|
+
function render(checks, { out }) {
|
|
314
|
+
const label = {
|
|
315
|
+
ok: out.out.green('ok '),
|
|
316
|
+
warn: out.out.yellow('warn'),
|
|
317
|
+
fail: out.out.red('FAIL'),
|
|
318
|
+
info: out.out.dim('info')
|
|
319
|
+
};
|
|
320
|
+
const width = Math.max(...checks.map((c) => c.name.length)) + 2;
|
|
321
|
+
const lines = checks.flatMap((c) => {
|
|
322
|
+
const line = `${label[c.status]} ${c.name.padEnd(width)}${c.detail}`;
|
|
323
|
+
return c.fix ? [line, ` ${' '.repeat(width)}${out.out.dim('-> ' + c.fix)}`] : [line];
|
|
324
|
+
});
|
|
325
|
+
return lines.join('\n');
|
|
326
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { AGENTS_FILE, BIN, CLAUDE_FILE, CONTROL_DB_FILE, CREDENTIALS_FILE, DEFAULT_WORKERS_DIR, ExitCode, LOCAL_SERVER_TOKEN_FILE, PROJECT_CONFIG_FILE, PROJECT_RECORD_FILE, RESULT_TABLE_PREFIX, USER_DB_FILE } from "../core/domain/index.js";
|
|
2
|
+
import { DOTENV_FILE } from "../modules/workers/runtime/env.js";
|
|
3
|
+
import { HANDLER_DOC, SETTINGS } from "../modules/workers/template.js";
|
|
4
|
+
export const GENERATED_MARKER = `<!-- generated by ${BIN}`;
|
|
5
|
+
export function agentGuide(version) {
|
|
6
|
+
return `# ${BIN} project — guide for AI agents
|
|
7
|
+
|
|
8
|
+
This folder is a ${BIN} project. The user's machine runs as a 3Min API host: an AI creates tasks, and
|
|
9
|
+
\`${BIN} start\` fetches them and runs them with the worker files here. You help the user write, test and run
|
|
10
|
+
those workers. Every command below works from any folder and takes \`--json\` for machine-readable output.
|
|
11
|
+
|
|
12
|
+
## Edit
|
|
13
|
+
|
|
14
|
+
- **Worker files** — \`${DEFAULT_WORKERS_DIR}/<name>.mjs\` (or \`.mts\`, \`.js\`, \`.ts\`); one file is one worker.
|
|
15
|
+
Start one with \`${BIN} worker new <name>\` (\`--ts\` for TypeScript) instead of writing it from scratch.
|
|
16
|
+
\`${BIN} paths\` shows the workers folder if \`${PROJECT_CONFIG_FILE}\` moved it.
|
|
17
|
+
- **\`${DOTENV_FILE}\`** — secrets the workers read through \`ctx.env\`. Write the values the user gives you, but never
|
|
18
|
+
print them back, log them or put them in a result.
|
|
19
|
+
- **\`${PROJECT_CONFIG_FILE}\`** — project settings. \`receiveMode\` is \`live\` (default) or \`poll\`; in poll mode
|
|
20
|
+
\`pollIntervalMs\` sets how often \`start\` fetches, and every fetch is an API call counted against the quota.
|
|
21
|
+
- **\`package.json\`** — only when a worker needs a library. \`npm init -y\` writes \`"type": "commonjs"\`, which
|
|
22
|
+
breaks \`.js\`/\`.ts\` workers that use \`export\`: keep workers as \`.mjs\`/\`.mts\`, or set \`"type": "module"\`.
|
|
23
|
+
|
|
24
|
+
## Run
|
|
25
|
+
|
|
26
|
+
| Command | Use |
|
|
27
|
+
|---|---|
|
|
28
|
+
| \`${BIN} worker run <name> --input '<json>'\` | Test a worker on this machine — no task, no API call (\`--input-file\` avoids shell quoting) |
|
|
29
|
+
| \`${BIN} worker list\` | Check that every worker file loads |
|
|
30
|
+
| \`${BIN} reload\` | After adding or renaming a worker, or changing a \`description\` — until then the AI sees the old one |
|
|
31
|
+
| \`${BIN} status\` | Whether \`start\` is running, its workers, the quota left |
|
|
32
|
+
| \`${BIN} logs\` / \`${BIN} worker logs <name>\` | The CLI's log / one worker's log |
|
|
33
|
+
| \`${BIN} worker results list\` / \`show <task_id>\` / \`query "<SQL>"\` | Results saved in \`${USER_DB_FILE}\` |
|
|
34
|
+
| \`${BIN} start --detach\` / \`${BIN} stop\` | Run the host in the background / stop it |
|
|
35
|
+
| \`${BIN} doctor\` | Check Node.js, config, network and proxy settings |
|
|
36
|
+
| \`${BIN} paths\` | Absolute paths of the files on this page |
|
|
37
|
+
|
|
38
|
+
Handler code and \`${DOTENV_FILE}\` edits apply to the next task with no reload. Ask the user before
|
|
39
|
+
\`${BIN} worker delete <name> --yes\` (it also deletes the worker and its task history on 3Min API) and before any
|
|
40
|
+
\`prune --yes\`.
|
|
41
|
+
|
|
42
|
+
## Do not touch
|
|
43
|
+
|
|
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.
|
|
46
|
+
- **\`${CONTROL_DB_FILE}\`, \`runner.*\`, \`${PROJECT_RECORD_FILE}\`** — internal state. Use \`${BIN} tasks prune\` to clear history.
|
|
47
|
+
- **\`${LOCAL_SERVER_TOKEN_FILE}\`** — only for a local script that calls \`${BIN} start --serve\`.
|
|
48
|
+
- **Tables starting with \`${RESULT_TABLE_PREFIX}\` in \`${USER_DB_FILE}\`** — read them, never write them.
|
|
49
|
+
|
|
50
|
+
## Worker file
|
|
51
|
+
|
|
52
|
+
\`\`\`js
|
|
53
|
+
${SETTINGS}
|
|
54
|
+
|
|
55
|
+
${HANDLER_DOC}
|
|
56
|
+
export async function handler(input, ctx) {
|
|
57
|
+
// ...
|
|
58
|
+
return { ok: true };
|
|
59
|
+
}
|
|
60
|
+
\`\`\`
|
|
61
|
+
|
|
62
|
+
This text matches ${BIN} v${version}. Run \`${BIN} guide\` for the version installed now.
|
|
63
|
+
`;
|
|
64
|
+
}
|
|
65
|
+
export function agentsFileText(version) {
|
|
66
|
+
return (`${GENERATED_MARKER} v${version}. '${BIN} init' replaces this file while this line is here — ` +
|
|
67
|
+
`delete the line to keep your own edits. -->\n\n${agentGuide(version)}`);
|
|
68
|
+
}
|
|
69
|
+
export const CLAUDE_FILE_TEXT = `@${AGENTS_FILE}\n`;
|
|
70
|
+
export const guide = {
|
|
71
|
+
name: 'guide',
|
|
72
|
+
summary: 'Print the guide for AI agents working in this project (also written to AGENTS.md by init)',
|
|
73
|
+
usage: '',
|
|
74
|
+
description: 'Which files an AI agent edits, which commands it runs, which files it never touches, and how a\n' +
|
|
75
|
+
`worker file is shaped. '${BIN} init' writes the same text to ${AGENTS_FILE}, plus a ${CLAUDE_FILE} that\n` +
|
|
76
|
+
'imports it for Claude Code. No API call; works without a project.',
|
|
77
|
+
options: {},
|
|
78
|
+
args: { min: 0, max: 0 },
|
|
79
|
+
async run(ctx) {
|
|
80
|
+
const text = agentGuide(ctx.version);
|
|
81
|
+
ctx.out.result({ version: ctx.version, guide: text }, text.trimEnd());
|
|
82
|
+
return ExitCode.Ok;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { login, logout, status } from "./auth.js";
|
|
2
|
+
import { dashboard } from "./dashboard.js";
|
|
3
|
+
import { doctor } from "./doctor.js";
|
|
4
|
+
import { guide } from "./guide.js";
|
|
5
|
+
import { init } from "./init.js";
|
|
6
|
+
import { logs } from "./logs.js";
|
|
7
|
+
import { paths } from "./paths.js";
|
|
8
|
+
import { reload } from "./reload.js";
|
|
9
|
+
import { start } from "./start.js";
|
|
10
|
+
import { stop } from "./stop.js";
|
|
11
|
+
import { tasks } from "./tasks.js";
|
|
12
|
+
import { worker } from "./worker.js";
|
|
13
|
+
export const commands = [
|
|
14
|
+
login,
|
|
15
|
+
init,
|
|
16
|
+
worker,
|
|
17
|
+
start,
|
|
18
|
+
reload,
|
|
19
|
+
stop,
|
|
20
|
+
status,
|
|
21
|
+
logs,
|
|
22
|
+
dashboard,
|
|
23
|
+
tasks,
|
|
24
|
+
paths,
|
|
25
|
+
guide,
|
|
26
|
+
logout,
|
|
27
|
+
doctor
|
|
28
|
+
];
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { AGENTS_FILE, BIN, CLAUDE_FILE, CliError, ConfigError, DEFAULT_WORKERS_DIR, ExitCode, GITIGNORE_ENTRIES, PROJECT_CONFIG_FILE, USER_DB_FILE, UsageError } from "../core/domain/index.js";
|
|
4
|
+
import { readRunner } from "../core/host/runner-lock.js";
|
|
5
|
+
import { findProjectFile } from "../core/config.js";
|
|
6
|
+
import { confirm } from "../core/prompt.js";
|
|
7
|
+
import { readUserText } from "../core/text-file.js";
|
|
8
|
+
import { shellArg } from "../core/text.js";
|
|
9
|
+
import { defaultProjectRoot, projectAlive, readProjectRecord, samePath, writeProjectRecord } from "../core/project.js";
|
|
10
|
+
import { ResultStore } from "../modules/workers/results/store.js";
|
|
11
|
+
import { DOTENV_FILE } from "../modules/workers/runtime/env.js";
|
|
12
|
+
import { agentsFileText, CLAUDE_FILE_TEXT, GENERATED_MARKER } from "./guide.js";
|
|
13
|
+
export const init = {
|
|
14
|
+
name: 'init',
|
|
15
|
+
summary: `Create the host's project (default ~/3minapi): ${PROJECT_CONFIG_FILE}, a workers/ folder, ${USER_DB_FILE} and ${AGENTS_FILE}`,
|
|
16
|
+
usage: '[folder] [--move] [--yes]',
|
|
17
|
+
description: 'A host has one project. Other commands use it from any folder — no need to cd into it.\n' +
|
|
18
|
+
' 3minapi init ~/3minapi (Windows: %USERPROFILE%\\3minapi). Asks first; without a terminal pass --yes\n' +
|
|
19
|
+
' 3minapi init <folder> that folder, created if missing\n' +
|
|
20
|
+
' 3minapi init . the current folder\n' +
|
|
21
|
+
'Inside the project (or once the host has one) plain init repairs it. Making your home folder or /\n' +
|
|
22
|
+
'the project asks first.\n' +
|
|
23
|
+
'Safe to run again: existing files are left untouched and only missing parts are added.\n' +
|
|
24
|
+
`${AGENTS_FILE} tells AI coding agents how to work here (the '${BIN} guide' text); ${CLAUDE_FILE} imports it\n` +
|
|
25
|
+
`for Claude Code. init refreshes ${AGENTS_FILE} to this version while its first line is the generated marker.\n` +
|
|
26
|
+
'AI agents and scripts: name the folder or pass --yes, and read the absolute paths from --json (root).\n' +
|
|
27
|
+
'\n' +
|
|
28
|
+
'Moving the project:\n' +
|
|
29
|
+
" with its files '3minapi stop', move the folder, then run any command inside it ('3minapi status')\n" +
|
|
30
|
+
' — the new place is picked up automatically\n' +
|
|
31
|
+
" to another folder '3minapi init <folder> --move' — nothing is copied: the old folder keeps its\n" +
|
|
32
|
+
' data.db, workers and .env\n' +
|
|
33
|
+
'\n' +
|
|
34
|
+
`${USER_DB_FILE} is a SQLite file. 'start' saves every task result in it (tables starting with\n` +
|
|
35
|
+
"_3minapi_), and your workers can keep their own data there — see the note in 'worker new' files.\n" +
|
|
36
|
+
'Saved results may contain personal data; nothing deletes them automatically.',
|
|
37
|
+
options: {
|
|
38
|
+
move: {
|
|
39
|
+
type: 'boolean',
|
|
40
|
+
description: "Make the folder the host's project instead of the current one"
|
|
41
|
+
},
|
|
42
|
+
yes: {
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
description: 'Create the project without asking (~/3minapi, your home folder or /)'
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
args: { min: 0, max: 1 },
|
|
48
|
+
async run(ctx) {
|
|
49
|
+
const { out, dirs, platform, values, now } = ctx;
|
|
50
|
+
const record = readProjectRecord(dirs.data);
|
|
51
|
+
const recordAlive = record !== null && projectAlive(record.root);
|
|
52
|
+
const target = initTarget(ctx, recordAlive ? record.root : null);
|
|
53
|
+
const root = target.root;
|
|
54
|
+
const stat = statSync(root, { throwIfNoEntry: false });
|
|
55
|
+
if (stat && !stat.isDirectory()) {
|
|
56
|
+
throw new ConfigError(`${root} is a file, not a folder. Run '${BIN} init <folder>'.`);
|
|
57
|
+
}
|
|
58
|
+
const isProject = projectAlive(root);
|
|
59
|
+
if (target.kind === 'default' && stat && !isProject && hasUserFiles(root)) {
|
|
60
|
+
throw new ConfigError(`${root} already exists and is not a 3minapi project. ` +
|
|
61
|
+
`Run '${BIN} init <folder>' to use another folder, or '${BIN} init ${shellArg(root)}' to use it anyway.`);
|
|
62
|
+
}
|
|
63
|
+
const other = recordAlive && !samePath(record.root, root, platform) ? record.root : null;
|
|
64
|
+
if (other !== null && !values.move) {
|
|
65
|
+
throw new ConfigError(`this host's project is ${other} — a host has one project.\n` +
|
|
66
|
+
`To make ${root} the project instead, run '${BIN} init ${shellArg(root)} --move' ` +
|
|
67
|
+
`(nothing is copied: ${other} keeps its ${USER_DB_FILE}, workers and .env).`);
|
|
68
|
+
}
|
|
69
|
+
if (other !== null && readRunner(dirs.data).state !== 'stopped') {
|
|
70
|
+
throw new CliError(`'${BIN} start' is running with the project ${other}. Run '${BIN} stop' first, then '${BIN} init ${shellArg(root)} --move'.`);
|
|
71
|
+
}
|
|
72
|
+
if (target.kind === 'default' && record !== null && !recordAlive && !isProject) {
|
|
73
|
+
out.warn(`this host's project ${record.root} no longer exists. If you moved it, run '${BIN} status' ` +
|
|
74
|
+
'inside the new folder instead of creating a new project.');
|
|
75
|
+
}
|
|
76
|
+
if (!isProject && needsConfirm(ctx, target.kind, root)) {
|
|
77
|
+
await agree(ctx, target.kind, root);
|
|
78
|
+
}
|
|
79
|
+
if (!stat)
|
|
80
|
+
mkdirSync(root, { recursive: true });
|
|
81
|
+
const created = stat ? [] : [root + path.sep];
|
|
82
|
+
const kept = [];
|
|
83
|
+
const configFile = path.join(root, PROJECT_CONFIG_FILE);
|
|
84
|
+
let workersDir = DEFAULT_WORKERS_DIR;
|
|
85
|
+
if (existsSync(configFile)) {
|
|
86
|
+
kept.push(configFile);
|
|
87
|
+
try {
|
|
88
|
+
const dir = JSON.parse(readUserText(configFile)).workersDir;
|
|
89
|
+
if (typeof dir === 'string' && dir.trim() !== '' && !path.isAbsolute(dir))
|
|
90
|
+
workersDir = dir;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
out.warn(err instanceof ConfigError
|
|
94
|
+
? err.message
|
|
95
|
+
: `${PROJECT_CONFIG_FILE} is not valid JSON — run '3minapi doctor'`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
writeFileSync(configFile, JSON.stringify({ workersDir }, null, '\t') + '\n', { flag: 'wx' });
|
|
100
|
+
created.push(configFile);
|
|
101
|
+
}
|
|
102
|
+
const workersPath = path.resolve(root, workersDir);
|
|
103
|
+
if (existsSync(workersPath)) {
|
|
104
|
+
kept.push(workersPath + path.sep);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
mkdirSync(workersPath, { recursive: true });
|
|
108
|
+
created.push(workersPath + path.sep);
|
|
109
|
+
}
|
|
110
|
+
const db = path.join(root, USER_DB_FILE);
|
|
111
|
+
let dbCreated = false;
|
|
112
|
+
if (existsSync(db)) {
|
|
113
|
+
kept.push(db);
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
ResultStore.open(root).store.close();
|
|
117
|
+
created.push(db);
|
|
118
|
+
dbCreated = true;
|
|
119
|
+
}
|
|
120
|
+
const gitignore = path.join(root, '.gitignore');
|
|
121
|
+
const current = existsSync(gitignore) ? readFileSync(gitignore, 'utf8') : null;
|
|
122
|
+
const lines = new Set((current ?? '').split(/\r?\n/).map((l) => l.trim()));
|
|
123
|
+
const missing = GITIGNORE_ENTRIES.filter((entry) => !lines.has(entry));
|
|
124
|
+
if (missing.length === 0) {
|
|
125
|
+
kept.push(gitignore);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
const prefix = current === null || current === '' || current.endsWith('\n') ? '' : '\n';
|
|
129
|
+
writeFileSync(gitignore, `${current ?? ''}${prefix}${missing.join('\n')}\n`);
|
|
130
|
+
created.push(current === null ? gitignore : `${gitignore} (added ${missing.join(', ')})`);
|
|
131
|
+
}
|
|
132
|
+
const agents = writeAgentFiles(root, ctx.version);
|
|
133
|
+
created.push(...agents.created);
|
|
134
|
+
kept.push(...agents.kept);
|
|
135
|
+
const recorded = record === null || !samePath(record.root, root, platform);
|
|
136
|
+
if (recorded)
|
|
137
|
+
writeProjectRecord(dirs.data, root, now());
|
|
138
|
+
const env = path.join(root, DOTENV_FILE);
|
|
139
|
+
const output = [
|
|
140
|
+
...(other !== null
|
|
141
|
+
? [
|
|
142
|
+
`${out.out.green('moved')} this host's project is now ${root}`,
|
|
143
|
+
out.out.dim(` ${other} was left as is — its ${USER_DB_FILE} (saved results), workers and .env were not moved.`)
|
|
144
|
+
]
|
|
145
|
+
: []),
|
|
146
|
+
...created.map((f) => `${out.out.green('created')} ${f}`),
|
|
147
|
+
...agents.updated.map((f) => `${out.out.green('updated')} ${f}`),
|
|
148
|
+
...kept.map((f) => `${out.out.dim('exists')} ${f}`),
|
|
149
|
+
`${out.out.dim('env')} ${env}${existsSync(env) ? '' : out.out.dim(' (create it when a worker needs secrets)')}`
|
|
150
|
+
];
|
|
151
|
+
if (agents.claudeWithoutImport) {
|
|
152
|
+
output.push('', `${CLAUDE_FILE} exists without '@${AGENTS_FILE}' — Claude Code reads ${AGENTS_FILE} only through it. Add that line to use the guide.`);
|
|
153
|
+
}
|
|
154
|
+
if (dbCreated) {
|
|
155
|
+
output.push('', `${USER_DB_FILE} keeps every task result this machine runs — they may contain personal data and`, `are never deleted automatically ('${BIN} worker results prune'). It is in .gitignore.`);
|
|
156
|
+
}
|
|
157
|
+
if (created.includes(configFile) || created.includes(workersPath + path.sep)) {
|
|
158
|
+
output.push('', `Next: ${BIN} worker new <name> ${out.out.dim('(works from any folder)')}`, ` edit workers in ${workersPath}`);
|
|
159
|
+
}
|
|
160
|
+
out.result({
|
|
161
|
+
root,
|
|
162
|
+
workersDir: workersPath,
|
|
163
|
+
configFile,
|
|
164
|
+
env,
|
|
165
|
+
db,
|
|
166
|
+
agentsFile: path.join(root, AGENTS_FILE),
|
|
167
|
+
created,
|
|
168
|
+
updated: agents.updated,
|
|
169
|
+
kept,
|
|
170
|
+
moved: other !== null,
|
|
171
|
+
previousRoot: other
|
|
172
|
+
}, output.join('\n'));
|
|
173
|
+
return ExitCode.Ok;
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
const OS_METADATA_FILES = new Set(['.DS_Store', 'Thumbs.db', 'desktop.ini']);
|
|
177
|
+
function hasUserFiles(dir) {
|
|
178
|
+
return readdirSync(dir).some((name) => !OS_METADATA_FILES.has(name));
|
|
179
|
+
}
|
|
180
|
+
function initTarget(ctx, recorded) {
|
|
181
|
+
const arg = ctx.positionals[0];
|
|
182
|
+
if (arg !== undefined)
|
|
183
|
+
return { root: path.resolve(ctx.cwd, arg), kind: 'explicit' };
|
|
184
|
+
const found = findProjectFile(ctx.cwd);
|
|
185
|
+
if (found)
|
|
186
|
+
return { root: path.dirname(found), kind: 'existing' };
|
|
187
|
+
if (recorded !== null && !ctx.values.move)
|
|
188
|
+
return { root: recorded, kind: 'existing' };
|
|
189
|
+
return { root: defaultProjectRoot(ctx.home), kind: 'default' };
|
|
190
|
+
}
|
|
191
|
+
function needsConfirm(ctx, kind, root) {
|
|
192
|
+
if (kind === 'default')
|
|
193
|
+
return true;
|
|
194
|
+
return samePath(root, ctx.home, ctx.platform) || path.parse(root).root === root;
|
|
195
|
+
}
|
|
196
|
+
async function agree(ctx, kind, root) {
|
|
197
|
+
const { out } = ctx;
|
|
198
|
+
if (ctx.values.yes)
|
|
199
|
+
return;
|
|
200
|
+
const rerun = kind === 'default' ? `${BIN} init --yes` : `${BIN} init ${shellArg(root)} --yes`;
|
|
201
|
+
if (out.json || !ctx.stdin.isTTY) {
|
|
202
|
+
throw new UsageError(`init creates the project in ${root} and needs agreement. Nothing was created. ` +
|
|
203
|
+
`Once the user has agreed, run: ${rerun} — or name another folder: ${BIN} init <folder>`);
|
|
204
|
+
}
|
|
205
|
+
const where = kind === 'default'
|
|
206
|
+
? root
|
|
207
|
+
: `${root} (${path.parse(root).root === root ? 'the filesystem root' : 'your home folder'})`;
|
|
208
|
+
const yes = await confirm(ctx.stdin, out, `Create the 3minapi project in ${where}? It adds ${PROJECT_CONFIG_FILE}, workers/, ${USER_DB_FILE}, .gitignore, ${AGENTS_FILE} and ${CLAUDE_FILE}.`);
|
|
209
|
+
if (!yes) {
|
|
210
|
+
throw new CliError(`cancelled — nothing was created. To use another folder: ${BIN} init <folder>`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function writeAgentFiles(root, version) {
|
|
214
|
+
const result = {
|
|
215
|
+
created: [],
|
|
216
|
+
updated: [],
|
|
217
|
+
kept: [],
|
|
218
|
+
claudeWithoutImport: false
|
|
219
|
+
};
|
|
220
|
+
const agents = path.join(root, AGENTS_FILE);
|
|
221
|
+
const text = agentsFileText(version);
|
|
222
|
+
const current = existsSync(agents)
|
|
223
|
+
? readFileSync(agents, 'utf8')
|
|
224
|
+
.replace(/^\uFEFF/, '')
|
|
225
|
+
.replace(/\r\n/g, '\n')
|
|
226
|
+
: null;
|
|
227
|
+
if (current === null) {
|
|
228
|
+
writeFileSync(agents, text, { flag: 'wx' });
|
|
229
|
+
result.created.push(agents);
|
|
230
|
+
}
|
|
231
|
+
else if (current.startsWith(GENERATED_MARKER) && current !== text) {
|
|
232
|
+
writeFileSync(agents, text);
|
|
233
|
+
result.updated.push(agents);
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
result.kept.push(agents);
|
|
237
|
+
}
|
|
238
|
+
const claude = path.join(root, CLAUDE_FILE);
|
|
239
|
+
if (!existsSync(claude)) {
|
|
240
|
+
writeFileSync(claude, CLAUDE_FILE_TEXT, { flag: 'wx' });
|
|
241
|
+
result.created.push(claude);
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
result.kept.push(claude);
|
|
245
|
+
result.claudeWithoutImport = !/^\s*@AGENTS\.md\s*$/m.test(readFileSync(claude, 'utf8'));
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
}
|