@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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +82 -2
  3. package/dist/bin.js +54 -0
  4. package/dist/cli.js +241 -0
  5. package/dist/commands/auth.js +573 -0
  6. package/dist/commands/common.js +48 -0
  7. package/dist/commands/dashboard.js +298 -0
  8. package/dist/commands/delete.js +167 -0
  9. package/dist/commands/doctor.js +326 -0
  10. package/dist/commands/guide.js +84 -0
  11. package/dist/commands/index.js +28 -0
  12. package/dist/commands/init.js +248 -0
  13. package/dist/commands/logs.js +154 -0
  14. package/dist/commands/new.js +109 -0
  15. package/dist/commands/paths.js +64 -0
  16. package/dist/commands/reload.js +32 -0
  17. package/dist/commands/results.js +445 -0
  18. package/dist/commands/start.js +443 -0
  19. package/dist/commands/stop.js +68 -0
  20. package/dist/commands/tasks.js +93 -0
  21. package/dist/commands/worker.js +178 -0
  22. package/dist/core/api.js +132 -0
  23. package/dist/core/config.js +188 -0
  24. package/dist/core/control-db.js +462 -0
  25. package/dist/core/credentials.js +75 -0
  26. package/dist/core/domain/constants.js +121 -0
  27. package/dist/core/domain/contract.js +102 -0
  28. package/dist/core/domain/errors.js +57 -0
  29. package/dist/core/domain/index.js +3 -0
  30. package/dist/core/domain/types.js +1 -0
  31. package/dist/core/error-report.js +103 -0
  32. package/dist/core/host/detach.js +49 -0
  33. package/dist/core/host/live-connection.js +270 -0
  34. package/dist/core/host/live-receiver.js +264 -0
  35. package/dist/core/host/module.js +1 -0
  36. package/dist/core/host/poller.js +113 -0
  37. package/dist/core/host/pull-step.js +54 -0
  38. package/dist/core/host/receiver.js +1 -0
  39. package/dist/core/host/reload.js +34 -0
  40. package/dist/core/host/runner-lock.js +245 -0
  41. package/dist/core/host/session.js +84 -0
  42. package/dist/core/host/usage.js +20 -0
  43. package/dist/core/log-read.js +59 -0
  44. package/dist/core/logger.js +206 -0
  45. package/dist/core/names.js +27 -0
  46. package/dist/core/network.js +10 -0
  47. package/dist/core/open-browser.js +26 -0
  48. package/dist/core/output.js +68 -0
  49. package/dist/core/paths.js +26 -0
  50. package/dist/core/project.js +85 -0
  51. package/dist/core/prompt.js +140 -0
  52. package/dist/core/redact.js +45 -0
  53. package/dist/core/secure-fs.js +50 -0
  54. package/dist/core/text-file.js +12 -0
  55. package/dist/core/text.js +27 -0
  56. package/dist/core/time.js +84 -0
  57. package/dist/core/update-notice.js +142 -0
  58. package/dist/core/version.js +16 -0
  59. package/dist/core/warnings.js +10 -0
  60. package/dist/help.js +144 -0
  61. package/dist/modules/dashboard/assets.js +411 -0
  62. package/dist/modules/dashboard/data.js +565 -0
  63. package/dist/modules/dashboard/guard.js +75 -0
  64. package/dist/modules/dashboard/lock.js +93 -0
  65. package/dist/modules/dashboard/server.js +295 -0
  66. package/dist/modules/dashboard/views/html.js +114 -0
  67. package/dist/modules/dashboard/views/layout.js +59 -0
  68. package/dist/modules/dashboard/views/logs.js +122 -0
  69. package/dist/modules/dashboard/views/overview.js +316 -0
  70. package/dist/modules/dashboard/views/runs.js +486 -0
  71. package/dist/modules/dashboard/views/workers.js +157 -0
  72. package/dist/modules/workers/builtin/host-identity.js +59 -0
  73. package/dist/modules/workers/builtin/results-worker.js +37 -0
  74. package/dist/modules/workers/local-server.js +244 -0
  75. package/dist/modules/workers/module.js +98 -0
  76. package/dist/modules/workers/reload-result.js +16 -0
  77. package/dist/modules/workers/repeat-scheduler.js +165 -0
  78. package/dist/modules/workers/results/missing-paths.js +66 -0
  79. package/dist/modules/workers/results/query.js +175 -0
  80. package/dist/modules/workers/results/reader-input.js +134 -0
  81. package/dist/modules/workers/results/reader.js +430 -0
  82. package/dist/modules/workers/results/results-child.js +64 -0
  83. package/dist/modules/workers/results/shape.js +212 -0
  84. package/dist/modules/workers/results/store.js +336 -0
  85. package/dist/modules/workers/results/substitute.js +30 -0
  86. package/dist/modules/workers/runtime/env.js +43 -0
  87. package/dist/modules/workers/runtime/error-chain.js +109 -0
  88. package/dist/modules/workers/runtime/executor.js +289 -0
  89. package/dist/modules/workers/runtime/inspect.js +53 -0
  90. package/dist/modules/workers/runtime/protocol.js +1 -0
  91. package/dist/modules/workers/runtime/resolve.js +30 -0
  92. package/dist/modules/workers/runtime/schema.js +85 -0
  93. package/dist/modules/workers/runtime/thread.js +117 -0
  94. package/dist/modules/workers/start.js +265 -0
  95. package/dist/modules/workers/sync.js +241 -0
  96. package/dist/modules/workers/task-runner.js +892 -0
  97. package/dist/modules/workers/template.js +100 -0
  98. package/dist/modules/workers/worker-set.js +122 -0
  99. package/dist/modules/workers/workers.js +66 -0
  100. package/package.json +48 -5
@@ -0,0 +1,154 @@
1
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
2
+ import { BIN, DEFAULT_LOG_LINES, ExitCode, LOG_FOLLOW_INTERVAL_MS, UsageError } from "../core/domain/index.js";
3
+ import { stripControl as clean, tailLines as tail } from "../core/log-read.js";
4
+ import { channelDir, listChannelFiles, pruneLogs } from "../core/logger.js";
5
+ import { assertWorkerName } from "../core/names.js";
6
+ import { formatLocal, sleep } from "../core/time.js";
7
+ const LOG_OPTIONS = {
8
+ follow: { type: 'boolean', short: 'f', description: 'Keep printing new log lines' },
9
+ lines: {
10
+ type: 'string',
11
+ short: 'n',
12
+ valueName: 'lines',
13
+ description: `Number of recent lines to show (default ${DEFAULT_LOG_LINES})`
14
+ }
15
+ };
16
+ const RETENTION_NOTE = 'Times are shown in your local time zone; --json prints the raw JSON Lines (UTC).\n' +
17
+ 'Logs are kept for 30 days by default (logRetentionDays) and limited to 300 MB in total\n' +
18
+ '(logMaxBytes): the oldest files are deleted first.';
19
+ export const logs = {
20
+ name: 'logs',
21
+ summary: 'Show the CLI log (fetches, live connection, stops, errors)',
22
+ usage: '[-f] [-n <lines>]',
23
+ description: `For one worker's log use '${BIN} worker logs <name>'.\n${RETENTION_NOTE}`,
24
+ options: LOG_OPTIONS,
25
+ args: { min: 0, max: 0 },
26
+ run: (ctx) => show(ctx, null)
27
+ };
28
+ export const workerLogs = {
29
+ name: 'worker logs',
30
+ summary: "Show one worker's log (ctx.log, console output, errors with stack traces)",
31
+ usage: '<name> [-f] [-n <lines>]',
32
+ description: RETENTION_NOTE,
33
+ options: LOG_OPTIONS,
34
+ args: { min: 1, max: 1 },
35
+ run(ctx) {
36
+ const worker = ctx.positionals[0];
37
+ assertWorkerName(worker);
38
+ return show(ctx, worker);
39
+ }
40
+ };
41
+ async function show(ctx, worker) {
42
+ const { values, out } = ctx;
43
+ const count = parseLines(values.lines);
44
+ const { logRetentionDays, logMaxBytes } = ctx.loadConfig().values;
45
+ try {
46
+ pruneLogs({
47
+ dir: ctx.dirs.logs,
48
+ now: ctx.now(),
49
+ retentionDays: logRetentionDays,
50
+ maxBytes: logMaxBytes
51
+ });
52
+ }
53
+ catch (err) {
54
+ out.warn(`log cleanup failed: ${err.message}`);
55
+ }
56
+ const dir = channelDir(ctx.dirs.logs, worker);
57
+ const lines = tail(dir, count);
58
+ if (lines.length === 0 && !values.follow) {
59
+ out.info(worker === null ? 'No logs yet.' : `No logs for worker "${worker}" yet.`);
60
+ return ExitCode.Ok;
61
+ }
62
+ for (const line of lines)
63
+ print(out, line);
64
+ if (values.follow)
65
+ await follow(dir, out, ctx.signal);
66
+ return ExitCode.Ok;
67
+ }
68
+ function parseLines(raw) {
69
+ if (raw === undefined)
70
+ return DEFAULT_LOG_LINES;
71
+ const n = Number(raw);
72
+ if (typeof raw !== 'string' || !Number.isInteger(n) || n < 1) {
73
+ throw new UsageError('--lines must be a positive integer');
74
+ }
75
+ return n;
76
+ }
77
+ async function follow(dir, out, signal) {
78
+ let current = listChannelFiles(dir).at(-1)?.file ?? null;
79
+ let offset = current ? (statSync(current, { throwIfNoEntry: false })?.size ?? 0) : 0;
80
+ let partial = '';
81
+ const drain = (file) => {
82
+ const size = statSync(file, { throwIfNoEntry: false })?.size;
83
+ if (size === undefined)
84
+ return;
85
+ if (size < offset) {
86
+ offset = 0;
87
+ partial = '';
88
+ }
89
+ if (size === offset)
90
+ return;
91
+ let fd;
92
+ try {
93
+ fd = openSync(file, 'r');
94
+ }
95
+ catch (err) {
96
+ if (err.code === 'ENOENT')
97
+ return;
98
+ throw err;
99
+ }
100
+ try {
101
+ const buf = Buffer.alloc(size - offset);
102
+ readSync(fd, buf, 0, buf.length, offset);
103
+ offset = size;
104
+ const text = partial + buf.toString('utf8');
105
+ const parts = text.split('\n');
106
+ partial = parts.pop() ?? '';
107
+ for (const line of parts)
108
+ if (line !== '')
109
+ print(out, line);
110
+ }
111
+ finally {
112
+ closeSync(fd);
113
+ }
114
+ };
115
+ while (!signal.aborted) {
116
+ const latest = listChannelFiles(dir).at(-1)?.file ?? null;
117
+ if (current)
118
+ drain(current);
119
+ if (latest && latest !== current) {
120
+ current = latest;
121
+ offset = 0;
122
+ partial = '';
123
+ drain(current);
124
+ }
125
+ await sleep(LOG_FOLLOW_INTERVAL_MS, signal);
126
+ }
127
+ }
128
+ function print(out, line) {
129
+ if (out.json) {
130
+ out.print(line);
131
+ return;
132
+ }
133
+ let record;
134
+ try {
135
+ record = JSON.parse(line);
136
+ }
137
+ catch {
138
+ out.print(clean(line));
139
+ return;
140
+ }
141
+ const ts = Date.parse(record.timestamp);
142
+ const level = String(record.severity_text ?? '').padEnd(5);
143
+ const paint = record.severity_text === 'error'
144
+ ? out.out.red
145
+ : record.severity_text === 'warn'
146
+ ? out.out.yellow
147
+ : out.out.dim;
148
+ const attrs = Object.entries(record.attributes ?? {})
149
+ .map(([k, v]) => `${k}=${typeof v === 'string' ? v : JSON.stringify(v)}`)
150
+ .join(' ');
151
+ const time = Number.isNaN(ts) ? clean(String(record.timestamp)) : formatLocal(ts);
152
+ out.print(`${out.out.dim(time)} ${paint(clean(level))} ${clean(String(record.body))}` +
153
+ (attrs ? ` ${out.out.dim(clean(attrs))}` : ''));
154
+ }
@@ -0,0 +1,109 @@
1
+ import { existsSync, realpathSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { BIN, CliError, ExitCode, StopError, TEMPLATE_EXTENSION, WORKER_EXTENSIONS } from "../core/domain/index.js";
4
+ import { assertWorkerName } from "../core/names.js";
5
+ import { failReload, reloadRunner } from "../core/host/reload.js";
6
+ import { readRunner } from "../core/host/runner-lock.js";
7
+ import { reloadLines } from "../modules/workers/reload-result.js";
8
+ import { DOTENV_FILE } from "../modules/workers/runtime/env.js";
9
+ import { jsTemplate, tsTemplate } from "../modules/workers/template.js";
10
+ import { requireWorkersDir } from "./common.js";
11
+ export const workerNew = {
12
+ name: 'worker new',
13
+ summary: 'Create a worker file from a template',
14
+ usage: '<name> [--ts] [--no-reload]',
15
+ description: 'The name becomes the file name (workers/<name>.mjs) and the task route (/task/<name>).\n' +
16
+ 'Allowed: 1-50 lowercase letters, digits, "_" and "-", not starting with "_" (reserved for\n' +
17
+ 'built-in workers).\n' +
18
+ 'Prints absolute paths of the new file, the workers folder and the project .env.\n' +
19
+ "If 'start' is running on this folder, the worker is registered right away (3minapi reload).",
20
+ options: {
21
+ ts: { type: 'boolean', description: 'Write a TypeScript worker (runs without a build step)' },
22
+ reload: {
23
+ type: 'boolean',
24
+ negated: true,
25
+ description: "Do not register it with the running 'start' now (run '3minapi reload' later)"
26
+ }
27
+ },
28
+ args: { min: 1, max: 1 },
29
+ async run(ctx) {
30
+ const { positionals, values, out, loadConfig } = ctx;
31
+ const name = positionals[0];
32
+ assertWorkerName(name);
33
+ const { root, dir } = requireWorkersDir(loadConfig());
34
+ const existing = WORKER_EXTENSIONS.map((ext) => path.join(dir, name + ext)).find((f) => existsSync(f));
35
+ if (existing)
36
+ throw new CliError(`worker "${name}" already exists: ${existing}`);
37
+ const file = path.join(dir, name + (values.ts ? TEMPLATE_EXTENSION.ts : TEMPLATE_EXTENSION.js));
38
+ writeFileSync(file, values.ts ? tsTemplate(name) : jsTemplate(name), { flag: 'wx' });
39
+ const env = path.join(root, DOTENV_FILE);
40
+ const envExists = existsSync(env);
41
+ const lines = [
42
+ `${out.out.green('created')} ${file}`,
43
+ `workers ${dir}`,
44
+ `env ${env}${envExists ? '' : out.out.dim(' (not created yet)')}`,
45
+ ''
46
+ ];
47
+ const reload = await registerNow(ctx, dir, values.reload === false);
48
+ lines.push(...reload.lines, '', `Next: edit the handler, then test it with '${BIN} worker run ${name}'`);
49
+ out.result({ name, file, workersDir: dir, env, envExists, reload: reload.json }, lines.join('\n'));
50
+ return ExitCode.Ok;
51
+ }
52
+ };
53
+ async function registerNow(ctx, dir, disabled) {
54
+ const runner = readRunner(ctx.dirs.data);
55
+ if (runner.state !== 'running') {
56
+ return {
57
+ lines: [`The next '${BIN} start' registers it with 3Min API.`],
58
+ json: { status: 'skipped', reason: 'not_running' }
59
+ };
60
+ }
61
+ const { info } = runner;
62
+ if (disabled) {
63
+ return {
64
+ lines: [`'${BIN} start' is running — run '${BIN} reload' to register it.`],
65
+ json: { status: 'skipped', reason: 'disabled' }
66
+ };
67
+ }
68
+ if (!info.reload || !info.workersDir) {
69
+ return {
70
+ lines: [
71
+ `'${BIN} start' (pid ${info.pid}) is from an older 3minapi and cannot reload — restart it to register this worker.`
72
+ ],
73
+ json: { status: 'skipped', reason: 'unsupported' }
74
+ };
75
+ }
76
+ if (!sameDir(info.workersDir, dir)) {
77
+ return {
78
+ lines: [
79
+ `'${BIN} start' is running on ${info.workersDir}, not this folder — this worker is not registered.`
80
+ ],
81
+ json: { status: 'skipped', reason: 'other_folder' }
82
+ };
83
+ }
84
+ ctx.out.info(`'${BIN} start' is running — registering it now (${BIN} reload)...`);
85
+ try {
86
+ const result = await reloadRunner(ctx, info);
87
+ if (!result.ok)
88
+ failReload(result);
89
+ return { lines: reloadLines(result), json: { status: 'done', result } };
90
+ }
91
+ catch (err) {
92
+ if (err instanceof StopError || !(err instanceof CliError))
93
+ throw err;
94
+ ctx.out.warn(`${err.message}\nThe file was created; run '${BIN} reload' to register it.`);
95
+ return { lines: [], json: { status: 'failed', error: err.message } };
96
+ }
97
+ }
98
+ export function sameDir(a, b, platform = process.platform) {
99
+ const real = (p) => {
100
+ try {
101
+ return realpathSync.native(p);
102
+ }
103
+ catch {
104
+ return path.resolve(p);
105
+ }
106
+ };
107
+ const [x, y] = [real(a), real(b)];
108
+ return platform === 'win32' ? x.toLowerCase() === y.toLowerCase() : x === y;
109
+ }
@@ -0,0 +1,64 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { APP_LOG_DIR, CONTROL_DB_FILE, CREDENTIALS_FILE, ConfigError, ExitCode, LOCAL_SERVER_TOKEN_FILE, RUNNER_OUT_FILE, USER_CONFIG_FILE, USER_DB_FILE } from "../core/domain/index.js";
4
+ import { initHint, projectRecordPath } from "../core/project.js";
5
+ import { DOTENV_FILE } from "../modules/workers/runtime/env.js";
6
+ import { workersPath } from "./common.js";
7
+ export const paths = {
8
+ name: 'paths',
9
+ summary: 'Show absolute paths of project files and CLI folders',
10
+ usage: '',
11
+ description: 'No API calls. Edit the project files (workers, .env, 3minapi.json); use commands for the\n' +
12
+ 'rest — the CLI folders hold the host token and internal state.',
13
+ options: {},
14
+ args: { min: 0, max: 0 },
15
+ async run(ctx) {
16
+ const { out, dirs } = ctx;
17
+ let projectRoot = null;
18
+ let workersDir = null;
19
+ let projectConfig = null;
20
+ try {
21
+ const config = ctx.loadConfig();
22
+ projectRoot = config.projectRoot;
23
+ projectConfig = config.projectFile;
24
+ if (projectRoot)
25
+ workersDir = workersPath(projectRoot, config);
26
+ }
27
+ catch (err) {
28
+ if (!(err instanceof ConfigError))
29
+ throw err;
30
+ out.warn(err.message);
31
+ }
32
+ const entry = (p, note) => ({
33
+ path: p,
34
+ exists: p !== null && existsSync(p),
35
+ note
36
+ });
37
+ const data = {
38
+ projectRoot: entry(projectRoot, projectRoot
39
+ ? "this host's project — commands use it from any folder. To move it, see '3minapi help init'"
40
+ : `this host has no project. ${initHint(ctx.home)}`),
41
+ projectConfig: entry(projectConfig, 'project settings — edit'),
42
+ workersDir: entry(workersDir, 'worker files — edit'),
43
+ env: entry(projectRoot && path.join(projectRoot, DOTENV_FILE), 'secrets for workers — edit, do not print values'),
44
+ db: entry(projectRoot && path.join(projectRoot, USER_DB_FILE), "SQLite file — saved task results (_3minapi_ tables, '3minapi worker results') and workers' own data"),
45
+ userConfig: entry(path.join(dirs.config, USER_CONFIG_FILE), 'user settings (project settings win)'),
46
+ logs: entry(dirs.logs, "read with '3minapi logs' / '3minapi worker logs <name>'"),
47
+ runnerOut: entry(path.join(dirs.logs, APP_LOG_DIR, RUNNER_OUT_FILE), "output of 'start --detach'"),
48
+ localServerToken: entry(path.join(dirs.config, LOCAL_SERVER_TOKEN_FILE), "read only to call 'start --serve' from a local script"),
49
+ controlDb: entry(path.join(dirs.data, CONTROL_DB_FILE), "task run history and internal state — do not edit ('3minapi tasks prune')"),
50
+ projectRecord: entry(projectRecordPath(dirs.data), "which folder is this host's project — change with '3minapi init <folder> --move'"),
51
+ credentials: entry(path.join(dirs.config, CREDENTIALS_FILE), 'host token — do not read or share')
52
+ };
53
+ const width = Math.max(...Object.keys(data).map((k) => k.length)) + 2;
54
+ const text = Object.entries(data)
55
+ .map(([key, e]) => {
56
+ const value = e.path ?? '-';
57
+ const missing = e.path !== null && !e.exists ? out.out.dim(' (not created yet)') : '';
58
+ return `${key.padEnd(width)}${value}${missing}\n${' '.repeat(width)}${out.out.dim(e.note)}`;
59
+ })
60
+ .join('\n');
61
+ out.result(data, text);
62
+ return ExitCode.Ok;
63
+ }
64
+ };
@@ -0,0 +1,32 @@
1
+ import { BIN, ExitCode } from "../core/domain/index.js";
2
+ import { failReload, reloadRunner } from "../core/host/reload.js";
3
+ import { readRunnerSettled } from "../core/host/runner-lock.js";
4
+ import { reloadLines } from "../modules/workers/reload-result.js";
5
+ export const reload = {
6
+ name: 'reload',
7
+ summary: "Apply added, renamed or re-described worker files to the running 'start'",
8
+ usage: '',
9
+ description: 'Asks the running start (the host process) to read the worker files again and register\n' +
10
+ 'them with 3Min API (one API call), without stopping it: running tasks keep going.\n' +
11
+ 'Use it after adding or renaming a worker file or changing its description —\n' +
12
+ "'worker new' does it by itself.\n" +
13
+ 'Handler code and .env changes need no reload; they apply to the next task.\n' +
14
+ 'Like start, it imports every worker file, so top-level code in them runs again.\n' +
15
+ 'Without a running start there is nothing to reload: the next start registers the files.',
16
+ options: {},
17
+ args: { min: 0, max: 0 },
18
+ async run(ctx) {
19
+ const { out } = ctx;
20
+ const current = await readRunnerSettled(ctx.dirs.data);
21
+ if (current.state !== 'running') {
22
+ out.result({ reloaded: false, running: false }, `No '${BIN} start' is running — the next '${BIN} start' registers the worker files.`);
23
+ return ExitCode.Ok;
24
+ }
25
+ out.info(`reloading pid ${current.info.pid}...`);
26
+ const result = await reloadRunner(ctx, current.info);
27
+ if (!result.ok)
28
+ failReload(result);
29
+ out.result({ reloaded: true, ...result }, reloadLines(result).join('\n'));
30
+ return ExitCode.Ok;
31
+ }
32
+ };