@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,178 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { BIN, CliError, ExitCode, REPORT_ENVELOPE_RESERVE, REPORT_MAX_BYTES, RESULT_RESERVED_KEY, UsageError, WORKER_DESCRIPTION_MAX_CHARS } from "../core/domain/index.js";
4
+ import { assertWorkerName } from "../core/names.js";
5
+ import { formatCount } from "../core/text.js";
6
+ import { workerEnv } from "../modules/workers/runtime/env.js";
7
+ import { Executor } from "../modules/workers/runtime/executor.js";
8
+ import { collectWorkers, descriptionTooLong } from "../modules/workers/workers.js";
9
+ import { requireProject, requireWorkersDir, workersPath } from "./common.js";
10
+ import { workerDelete } from "./delete.js";
11
+ import { workerLogs } from "./logs.js";
12
+ import { workerNew } from "./new.js";
13
+ import { workerResults } from "./results.js";
14
+ export const workerList = {
15
+ name: 'worker list',
16
+ summary: 'List the workers in this project and check that each one can load',
17
+ usage: '',
18
+ description: 'Loads every worker file (without running the handler) and reports its description,\n' +
19
+ 'timeout and whether it has an inputSchema. Makes no API calls.\n' +
20
+ "Loading runs the file's top-level code, as a real run would.",
21
+ options: {},
22
+ args: { min: 0, max: 0 },
23
+ async run(ctx) {
24
+ const { out } = ctx;
25
+ const { root, dir } = requireWorkersDir(ctx.loadConfig());
26
+ const workers = (await collectWorkers(dir, workerEnv(root, ctx.env))).map((w) => {
27
+ const warnings = [];
28
+ const description = w.meta?.description ?? null;
29
+ if (w.meta && description === null) {
30
+ warnings.push('no description — the AI will not know what it does');
31
+ }
32
+ else if (description !== null && descriptionTooLong(description)) {
33
+ warnings.push(`description is over ${WORKER_DESCRIPTION_MAX_CHARS} characters — the server will not register this worker`);
34
+ }
35
+ return {
36
+ name: w.name,
37
+ files: w.files,
38
+ ok: w.meta !== null,
39
+ problem: w.message,
40
+ description,
41
+ timeoutMs: w.meta?.timeoutMs ?? null,
42
+ hasInputSchema: w.meta?.hasInputSchema ?? false,
43
+ warnings
44
+ };
45
+ });
46
+ const text = workers.length === 0
47
+ ? `No workers in ${dir}. Create one with '${BIN} worker new <name>'.`
48
+ : [
49
+ `workers ${dir}`,
50
+ '',
51
+ ...workers.flatMap((w) => [
52
+ `${w.ok ? out.out.green('ok ') : out.out.red('error')} ${w.name} ${out.out.dim(w.files.join(', '))}`,
53
+ ...(w.ok
54
+ ? [
55
+ ` ${w.description ?? out.out.dim('(no description)')}`,
56
+ ` ${out.out.dim(`timeout ${(w.timeoutMs ?? 60_000) / 1000}s · ${w.hasInputSchema ? 'inputSchema' : 'no inputSchema (any input)'}`)}`
57
+ ]
58
+ : [` ${w.problem}`]),
59
+ ...w.warnings.map((m) => ` ${out.out.yellow('warn')} ${m}`)
60
+ ])
61
+ ].join('\n');
62
+ out.result({ workersDir: dir, workers }, text);
63
+ return ExitCode.Ok;
64
+ }
65
+ };
66
+ export const workerRun = {
67
+ name: 'worker run',
68
+ summary: 'Run one worker once with the given input — no task, no API calls',
69
+ usage: '<worker> [--input <json> | --input-file <file>]',
70
+ description: 'Runs the worker file once on this machine, without a task: same thread, timeout and\n' +
71
+ 'inputSchema check as start, so if it works here it works as a real task.\n' +
72
+ 'Makes no API calls (no quota, no login needed). Input defaults to {}.\n' +
73
+ 'ctx.log lines go to stderr; the result goes to stdout (--json).',
74
+ options: {
75
+ input: { type: 'string', valueName: 'json', description: 'Input as a JSON string' },
76
+ 'input-file': { type: 'string', valueName: 'file', description: 'Read input JSON from a file' }
77
+ },
78
+ args: { min: 1, max: 1 },
79
+ async run(ctx) {
80
+ const { out, values } = ctx;
81
+ const name = ctx.positionals[0];
82
+ assertWorkerName(name);
83
+ if (values.input !== undefined && values['input-file'] !== undefined) {
84
+ throw new UsageError('use either --input or --input-file, not both');
85
+ }
86
+ const input = parseInput(ctx);
87
+ const config = ctx.loadConfig();
88
+ const root = requireProject(config);
89
+ ctx.chdir(root);
90
+ const executor = new Executor({
91
+ workersDir: workersPath(root, config),
92
+ concurrency: 1,
93
+ env: workerEnv(root, ctx.env)
94
+ });
95
+ ctx.claimInterrupt();
96
+ const id = 'local-run';
97
+ const onAbort = () => executor.cancel(id, 'interrupted');
98
+ ctx.signal.addEventListener('abort', onAbort, { once: true });
99
+ try {
100
+ const outcome = await executor.run({ id, worker: name, input }, {
101
+ onLog: (message) => out.info(`${out.err.dim('log')} ${message}`),
102
+ onReport: async (message) => out.info(`${out.err.dim('report')} ${message}`)
103
+ });
104
+ await executor.idle();
105
+ if (outcome.status === 'completed') {
106
+ const result = JSON.parse(outcome.resultJson);
107
+ warnReplyLimits(ctx, result, Buffer.byteLength(outcome.resultJson));
108
+ out.result({ status: 'completed', result }, JSON.stringify(result, null, 2));
109
+ return ExitCode.Ok;
110
+ }
111
+ if (outcome.status === 'cancelled') {
112
+ out.result({ status: 'cancelled' }, 'Cancelled.');
113
+ return ExitCode.Interrupted;
114
+ }
115
+ if (out.json)
116
+ out.result({ status: 'failed', reason: outcome.reason, message: outcome.message });
117
+ throw new CliError(`${name} failed (${outcome.reason}): ${outcome.message}`);
118
+ }
119
+ finally {
120
+ ctx.signal.removeEventListener('abort', onAbort);
121
+ }
122
+ }
123
+ };
124
+ export const worker = {
125
+ name: 'worker',
126
+ summary: 'Create, test and inspect local workers',
127
+ usage: '<command>',
128
+ description: 'A worker is one file in the workers folder; its file name is the worker name. ' +
129
+ "'start' registers it as a local worker; with 'start --serve --public' a name registered as a " +
130
+ 'public worker on the dashboard is served as that public worker instead. ' +
131
+ "Removing the file does not remove the local worker from 3Min API — use 'worker delete'.",
132
+ options: {},
133
+ args: { min: 0, max: 0 },
134
+ subcommands: [workerNew, workerRun, workerList, workerLogs, workerDelete, workerResults],
135
+ async run() {
136
+ return ExitCode.Usage;
137
+ }
138
+ };
139
+ function warnReplyLimits(ctx, result, bytes) {
140
+ if (result !== null &&
141
+ typeof result === 'object' &&
142
+ !Array.isArray(result) &&
143
+ Object.hasOwn(result, RESULT_RESERVED_KEY)) {
144
+ ctx.out.warn(`the result has a top-level "${RESULT_RESERVED_KEY}" key, which 3minapi reserves — as a task it fails ` +
145
+ '(reserved_result_key). Rename the key.');
146
+ }
147
+ if (bytes > REPORT_MAX_BYTES - REPORT_ENVELOPE_RESERVE) {
148
+ ctx.out.warn(`the result is ${formatCount(bytes)} bytes, over the 100 KiB reply limit. As a task, a local worker ` +
149
+ "saves it in data.db and sends the AI a summary instead; a public worker's task fails.");
150
+ }
151
+ }
152
+ function parseInput(ctx) {
153
+ const { values } = ctx;
154
+ let text;
155
+ let from;
156
+ if (typeof values['input-file'] === 'string') {
157
+ from = values['input-file'];
158
+ try {
159
+ text = readFileSync(path.resolve(ctx.cwd, from), 'utf8');
160
+ }
161
+ catch (err) {
162
+ throw new UsageError(`cannot read ${from}: ${err.message}`);
163
+ }
164
+ }
165
+ else if (typeof values.input === 'string') {
166
+ from = '--input';
167
+ text = values.input;
168
+ }
169
+ else {
170
+ return {};
171
+ }
172
+ try {
173
+ return JSON.parse(text);
174
+ }
175
+ catch (err) {
176
+ throw new UsageError(`${from} is not valid JSON: ${err.message}`);
177
+ }
178
+ }
@@ -0,0 +1,132 @@
1
+ import { API_TIMEOUT_MS, BIN, CliError, ENV, StopError, isStopCode } from "./domain/index.js";
2
+ import { stripControl } from "./text.js";
3
+ export class ApiError extends CliError {
4
+ status;
5
+ code;
6
+ retryAfterMs;
7
+ constructor(message, status, code, retryAfterMs = null) {
8
+ super(message);
9
+ this.name = 'ApiError';
10
+ this.status = status;
11
+ this.code = code;
12
+ this.retryAfterMs = retryAfterMs;
13
+ }
14
+ }
15
+ export function userAgent(version, platform) {
16
+ return `${BIN}/${version} node/${process.versions.node} ${platform}`;
17
+ }
18
+ export class ApiClient {
19
+ opts;
20
+ stop = null;
21
+ constructor(opts) {
22
+ this.opts = opts;
23
+ }
24
+ get halted() {
25
+ return this.stop;
26
+ }
27
+ notifyCli(cli) {
28
+ try {
29
+ this.opts.onServerCli?.(cli);
30
+ }
31
+ catch {
32
+ }
33
+ }
34
+ halt(err) {
35
+ this.stop ??= err;
36
+ }
37
+ async getHost() {
38
+ const res = await this.request('GET', this.opts.apiUrl + '/api/v1/host');
39
+ this.notifyCli(res.cli);
40
+ return res;
41
+ }
42
+ async pullTasks(max) {
43
+ const res = await this.request('GET', `${this.opts.apiUrl}/api/v1/host/tasks?max=${max}`);
44
+ this.notifyCli(res.cli);
45
+ return res;
46
+ }
47
+ async syncWorkers(body) {
48
+ const res = await this.request('PUT', this.opts.apiUrl + '/api/v1/host/workers', body);
49
+ this.notifyCli(res.cli);
50
+ return res;
51
+ }
52
+ deleteWorker(name) {
53
+ return this.request('DELETE', `${this.opts.apiUrl}/api/v1/host/workers/${encodeURIComponent(name)}`);
54
+ }
55
+ report(callbackUrl, body) {
56
+ let target;
57
+ try {
58
+ target = new URL(callbackUrl);
59
+ }
60
+ catch {
61
+ throw new ApiError(`invalid callback_url: ${callbackUrl}`, null, null);
62
+ }
63
+ if (target.origin !== new URL(this.opts.apiUrl).origin) {
64
+ throw new ApiError(`refusing to send the token to ${target.origin}: callback_url must be on ${this.opts.apiUrl}`, null, null);
65
+ }
66
+ return this.request('POST', target.href, body);
67
+ }
68
+ async request(method, url, payload) {
69
+ if (this.stop)
70
+ throw this.stop;
71
+ const signals = [AbortSignal.timeout(API_TIMEOUT_MS)];
72
+ if (this.opts.signal)
73
+ signals.push(this.opts.signal);
74
+ const headers = {
75
+ authorization: `Bearer ${this.opts.token}`,
76
+ accept: 'application/json',
77
+ 'user-agent': this.opts.userAgent
78
+ };
79
+ if (payload !== undefined)
80
+ headers['content-type'] = 'application/json';
81
+ let res;
82
+ let text;
83
+ try {
84
+ res = await (this.opts.fetch ?? fetch)(url, {
85
+ method,
86
+ headers,
87
+ body: payload === undefined ? undefined : JSON.stringify(payload),
88
+ signal: AbortSignal.any(signals)
89
+ });
90
+ text = await res.text();
91
+ }
92
+ catch (err) {
93
+ throw new ApiError(`cannot reach ${this.opts.apiUrl}: ${networkReason(err)}`, null, null);
94
+ }
95
+ const body = parseJson(text);
96
+ if (res.ok && body !== null && body.success === true) {
97
+ return body;
98
+ }
99
+ const code = body?.code;
100
+ const message = body?.error;
101
+ const sentence = typeof message === 'string' && message !== '' ? stripControl(message) : `HTTP ${res.status}`;
102
+ if (isStopCode(code)) {
103
+ this.stop ??= new StopError(code, sentence);
104
+ throw this.stop;
105
+ }
106
+ const hint = res.status === 404 && typeof code !== 'string'
107
+ ? `\nThis server has no host API. Check the API URL (--api-url or ${ENV.apiUrl}).`
108
+ : '';
109
+ throw new ApiError(`${method} ${url} failed: ${sentence}${hint}`, res.status, typeof code === 'string' ? code : null, retryAfterMs(res.headers.get('retry-after')));
110
+ }
111
+ }
112
+ function retryAfterMs(value) {
113
+ if (value === null || !/^\d+$/.test(value.trim()))
114
+ return null;
115
+ return Number(value.trim()) * 1000;
116
+ }
117
+ function parseJson(text) {
118
+ try {
119
+ return JSON.parse(text);
120
+ }
121
+ catch {
122
+ return null;
123
+ }
124
+ }
125
+ function networkReason(err) {
126
+ const e = err;
127
+ if (e?.name === 'TimeoutError')
128
+ return `no response within ${API_TIMEOUT_MS / 1000}s`;
129
+ if (e?.name === 'AbortError')
130
+ return 'aborted';
131
+ return e?.cause?.code ?? e?.cause?.message ?? e?.message ?? String(err);
132
+ }
@@ -0,0 +1,188 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { BIN, CONCURRENCY_MAX, CONCURRENCY_MIN, ConfigError, DEFAULT_API_URL, DEFAULT_CONCURRENCY, DEFAULT_LOCAL_SERVER_PORT, DEFAULT_LOG_MAX_BYTES, DEFAULT_LOG_RETENTION_DAYS, DEFAULT_POLL_INTERVAL_MS, DEFAULT_WORKERS_DIR, ENV, LOG_MAX_BYTES_MAX, LOG_MAX_BYTES_MIN, LOG_RETENTION_MAX_DAYS, LOG_RETENTION_MIN_DAYS, LOCAL_SERVER_PORT_MAX, LOCAL_SERVER_PORT_MIN, POLL_INTERVAL_MAX_MS, POLL_INTERVAL_MIN_MS, PROJECT_CONFIG_FILE, RECEIVE_MODES, USER_CONFIG_FILE } from "./domain/index.js";
4
+ import { readUserText } from "./text-file.js";
5
+ export const DEFAULTS = {
6
+ apiUrl: DEFAULT_API_URL,
7
+ workersDir: DEFAULT_WORKERS_DIR,
8
+ pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
9
+ logRetentionDays: DEFAULT_LOG_RETENTION_DAYS,
10
+ logMaxBytes: DEFAULT_LOG_MAX_BYTES,
11
+ concurrency: DEFAULT_CONCURRENCY,
12
+ localServerPort: DEFAULT_LOCAL_SERVER_PORT,
13
+ receiveMode: 'live'
14
+ };
15
+ const ENV_KEYS = {
16
+ apiUrl: ENV.apiUrl,
17
+ pollIntervalMs: ENV.pollIntervalMs,
18
+ logRetentionDays: ENV.logRetentionDays,
19
+ logMaxBytes: ENV.logMaxBytes,
20
+ concurrency: ENV.concurrency,
21
+ localServerPort: ENV.localServerPort,
22
+ receiveMode: ENV.receiveMode
23
+ };
24
+ const FLAG_NAMES = {
25
+ localServerPort: '--port',
26
+ receiveMode: '--mode'
27
+ };
28
+ const INTEGER_RANGES = {
29
+ pollIntervalMs: [POLL_INTERVAL_MIN_MS, POLL_INTERVAL_MAX_MS],
30
+ logRetentionDays: [LOG_RETENTION_MIN_DAYS, LOG_RETENTION_MAX_DAYS],
31
+ logMaxBytes: [LOG_MAX_BYTES_MIN, LOG_MAX_BYTES_MAX],
32
+ concurrency: [CONCURRENCY_MIN, CONCURRENCY_MAX],
33
+ localServerPort: [LOCAL_SERVER_PORT_MIN, LOCAL_SERVER_PORT_MAX]
34
+ };
35
+ export function loadConfig(input) {
36
+ const warnings = [];
37
+ const userFile = path.join(input.configDir, USER_CONFIG_FILE);
38
+ let projectFile;
39
+ if (input.configFile !== undefined) {
40
+ projectFile = path.resolve(input.cwd, input.configFile);
41
+ if (!existsSync(projectFile))
42
+ throw new ConfigError(`config file not found: ${projectFile}`);
43
+ }
44
+ else if (input.projectFile !== undefined) {
45
+ projectFile = input.projectFile;
46
+ }
47
+ else {
48
+ projectFile = findProjectFile(input.cwd);
49
+ }
50
+ const layers = [
51
+ ['flag', input.flags ?? {}],
52
+ ['env', fromEnv(input.env)],
53
+ ['project', projectFile ? projectLayer(projectFile, warnings) : {}],
54
+ ['user', existsSync(userFile) ? readJsonObject(userFile, warnings) : {}]
55
+ ];
56
+ const values = { ...DEFAULTS };
57
+ const sources = Object.fromEntries(Object.keys(DEFAULTS).map((k) => [k, 'default']));
58
+ for (const key of Object.keys(DEFAULTS)) {
59
+ for (const [source, layer] of layers) {
60
+ const raw = layer[key];
61
+ if (raw === undefined)
62
+ continue;
63
+ setValue(values, key, raw, source);
64
+ sources[key] = source;
65
+ break;
66
+ }
67
+ }
68
+ return {
69
+ values,
70
+ sources,
71
+ projectRoot: projectFile ? path.dirname(projectFile) : null,
72
+ projectFile,
73
+ userFile,
74
+ warnings
75
+ };
76
+ }
77
+ function projectLayer(file, warnings) {
78
+ const layer = readJsonObject(file, warnings);
79
+ if (layer.apiUrl !== undefined) {
80
+ delete layer.apiUrl;
81
+ warnings.push(`apiUrl in ${file} is ignored — a project file must not decide where your host token is sent. ` +
82
+ `Set it with --api-url, ${ENV.apiUrl} or ${USER_CONFIG_FILE} in the CLI config folder ('${BIN} paths'), and remove it from ${file}.`);
83
+ }
84
+ return layer;
85
+ }
86
+ export function findProjectFile(cwd) {
87
+ let dir = path.resolve(cwd);
88
+ for (;;) {
89
+ const candidate = path.join(dir, PROJECT_CONFIG_FILE);
90
+ if (existsSync(candidate))
91
+ return candidate;
92
+ const parent = path.dirname(dir);
93
+ if (parent === dir)
94
+ return null;
95
+ dir = parent;
96
+ }
97
+ }
98
+ function fromEnv(env) {
99
+ const out = {};
100
+ for (const [key, name] of Object.entries(ENV_KEYS)) {
101
+ const v = env[name];
102
+ if (v !== undefined && v !== '')
103
+ out[key] = v;
104
+ }
105
+ return out;
106
+ }
107
+ function readJsonObject(file, warnings) {
108
+ let parsed;
109
+ try {
110
+ parsed = JSON.parse(readUserText(file));
111
+ }
112
+ catch (err) {
113
+ if (err instanceof ConfigError)
114
+ throw err;
115
+ throw new ConfigError(`cannot read ${file}: ${err.message}`);
116
+ }
117
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
118
+ throw new ConfigError(`${file} must contain a JSON object`);
119
+ }
120
+ for (const key of Object.keys(parsed)) {
121
+ if (!(key in DEFAULTS))
122
+ warnings.push(`unknown key "${key}" in ${file} (ignored)`);
123
+ }
124
+ return parsed;
125
+ }
126
+ function setValue(values, key, raw, source) {
127
+ const where = source === 'env'
128
+ ? ENV_KEYS[key]
129
+ : source === 'flag'
130
+ ? (FLAG_NAMES[key] ?? `--${kebab(key)}`)
131
+ : key;
132
+ switch (key) {
133
+ case 'apiUrl': {
134
+ if (typeof raw !== 'string' || !isApiUrl(raw)) {
135
+ throw new ConfigError(`${where} must be an https URL (http only for 127.0.0.1, localhost or [::1])`);
136
+ }
137
+ values.apiUrl = raw.replace(/\/+$/, '');
138
+ return;
139
+ }
140
+ case 'workersDir': {
141
+ if (typeof raw !== 'string' || raw.trim() === '' || path.isAbsolute(raw)) {
142
+ throw new ConfigError(`${where} must be a relative directory path`);
143
+ }
144
+ const normalized = path.normalize(raw);
145
+ if (normalized === '..' ||
146
+ normalized.startsWith(`..${path.sep}`) ||
147
+ normalized.startsWith('../')) {
148
+ throw new ConfigError(`${where} must be inside the project folder`);
149
+ }
150
+ values.workersDir = raw;
151
+ return;
152
+ }
153
+ case 'pollIntervalMs':
154
+ case 'logRetentionDays':
155
+ case 'logMaxBytes':
156
+ case 'concurrency':
157
+ case 'localServerPort': {
158
+ const [min, max] = INTEGER_RANGES[key];
159
+ const n = typeof raw === 'string' ? Number(raw) : raw;
160
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < min || n > max) {
161
+ throw new ConfigError(`${where} must be an integer between ${min} and ${max}`);
162
+ }
163
+ values[key] = n;
164
+ return;
165
+ }
166
+ case 'receiveMode': {
167
+ if (typeof raw !== 'string' || !RECEIVE_MODES.includes(raw)) {
168
+ throw new ConfigError(`${where} must be one of: ${RECEIVE_MODES.join(', ')}`);
169
+ }
170
+ values.receiveMode = raw;
171
+ return;
172
+ }
173
+ }
174
+ }
175
+ function isApiUrl(value) {
176
+ try {
177
+ const u = new URL(value);
178
+ if (u.protocol === 'https:')
179
+ return true;
180
+ return u.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(u.hostname);
181
+ }
182
+ catch {
183
+ return false;
184
+ }
185
+ }
186
+ function kebab(key) {
187
+ return key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
188
+ }