@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,462 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import path from 'node:path';
3
+ import { existsSync, statSync } from 'node:fs';
4
+ import { CONTROL_DB_FILE, ConfigError, DB_BUSY_TIMEOUT_MS, NewerSchemaError, LAST_ERROR_MAX_CHARS, TASKS_PRUNE_BATCH, TASKS_PRUNE_MIN_AGE_MS } from "./domain/index.js";
5
+ import { redactAndTruncate } from "./redact.js";
6
+ import { touchSecretFile } from "./secure-fs.js";
7
+ export const MIGRATIONS = [
8
+ `CREATE TABLE tasks (
9
+ task_id TEXT PRIMARY KEY,
10
+ worker TEXT NOT NULL,
11
+ state TEXT NOT NULL CHECK (state IN ('received','running','completed','failed','cancelled','superseded')),
12
+ lease_id TEXT,
13
+ delivery_attempt INTEGER NOT NULL DEFAULT 1,
14
+ run_count INTEGER NOT NULL DEFAULT 0,
15
+ received_at INTEGER NOT NULL,
16
+ started_at INTEGER,
17
+ finished_at INTEGER,
18
+ last_error TEXT
19
+ ) STRICT;
20
+ CREATE INDEX tasks_finished ON tasks (state, finished_at);
21
+ CREATE TABLE meta (
22
+ key TEXT PRIMARY KEY,
23
+ value TEXT NOT NULL,
24
+ updated_at INTEGER NOT NULL
25
+ ) STRICT;`
26
+ ];
27
+ export const SCHEMA_VERSION = MIGRATIONS.length;
28
+ export const TERMINAL_STATES = ['completed', 'failed', 'cancelled', 'superseded'];
29
+ export const META = {
30
+ stopReason: 'stop_reason',
31
+ stoppedAt: 'stopped_at',
32
+ quotaResetAt: 'quota_reset_at'
33
+ };
34
+ export const END_META = {
35
+ reason: 'end_reason',
36
+ endedAt: 'ended_at',
37
+ workers: 'end_workers'
38
+ };
39
+ export const QUOTA_META = { quota: 'quota' };
40
+ export const LIVE_META = { state: 'live_state', url: 'live_url' };
41
+ export class ControlDb {
42
+ file;
43
+ db;
44
+ constructor(file, db) {
45
+ this.file = file;
46
+ this.db = db;
47
+ }
48
+ static open(dataDir) {
49
+ const file = path.join(dataDir, CONTROL_DB_FILE);
50
+ let db;
51
+ try {
52
+ touchSecretFile(file);
53
+ db = new DatabaseSync(file, { timeout: DB_BUSY_TIMEOUT_MS });
54
+ db.exec('PRAGMA journal_mode = WAL');
55
+ db.exec('PRAGMA synchronous = NORMAL');
56
+ migrate(db, file);
57
+ ensureColumns(db);
58
+ ensureIndexes(db);
59
+ return new ControlDb(file, db);
60
+ }
61
+ catch (err) {
62
+ if (db?.isOpen)
63
+ db.close();
64
+ if (err instanceof ConfigError)
65
+ throw err;
66
+ throw new ConfigError(openFailure(file, err));
67
+ }
68
+ }
69
+ static openReadOnly(dataDir) {
70
+ const file = path.join(dataDir, CONTROL_DB_FILE);
71
+ if (!existsSync(file))
72
+ return null;
73
+ let db;
74
+ try {
75
+ db = new DatabaseSync(file, { readOnly: true, timeout: DB_BUSY_TIMEOUT_MS });
76
+ const version = userVersion(db);
77
+ if (version === 0) {
78
+ db.close();
79
+ return null;
80
+ }
81
+ if (version > SCHEMA_VERSION)
82
+ throw new NewerSchemaError(file, version, SCHEMA_VERSION);
83
+ return new ControlDb(file, db);
84
+ }
85
+ catch (err) {
86
+ if (db?.isOpen)
87
+ db.close();
88
+ if (err instanceof ConfigError)
89
+ throw err;
90
+ throw new ConfigError(openFailure(file, err));
91
+ }
92
+ }
93
+ close() {
94
+ if (this.db.isOpen)
95
+ this.db.close();
96
+ }
97
+ schemaVersion() {
98
+ return userVersion(this.db);
99
+ }
100
+ receive(task, now) {
101
+ return this.tx(() => {
102
+ const row = this.getTask(task.task_id);
103
+ if (row &&
104
+ row.state !== 'superseded' &&
105
+ TERMINAL_STATES.includes(row.state))
106
+ return 'duplicate-done';
107
+ if (row) {
108
+ this.db
109
+ .prepare(`UPDATE tasks SET state = 'received', lease_id = ?, delivery_attempt = ?, worker = ?,
110
+ callback_url = ?, acked_at = NULL, repeat = ?, input_json = ?
111
+ WHERE task_id = ?`)
112
+ .run(task.lease_id, task.delivery_attempt, task.worker, task.callback_url ?? null, ...repeatColumns(task), task.task_id);
113
+ }
114
+ else {
115
+ this.db
116
+ .prepare(`INSERT INTO tasks (task_id, worker, state, lease_id, delivery_attempt, received_at, callback_url,
117
+ repeat, input_json)
118
+ VALUES (?, ?, 'received', ?, ?, ?, ?, ?, ?)`)
119
+ .run(task.task_id, task.worker, task.lease_id, task.delivery_attempt, now, task.callback_url ?? null, ...repeatColumns(task));
120
+ }
121
+ return 'run';
122
+ });
123
+ }
124
+ markAcked(taskId, now) {
125
+ this.db.prepare('UPDATE tasks SET acked_at = ? WHERE task_id = ?').run(now, taskId);
126
+ }
127
+ unfinishedTasks() {
128
+ return this.db
129
+ .prepare(`SELECT * FROM tasks WHERE state IN ('received', 'running')
130
+ AND NOT (repeat IS NOT NULL AND acked_at IS NOT NULL)
131
+ ORDER BY received_at, task_id`)
132
+ .all();
133
+ }
134
+ repeatTasks() {
135
+ return this.db
136
+ .prepare(`SELECT * FROM tasks WHERE state IN ('received', 'running')
137
+ AND repeat IS NOT NULL AND acked_at IS NOT NULL
138
+ ORDER BY received_at, task_id`)
139
+ .all();
140
+ }
141
+ startRun(taskId, runNo, now) {
142
+ this.db
143
+ .prepare(`UPDATE tasks SET state = 'running', run_no = ?, started_at = coalesce(started_at, ?),
144
+ run_count = run_count + 1
145
+ WHERE task_id = ?`)
146
+ .run(runNo, now, taskId);
147
+ }
148
+ recordRun(taskId, p) {
149
+ this.db
150
+ .prepare('UPDATE tasks SET runs = ?, failed = ?, fail_streak = ?, last_run_at = ? WHERE task_id = ?')
151
+ .run(p.runs, p.failed, p.failStreak, p.lastRunAt, taskId);
152
+ }
153
+ setNextAt(taskId, nextAt) {
154
+ this.db.prepare('UPDATE tasks SET next_at = ? WHERE task_id = ?').run(nextAt, taskId);
155
+ }
156
+ markRunning(taskId, now) {
157
+ this.db
158
+ .prepare(`UPDATE tasks SET state = 'running', started_at = ?, run_count = run_count + 1
159
+ WHERE task_id = ?`)
160
+ .run(now, taskId);
161
+ }
162
+ markFinished(taskId, state, now, error) {
163
+ this.db
164
+ .prepare(`UPDATE tasks SET state = ?, finished_at = ?, last_error = ?, input_json = NULL WHERE task_id = ?`)
165
+ .run(state, now, error === undefined ? null : redactAndTruncate(error, LAST_ERROR_MAX_CHARS), taskId);
166
+ }
167
+ getTask(taskId) {
168
+ const row = this.db.prepare('SELECT * FROM tasks WHERE task_id = ?').get(taskId);
169
+ return row ?? null;
170
+ }
171
+ listTasks(filter, limit) {
172
+ const conds = [];
173
+ const params = [];
174
+ if (filter.since !== undefined) {
175
+ conds.push('received_at >= ?');
176
+ params.push(filter.since);
177
+ }
178
+ if (filter.worker !== undefined) {
179
+ conds.push('worker = ?');
180
+ params.push(filter.worker);
181
+ }
182
+ if (filter.after !== undefined) {
183
+ conds.push('received_at <= ? AND (received_at < ? OR task_id > ?)');
184
+ params.push(filter.after.at, filter.after.at, filter.after.taskId);
185
+ }
186
+ if (filter.states !== undefined) {
187
+ conds.push(`state IN (${filter.states.map(() => '?').join(', ') || 'NULL'})`);
188
+ params.push(...filter.states);
189
+ }
190
+ const where = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : '';
191
+ return this.db
192
+ .prepare(`SELECT * FROM tasks ${where} ORDER BY received_at DESC, task_id LIMIT ?`)
193
+ .all(...params, limit);
194
+ }
195
+ tasksByIds(ids) {
196
+ if (ids.length === 0)
197
+ return [];
198
+ return this.db
199
+ .prepare(`SELECT * FROM tasks WHERE task_id IN (${ids.map(() => '?').join(', ')})`)
200
+ .all(...ids);
201
+ }
202
+ countGrouped(since, unit) {
203
+ const format = unit === 'day' ? '%Y-%m-%d' : '%Y-%m-%d %H';
204
+ return this.db
205
+ .prepare(`SELECT strftime('${format}', received_at / 1000, 'unixepoch', 'localtime') AS bucket, worker, state, count(*) AS n
206
+ FROM tasks WHERE received_at >= ? GROUP BY bucket, worker, state`)
207
+ .all(since).map((r) => ({ ...r, n: Number(r.n) }));
208
+ }
209
+ distinctWorkers() {
210
+ return this.db.prepare('SELECT DISTINCT worker FROM tasks').all().map((r) => r.worker);
211
+ }
212
+ taskCounts() {
213
+ const row = this.db
214
+ .prepare(`SELECT count(*) AS total,
215
+ coalesce(sum(state IN ('received','running') AND repeat IS NULL), 0) AS unfinished,
216
+ coalesce(sum(state IN ('received','running') AND repeat IS NOT NULL), 0) AS repeating
217
+ FROM tasks`)
218
+ .get();
219
+ return {
220
+ total: Number(row.total),
221
+ unfinished: Number(row.unfinished),
222
+ repeating: Number(row.repeating)
223
+ };
224
+ }
225
+ fileBytes() {
226
+ const wal = `${this.file}-wal`;
227
+ return statSync(this.file).size + (existsSync(wal) ? statSync(wal).size : 0);
228
+ }
229
+ pruneUsage(filter, now) {
230
+ const cutoff = pruneCutoff(filter.before, now);
231
+ const { where, params } = pruneWhere(filter.worker, cutoff);
232
+ const row = this.db
233
+ .prepare(`SELECT count(*) AS count, coalesce(sum(${ROW_BYTES}), 0) AS bytes FROM tasks WHERE ${where}`)
234
+ .get(...params);
235
+ let recent = 0;
236
+ if (cutoff < filter.before) {
237
+ const r = pruneWhere(filter.worker, filter.before);
238
+ recent =
239
+ Number(this.db
240
+ .prepare(`SELECT count(*) AS n FROM tasks WHERE ${r.where} AND finished_at >= ?`)
241
+ .get(...r.params, cutoff).n) || 0;
242
+ }
243
+ return { count: Number(row.count), bytes: Number(row.bytes), recent };
244
+ }
245
+ prune(filter, now) {
246
+ const { where, params } = pruneWhere(filter.worker, pruneCutoff(filter.before, now));
247
+ const stmt = this.db.prepare(`DELETE FROM tasks WHERE rowid IN (SELECT rowid FROM tasks WHERE ${where} LIMIT ${TASKS_PRUNE_BATCH})`);
248
+ let deleted = 0;
249
+ for (;;) {
250
+ const n = this.tx(() => Number(stmt.run(...params).changes));
251
+ deleted += n;
252
+ if (n < TASKS_PRUNE_BATCH)
253
+ return deleted;
254
+ }
255
+ }
256
+ recordQuota(quota, now) {
257
+ this.setMeta(QUOTA_META.quota, JSON.stringify(quota), now);
258
+ }
259
+ getQuota() {
260
+ const row = this.db
261
+ .prepare('SELECT value, updated_at FROM meta WHERE key = ?')
262
+ .get(QUOTA_META.quota);
263
+ if (!row)
264
+ return null;
265
+ try {
266
+ const quota = JSON.parse(row.value);
267
+ if (typeof quota?.used !== 'number' || typeof quota.limit !== 'number')
268
+ return null;
269
+ return { quota, at: Number(row.updated_at) };
270
+ }
271
+ catch {
272
+ return null;
273
+ }
274
+ }
275
+ getMeta(key) {
276
+ const row = this.db.prepare('SELECT value FROM meta WHERE key = ?').get(key);
277
+ return row?.value ?? null;
278
+ }
279
+ setMeta(key, value, now) {
280
+ this.db
281
+ .prepare(`INSERT INTO meta (key, value, updated_at) VALUES (?, ?, ?)
282
+ ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`)
283
+ .run(key, value, now);
284
+ }
285
+ setMetas(entries, now) {
286
+ this.tx(() => {
287
+ for (const [key, value] of Object.entries(entries))
288
+ this.setMeta(key, value, now);
289
+ });
290
+ }
291
+ deleteMeta(key) {
292
+ this.db.prepare('DELETE FROM meta WHERE key = ?').run(key);
293
+ }
294
+ getLiveState() {
295
+ const raw = this.getMeta(LIVE_META.state);
296
+ if (raw === null)
297
+ return null;
298
+ try {
299
+ const v = JSON.parse(raw);
300
+ return typeof v === 'object' && v !== null && typeof v.connected === 'boolean' ? v : null;
301
+ }
302
+ catch {
303
+ return null;
304
+ }
305
+ }
306
+ setLiveState(state, now) {
307
+ this.setMeta(LIVE_META.state, JSON.stringify(state), now);
308
+ }
309
+ recordStop(reason, now, quotaResetAt) {
310
+ this.tx(() => {
311
+ this.setMeta(META.stopReason, reason, now);
312
+ this.setMeta(META.stoppedAt, String(now), now);
313
+ if (quotaResetAt)
314
+ this.setMeta(META.quotaResetAt, quotaResetAt, now);
315
+ else
316
+ this.deleteMeta(META.quotaResetAt);
317
+ });
318
+ }
319
+ getStop() {
320
+ const reason = this.getMeta(META.stopReason);
321
+ if (reason === null)
322
+ return null;
323
+ return {
324
+ reason: reason,
325
+ stoppedAt: Number(this.getMeta(META.stoppedAt) ?? 0),
326
+ quotaResetAt: this.getMeta(META.quotaResetAt)
327
+ };
328
+ }
329
+ clearStop() {
330
+ this.tx(() => {
331
+ for (const key of Object.values(META))
332
+ this.deleteMeta(key);
333
+ });
334
+ }
335
+ recordEnd(reason, now, deletedWorkers) {
336
+ this.tx(() => {
337
+ this.setMeta(END_META.reason, reason, now);
338
+ this.setMeta(END_META.endedAt, String(now), now);
339
+ this.setMeta(END_META.workers, JSON.stringify(deletedWorkers), now);
340
+ });
341
+ }
342
+ getEnd() {
343
+ const reason = this.getMeta(END_META.reason);
344
+ if (reason === null)
345
+ return null;
346
+ let deletedWorkers = [];
347
+ try {
348
+ const parsed = JSON.parse(this.getMeta(END_META.workers) ?? '[]');
349
+ if (Array.isArray(parsed))
350
+ deletedWorkers = parsed.filter((n) => typeof n === 'string');
351
+ }
352
+ catch {
353
+ }
354
+ return {
355
+ reason: reason,
356
+ endedAt: Number(this.getMeta(END_META.endedAt) ?? 0),
357
+ deletedWorkers
358
+ };
359
+ }
360
+ clearEnd() {
361
+ this.tx(() => {
362
+ for (const key of Object.values(END_META))
363
+ this.deleteMeta(key);
364
+ });
365
+ }
366
+ tx(fn) {
367
+ return transaction(this.db, fn);
368
+ }
369
+ }
370
+ const ROW_BYTES = 'length(task_id) + length(worker) + length(state) + coalesce(length(lease_id), 0) + coalesce(length(last_error), 0) + 48';
371
+ function pruneCutoff(before, now) {
372
+ return Math.min(before, now - TASKS_PRUNE_MIN_AGE_MS);
373
+ }
374
+ function pruneWhere(worker, cutoff) {
375
+ const conds = [
376
+ `state IN (${TERMINAL_STATES.map(() => '?').join(',')})`,
377
+ 'finished_at IS NOT NULL',
378
+ 'finished_at < ?'
379
+ ];
380
+ const params = [...TERMINAL_STATES, cutoff];
381
+ if (worker !== undefined) {
382
+ conds.push('worker = ?');
383
+ params.push(worker);
384
+ }
385
+ return { where: conds.join(' AND '), params };
386
+ }
387
+ function transaction(db, fn) {
388
+ db.exec('BEGIN IMMEDIATE');
389
+ try {
390
+ const result = fn();
391
+ db.exec('COMMIT');
392
+ return result;
393
+ }
394
+ catch (err) {
395
+ if (db.isTransaction)
396
+ db.exec('ROLLBACK');
397
+ throw err;
398
+ }
399
+ }
400
+ function userVersion(db) {
401
+ const row = db.prepare('PRAGMA user_version').get();
402
+ return Number(row.user_version);
403
+ }
404
+ function migrate(db, file) {
405
+ if (userVersion(db) === SCHEMA_VERSION)
406
+ return;
407
+ transaction(db, () => {
408
+ const current = userVersion(db);
409
+ if (current > SCHEMA_VERSION)
410
+ throw new NewerSchemaError(file, current, SCHEMA_VERSION);
411
+ for (let v = current; v < SCHEMA_VERSION; v++)
412
+ db.exec(MIGRATIONS[v]);
413
+ db.exec(`PRAGMA user_version = ${SCHEMA_VERSION}`);
414
+ });
415
+ }
416
+ const EXTRA_COLUMNS = [
417
+ ['acked_at', 'INTEGER'],
418
+ ['callback_url', 'TEXT'],
419
+ ['repeat', 'TEXT'],
420
+ ['input_json', 'TEXT'],
421
+ ['run_no', 'INTEGER'],
422
+ ['next_at', 'INTEGER'],
423
+ ['fail_streak', 'INTEGER'],
424
+ ['runs', 'INTEGER'],
425
+ ['failed', 'INTEGER'],
426
+ ['last_run_at', 'INTEGER']
427
+ ];
428
+ function repeatColumns(task) {
429
+ if (!task.repeat)
430
+ return [null, null];
431
+ return [JSON.stringify(task.repeat), JSON.stringify(task.input ?? null)];
432
+ }
433
+ function ensureColumns(db) {
434
+ const missing = () => {
435
+ const have = new Set(db.prepare('PRAGMA table_info(tasks)').all().map((c) => c.name));
436
+ return EXTRA_COLUMNS.filter(([name]) => !have.has(name));
437
+ };
438
+ if (missing().length === 0)
439
+ return;
440
+ transaction(db, () => {
441
+ for (const [name, type] of missing())
442
+ db.exec(`ALTER TABLE tasks ADD COLUMN ${name} ${type}`);
443
+ });
444
+ }
445
+ function ensureIndexes(db) {
446
+ try {
447
+ db.exec('CREATE INDEX IF NOT EXISTS tasks_received ON tasks (received_at)');
448
+ }
449
+ catch {
450
+ }
451
+ }
452
+ function openFailure(file, err) {
453
+ const e = err;
454
+ const reason = e?.message ?? String(err);
455
+ if (e?.code === 'EACCES' || e?.code === 'EPERM') {
456
+ return `cannot open ${file}: permission denied. Check the owner and permissions of that folder.`;
457
+ }
458
+ if (e?.errcode === 26 || e?.errcode === 11) {
459
+ return `${file} is corrupted (${reason}). Move it aside and run the command again — it only holds local state.`;
460
+ }
461
+ return `cannot open ${file}: ${reason}`;
462
+ }
@@ -0,0 +1,75 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { CREDENTIALS_FILE, ConfigError, ENV, HOST_TOKEN_PREFIX, LOCAL_SERVER_TOKEN_FILE } from "./domain/index.js";
5
+ import { writeSecretFile } from "./secure-fs.js";
6
+ const HOST_TOKEN = new RegExp(`^${HOST_TOKEN_PREFIX}[0-9a-f]{48}$`);
7
+ export function isHostToken(value) {
8
+ return HOST_TOKEN.test(value);
9
+ }
10
+ export function credentialsPath(configDir) {
11
+ return path.join(configDir, CREDENTIALS_FILE);
12
+ }
13
+ export function resolveHostToken(configDir, env) {
14
+ const fromEnv = env[ENV.token];
15
+ if (fromEnv !== undefined && fromEnv !== '') {
16
+ const token = fromEnv.trim();
17
+ if (!isHostToken(token)) {
18
+ throw new ConfigError(`${ENV.token} is not a host token (expected ${HOST_TOKEN_PREFIX}...)`);
19
+ }
20
+ return { token, source: 'env', location: ENV.token };
21
+ }
22
+ const file = credentialsPath(configDir);
23
+ const saved = readCredentials(file);
24
+ return saved ? { token: saved.hostToken, source: 'file', location: file } : null;
25
+ }
26
+ export function saveHostToken(configDir, token, now) {
27
+ if (!isHostToken(token))
28
+ throw new ConfigError(`not a host token (expected ${HOST_TOKEN_PREFIX}...)`);
29
+ const file = credentialsPath(configDir);
30
+ const body = { hostToken: token, savedAt: new Date(now).toISOString() };
31
+ writeSecretFile(file, JSON.stringify(body, null, '\t') + '\n');
32
+ return file;
33
+ }
34
+ export function deleteHostToken(configDir) {
35
+ const file = credentialsPath(configDir);
36
+ if (!existsSync(file))
37
+ return false;
38
+ rmSync(file);
39
+ return true;
40
+ }
41
+ export function getOrCreateLocalServerToken(configDir) {
42
+ const file = path.join(configDir, LOCAL_SERVER_TOKEN_FILE);
43
+ if (existsSync(file)) {
44
+ const token = readFileSync(file, 'utf8').trim();
45
+ if (/^[0-9a-f]{64}$/.test(token))
46
+ return token;
47
+ }
48
+ const token = randomBytes(32).toString('hex');
49
+ writeSecretFile(file, token + '\n');
50
+ return token;
51
+ }
52
+ function readCredentials(file) {
53
+ let text;
54
+ try {
55
+ text = readFileSync(file, 'utf8');
56
+ }
57
+ catch (err) {
58
+ const code = err.code;
59
+ if (code === 'ENOENT')
60
+ return null;
61
+ throw new ConfigError(`cannot read ${file} (${code ?? err.message})`);
62
+ }
63
+ let parsed;
64
+ try {
65
+ parsed = JSON.parse(text);
66
+ }
67
+ catch {
68
+ throw new ConfigError(`${file} is corrupted. Run '3minapi login' again.`);
69
+ }
70
+ const token = parsed?.hostToken;
71
+ if (typeof token !== 'string' || !isHostToken(token)) {
72
+ throw new ConfigError(`${file} does not hold a valid host token. Run '3minapi login' again.`);
73
+ }
74
+ return parsed;
75
+ }
@@ -0,0 +1,121 @@
1
+ export const BIN = '3minapi';
2
+ export const PACKAGE_NAME = '@3minapi/cli';
3
+ export const MIN_NODE_VERSION = '24.12.0';
4
+ export const APP_DIR_NAME = '3minapi';
5
+ export const PROJECT_CONFIG_FILE = '3minapi.json';
6
+ export const AGENTS_FILE = 'AGENTS.md';
7
+ export const CLAUDE_FILE = 'CLAUDE.md';
8
+ export const DEFAULT_PROJECT_DIR = '3minapi';
9
+ export const USER_CONFIG_FILE = 'config.json';
10
+ export const CREDENTIALS_FILE = 'credentials.json';
11
+ export const LOCAL_SERVER_TOKEN_FILE = 'local-server-token';
12
+ export const CONTROL_DB_FILE = 'control.db';
13
+ export const PROJECT_RECORD_FILE = 'project.json';
14
+ export const RUNNER_LOCK_FILE = 'runner.json';
15
+ export const RUNNER_STOP_FILE = 'runner.stop';
16
+ export const RUNNER_RELOAD_FILE = 'runner.reload';
17
+ export const RUNNER_RELOADED_FILE = 'runner.reloaded';
18
+ export const RUNNER_DELETED_FILE = 'runner.deleted';
19
+ export const RUNNER_OUT_FILE = 'runner.out';
20
+ export const DASHBOARD_LOCK_FILE = 'dashboard.json';
21
+ export const DASHBOARD_STOP_FILE = 'dashboard.stop';
22
+ export const DASHBOARD_LINK_FILE = 'dashboard.link';
23
+ export const DASHBOARD_OUT_FILE = 'dashboard.out';
24
+ export const OUT_FILE_MAX_BYTES = 10 * 1024 * 1024;
25
+ export const APP_LOG_DIR = 'app';
26
+ export const WORKER_LOG_DIR = 'workers';
27
+ export const LOG_FILE_EXTENSION = '.jsonl';
28
+ export const SECRET_FILE_MODE = 0o600;
29
+ export const PRIVATE_DIR_MODE = 0o700;
30
+ export const ENV = {
31
+ home: 'THREEMINAPI_HOME',
32
+ token: 'THREEMINAPI_TOKEN',
33
+ apiUrl: 'THREEMINAPI_API_URL',
34
+ pollIntervalMs: 'THREEMINAPI_POLL_INTERVAL_MS',
35
+ logRetentionDays: 'THREEMINAPI_LOG_RETENTION_DAYS',
36
+ logMaxBytes: 'THREEMINAPI_LOG_MAX_BYTES',
37
+ concurrency: 'THREEMINAPI_CONCURRENCY',
38
+ localServerPort: 'THREEMINAPI_LOCAL_SERVER_PORT',
39
+ receiveMode: 'THREEMINAPI_RECEIVE_MODE',
40
+ detached: 'THREEMINAPI_DETACHED',
41
+ noUpdateNotice: 'THREEMINAPI_NO_UPDATE_NOTICE'
42
+ };
43
+ export const WORKER_SECRET_ENV = (name) => `THREEMINAPI_WORKER_${envName(name)}_SECRET`;
44
+ export const WORKER_KEY_ENV = (name) => `THREEMINAPI_WORKER_${envName(name)}_KEY`;
45
+ function envName(name) {
46
+ return name.toUpperCase().replace(/-/g, '_');
47
+ }
48
+ export const PROXY_ENV_VARS = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'];
49
+ export const DEFAULT_API_URL = 'https://api.3minapi.com';
50
+ export const DEFAULT_WORKERS_DIR = 'workers';
51
+ export const DEFAULT_POLL_INTERVAL_MS = 60_000;
52
+ export const POLL_INTERVAL_MIN_MS = 5_000;
53
+ export const POLL_INTERVAL_MAX_MS = 3_600_000;
54
+ export const LIVE_CONNECT_TIMEOUT_MS = 30_000;
55
+ export const EXIT_GRACE_MS = 3_000;
56
+ export const DEFAULT_CONCURRENCY = 4;
57
+ export const CONCURRENCY_MIN = 1;
58
+ export const CONCURRENCY_MAX = 50;
59
+ export const DEFAULT_LOCAL_SERVER_PORT = 3180;
60
+ export const LOCAL_SERVER_PORT_MIN = 1024;
61
+ export const LOCAL_SERVER_PORT_MAX = 65535;
62
+ export const DEFAULT_LOG_RETENTION_DAYS = 30;
63
+ export const LOG_RETENTION_MIN_DAYS = 1;
64
+ export const LOG_RETENTION_MAX_DAYS = 365;
65
+ export const MIB = 1024 * 1024;
66
+ export const DEFAULT_LOG_MAX_BYTES = 300 * MIB;
67
+ export const LOG_MAX_BYTES_MIN = 10 * MIB;
68
+ export const LOG_MAX_BYTES_MAX = 1024 * MIB;
69
+ export const LOG_FILE_MAX_BYTES = 10 * MIB;
70
+ export const LOG_STRING_MAX_CHARS = 4_000;
71
+ export const LAST_ERROR_MAX_CHARS = 256;
72
+ export const ERROR_MESSAGE_MAX_CHARS = 4_000;
73
+ export const EVENT_MESSAGE_MAX_CHARS = 200;
74
+ export const REPEAT_FAIL_STREAK_MAX = 5;
75
+ export const REPEAT_TICK_MS = 1_000;
76
+ export const CLI_ENV_PREFIX = 'THREEMINAPI_';
77
+ export const WORKER_EXTENSIONS = ['.js', '.mjs', '.ts', '.mts'];
78
+ export const TEMPLATE_EXTENSION = { js: '.mjs', ts: '.mts' };
79
+ export const USER_DB_FILE = 'data.db';
80
+ export const RESULT_TABLE_PREFIX = '_3minapi_';
81
+ export const RESULTS_TABLE = `${RESULT_TABLE_PREFIX}results`;
82
+ export const RESULTS_META_TABLE = `${RESULT_TABLE_PREFIX}meta`;
83
+ export const RESULTS_SCHEMA_VERSION = 1;
84
+ export const RESULT_RESERVED_KEY = '_3minapi';
85
+ export const REPORT_ENVELOPE_RESERVE = 1024;
86
+ export const STORED_ERROR_MAX_CHARS = 1_000_000;
87
+ export const SHAPE_MAX_DEPTH = 8;
88
+ export const SHAPE_MAX_PATHS = 200;
89
+ export const SHAPE_MAX_VALUES = 10;
90
+ export const SHAPE_VALUE_MAX_CHARS = 40;
91
+ export const SHAPE_PATH_MAX_CHARS = 200;
92
+ export const SUBSTITUTE_MAX_BYTES = 8 * 1024;
93
+ export const DESCRIBE_MAX_GROUPS = 20;
94
+ export const DESCRIBE_MAX_DIFF_PATHS = 50;
95
+ export const DESCRIBE_MAX_MERGE = 1000;
96
+ export const RESULTS_LIST_DEFAULT = 20;
97
+ export const RESULTS_QUERY_TIMEOUT_MS = 30_000;
98
+ export const READ_MAX_BYTES = 90 * 1024;
99
+ export const READ_LIMIT_MAX = 10_000;
100
+ export const BUILTIN_TASK_IDS_MAX = 100;
101
+ export const GITIGNORE_ENTRIES = ['.env', `${USER_DB_FILE}*`];
102
+ export const DOCTOR_API_TIMEOUT_MS = 5_000;
103
+ export const API_TIMEOUT_MS = 15_000;
104
+ export const NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS = 2_000;
105
+ export const TASKS_PRUNE_MIN_AGE_MS = 24 * 60 * 60 * 1000;
106
+ export const TASKS_PRUNE_BATCH = 5_000;
107
+ export const DB_BUSY_TIMEOUT_MS = 5_000;
108
+ export const LOG_FOLLOW_INTERVAL_MS = 500;
109
+ export const DEFAULT_WORKER_TIMEOUT_MS = 60_000;
110
+ export const WORKER_TIMEOUT_MAX_MS = 23 * 60 * 60 * 1000;
111
+ export const CANCEL_GRACE_MS = 2_000;
112
+ export const TERMINATE_WAIT_MS = 5_000;
113
+ export const STOP_CHECK_INTERVAL_MS = 1_000;
114
+ export const STOP_WAIT_MS = 15_000;
115
+ export const RELOAD_WAIT_MS = 120_000;
116
+ export const DETACH_READY_MS = 10_000;
117
+ export const HEARTBEAT_INTERVAL_MS = 10_000;
118
+ export const HEARTBEAT_STALE_MS = 60_000;
119
+ export const MONTH_MS = 30 * 24 * 60 * 60 * 1000;
120
+ export const PUSH_TIMESTAMP_TOLERANCE_S = 300;
121
+ export const DEFAULT_LOG_LINES = 100;