@3minapi/cli 0.0.0-stage → 0.22.1
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 +88 -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,245 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync, renameSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { uptime } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { BIN, CliError, HEARTBEAT_INTERVAL_MS, HEARTBEAT_STALE_MS, RUNNER_LOCK_FILE, RUNNER_DELETED_FILE, RUNNER_RELOADED_FILE, RUNNER_RELOAD_FILE, RUNNER_STOP_FILE, SECRET_FILE_MODE } from "../domain/index.js";
|
|
6
|
+
import { ensurePrivateDir, writeSecretFile } from "../secure-fs.js";
|
|
7
|
+
import { sleep } from "../time.js";
|
|
8
|
+
const STARTING_GRACE_MS = 10_000;
|
|
9
|
+
const BOOT_TOLERANCE_MS = 60_000;
|
|
10
|
+
export function bootTime(now = Date.now()) {
|
|
11
|
+
return now - uptime() * 1000;
|
|
12
|
+
}
|
|
13
|
+
export function runnerMode(info) {
|
|
14
|
+
return info.mode ?? 'poll';
|
|
15
|
+
}
|
|
16
|
+
export function lockPath(dataDir) {
|
|
17
|
+
return path.join(dataDir, RUNNER_LOCK_FILE);
|
|
18
|
+
}
|
|
19
|
+
export function stopPath(dataDir) {
|
|
20
|
+
return path.join(dataDir, RUNNER_STOP_FILE);
|
|
21
|
+
}
|
|
22
|
+
function reloadPath(dataDir) {
|
|
23
|
+
return path.join(dataDir, RUNNER_RELOAD_FILE);
|
|
24
|
+
}
|
|
25
|
+
function reloadedPath(dataDir) {
|
|
26
|
+
return path.join(dataDir, RUNNER_RELOADED_FILE);
|
|
27
|
+
}
|
|
28
|
+
function deletedPath(dataDir) {
|
|
29
|
+
return path.join(dataDir, RUNNER_DELETED_FILE);
|
|
30
|
+
}
|
|
31
|
+
function readDeleted(file) {
|
|
32
|
+
try {
|
|
33
|
+
const v = JSON.parse(readFileSync(file, 'utf8'));
|
|
34
|
+
return typeof v?.nonce === 'string' && Array.isArray(v.names) ? v : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function isAlive(pid) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return err.code === 'EPERM';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function readRunner(dataDir) {
|
|
50
|
+
return readLockFile(lockPath(dataDir));
|
|
51
|
+
}
|
|
52
|
+
export function readLockFile(file) {
|
|
53
|
+
let text;
|
|
54
|
+
try {
|
|
55
|
+
text = readFileSync(file, 'utf8');
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
if (err.code === 'ENOENT')
|
|
59
|
+
return { state: 'stopped' };
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
if (text.trim() === '') {
|
|
63
|
+
const age = Date.now() - statSync(file, { throwIfNoEntry: false }).mtimeMs;
|
|
64
|
+
return age < STARTING_GRACE_MS ? { state: 'starting' } : { state: 'stopped' };
|
|
65
|
+
}
|
|
66
|
+
let info;
|
|
67
|
+
try {
|
|
68
|
+
info = JSON.parse(text);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return { state: 'stopped' };
|
|
72
|
+
}
|
|
73
|
+
if (!Number.isInteger(info.pid) || !isAlive(info.pid))
|
|
74
|
+
return { state: 'stopped' };
|
|
75
|
+
if (!Number.isFinite(info.startedAt) || info.startedAt < bootTime() - BOOT_TOLERANCE_MS) {
|
|
76
|
+
return { state: 'stopped' };
|
|
77
|
+
}
|
|
78
|
+
return { state: 'running', info };
|
|
79
|
+
}
|
|
80
|
+
export function claimLockFile(file, info, busy) {
|
|
81
|
+
ensurePrivateDir(path.dirname(file));
|
|
82
|
+
for (let attempt = 0;; attempt++) {
|
|
83
|
+
try {
|
|
84
|
+
writeFileSync(file, '', { flag: 'wx', mode: SECRET_FILE_MODE });
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err.code !== 'EEXIST' || attempt > 0)
|
|
89
|
+
throw busy(err);
|
|
90
|
+
if (readLockFile(file).state !== 'stopped')
|
|
91
|
+
throw busy(err);
|
|
92
|
+
rmSync(file, { force: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
writeFileSync(file, JSON.stringify(info), { mode: SECRET_FILE_MODE });
|
|
96
|
+
}
|
|
97
|
+
export function releaseLockFile(file, nonce) {
|
|
98
|
+
try {
|
|
99
|
+
const current = JSON.parse(readFileSync(file, 'utf8'));
|
|
100
|
+
if (current.nonce === nonce)
|
|
101
|
+
rmSync(file, { force: true });
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
export function touchLockFile(file) {
|
|
107
|
+
try {
|
|
108
|
+
const now = new Date();
|
|
109
|
+
utimesSync(file, now, now);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
export function heartbeatStopped(file, info, now = Date.now()) {
|
|
115
|
+
if (info.heartbeat !== true)
|
|
116
|
+
return false;
|
|
117
|
+
const stat = statSync(file, { throwIfNoEntry: false });
|
|
118
|
+
return stat !== undefined && now - stat.mtimeMs > HEARTBEAT_STALE_MS;
|
|
119
|
+
}
|
|
120
|
+
export function heartbeater(file) {
|
|
121
|
+
let last = Date.now();
|
|
122
|
+
return () => {
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
if (now - last < HEARTBEAT_INTERVAL_MS)
|
|
125
|
+
return;
|
|
126
|
+
last = now;
|
|
127
|
+
touchLockFile(file);
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export function stopFileMatches(file, nonce) {
|
|
131
|
+
try {
|
|
132
|
+
return readFileSync(file, 'utf8').trim() === nonce;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export async function readSettled(read) {
|
|
139
|
+
const current = read();
|
|
140
|
+
if (current.state !== 'starting')
|
|
141
|
+
return current;
|
|
142
|
+
await sleep(2_000);
|
|
143
|
+
return read();
|
|
144
|
+
}
|
|
145
|
+
export function acquireRunnerLock(dataDir, fields, now) {
|
|
146
|
+
const file = lockPath(dataDir);
|
|
147
|
+
const info = {
|
|
148
|
+
pid: process.pid,
|
|
149
|
+
nonce: randomBytes(8).toString('hex'),
|
|
150
|
+
startedAt: now,
|
|
151
|
+
heartbeat: true,
|
|
152
|
+
...fields
|
|
153
|
+
};
|
|
154
|
+
claimLockFile(file, info, (cause) => alreadyRunning(dataDir, cause));
|
|
155
|
+
const beat = heartbeater(file);
|
|
156
|
+
rmSync(stopPath(dataDir), { force: true });
|
|
157
|
+
rmSync(reloadPath(dataDir), { force: true });
|
|
158
|
+
rmSync(reloadedPath(dataDir), { force: true });
|
|
159
|
+
rmSync(deletedPath(dataDir), { force: true });
|
|
160
|
+
let released = false;
|
|
161
|
+
return {
|
|
162
|
+
info,
|
|
163
|
+
stopRequested() {
|
|
164
|
+
return stopFileMatches(stopPath(dataDir), info.nonce);
|
|
165
|
+
},
|
|
166
|
+
heartbeat: beat,
|
|
167
|
+
takeReload() {
|
|
168
|
+
let text;
|
|
169
|
+
try {
|
|
170
|
+
text = readFileSync(reloadPath(dataDir), 'utf8');
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
rmSync(reloadPath(dataDir), { force: true });
|
|
176
|
+
return text.trim() === info.nonce;
|
|
177
|
+
},
|
|
178
|
+
takeDeleted() {
|
|
179
|
+
if (!existsSync(deletedPath(dataDir)))
|
|
180
|
+
return [];
|
|
181
|
+
const taken = `${deletedPath(dataDir)}.${randomBytes(6).toString('hex')}.taking`;
|
|
182
|
+
try {
|
|
183
|
+
renameSync(deletedPath(dataDir), taken);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return [];
|
|
187
|
+
}
|
|
188
|
+
const record = readDeleted(taken);
|
|
189
|
+
rmSync(taken, { force: true });
|
|
190
|
+
return record?.nonce === info.nonce ? record.names.filter((n) => typeof n === 'string') : [];
|
|
191
|
+
},
|
|
192
|
+
writeReload(record) {
|
|
193
|
+
writeFileSync(reloadedPath(dataDir), JSON.stringify({ nonce: info.nonce, ...record }), {
|
|
194
|
+
mode: SECRET_FILE_MODE
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
release() {
|
|
198
|
+
if (released)
|
|
199
|
+
return;
|
|
200
|
+
released = true;
|
|
201
|
+
releaseLockFile(file, info.nonce);
|
|
202
|
+
rmSync(stopPath(dataDir), { force: true });
|
|
203
|
+
rmSync(reloadPath(dataDir), { force: true });
|
|
204
|
+
rmSync(deletedPath(dataDir), { force: true });
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
export function requestReload(dataDir, info, now = Date.now()) {
|
|
209
|
+
writeFileSync(reloadPath(dataDir), info.nonce, { mode: SECRET_FILE_MODE });
|
|
210
|
+
return now;
|
|
211
|
+
}
|
|
212
|
+
export function notifyDeleted(dataDir, info, names) {
|
|
213
|
+
const prev = readDeleted(deletedPath(dataDir));
|
|
214
|
+
const merged = new Set(prev?.nonce === info.nonce ? prev.names : []);
|
|
215
|
+
for (const n of names)
|
|
216
|
+
merged.add(n);
|
|
217
|
+
writeSecretFile(deletedPath(dataDir), JSON.stringify({ nonce: info.nonce, names: [...merged] }));
|
|
218
|
+
}
|
|
219
|
+
export function readReload(dataDir, info) {
|
|
220
|
+
try {
|
|
221
|
+
const record = JSON.parse(readFileSync(reloadedPath(dataDir), 'utf8'));
|
|
222
|
+
return record.nonce === info.nonce ? record : null;
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
export function readRunnerSettled(dataDir) {
|
|
229
|
+
return readSettled(() => readRunner(dataDir));
|
|
230
|
+
}
|
|
231
|
+
export function requestStop(dataDir, info) {
|
|
232
|
+
writeFileSync(stopPath(dataDir), info.nonce, { mode: SECRET_FILE_MODE });
|
|
233
|
+
}
|
|
234
|
+
function alreadyRunning(dataDir, cause) {
|
|
235
|
+
const current = readRunner(dataDir);
|
|
236
|
+
if (current.state === 'running') {
|
|
237
|
+
const where = current.info.detached ? 'in the background' : 'in another terminal';
|
|
238
|
+
return new CliError(`3minapi is already running ${where} (pid ${current.info.pid}). Only one can run per host.\n` +
|
|
239
|
+
`Stop it with '${BIN} stop' first.`);
|
|
240
|
+
}
|
|
241
|
+
if (current.state === 'starting') {
|
|
242
|
+
return new CliError(`another '${BIN} start' is starting right now. Check with '${BIN} status'.`);
|
|
243
|
+
}
|
|
244
|
+
return new CliError(`cannot create ${lockPath(dataDir)}: ${cause.message}`);
|
|
245
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { ControlDb } from "../control-db.js";
|
|
2
|
+
import { Logger, pruneLogs } from "../logger.js";
|
|
3
|
+
import { updateCommand, updateNote } from "../update-notice.js";
|
|
4
|
+
export function openSession(ctx) {
|
|
5
|
+
const config = ctx.loadConfig();
|
|
6
|
+
const db = ControlDb.open(ctx.dirs.data);
|
|
7
|
+
try {
|
|
8
|
+
const { logRetentionDays, logMaxBytes } = config.values;
|
|
9
|
+
const logger = new Logger({
|
|
10
|
+
dir: ctx.dirs.logs,
|
|
11
|
+
now: ctx.now,
|
|
12
|
+
onMaintenance(now) {
|
|
13
|
+
pruneLogs({
|
|
14
|
+
dir: ctx.dirs.logs,
|
|
15
|
+
now,
|
|
16
|
+
retentionDays: logRetentionDays,
|
|
17
|
+
maxBytes: logMaxBytes
|
|
18
|
+
});
|
|
19
|
+
},
|
|
20
|
+
onError: (message) => ctx.out.warn(message)
|
|
21
|
+
});
|
|
22
|
+
return { config, out: ctx.out, db, logger, close: () => db.close() };
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
db.close();
|
|
26
|
+
throw err;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function recordStop(session, err, now) {
|
|
30
|
+
try {
|
|
31
|
+
session.db.recordStop(err.reason, now, err.resetAt);
|
|
32
|
+
}
|
|
33
|
+
catch (e) {
|
|
34
|
+
session.out.warn(`could not record the stop in ${session.db.file}: ${e.message}`);
|
|
35
|
+
}
|
|
36
|
+
session.logger.warn('stopped all API calls', { reason: err.reason, message: err.message });
|
|
37
|
+
}
|
|
38
|
+
export function clearStop(session) {
|
|
39
|
+
try {
|
|
40
|
+
session.db.clearStop();
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
session.out.warn(`could not clear the previous stop record in ${session.db.file}: ${e.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export function recordEnd(session, reason, deletedWorkers, now) {
|
|
47
|
+
try {
|
|
48
|
+
session.db.recordEnd(reason, now, deletedWorkers);
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
session.out.warn(`could not record why it ended in ${session.db.file}: ${e.message}`);
|
|
52
|
+
}
|
|
53
|
+
session.logger.warn('runner ended: no active workers', { deleted_workers: deletedWorkers });
|
|
54
|
+
}
|
|
55
|
+
export function clearRunnerRecords(session) {
|
|
56
|
+
clearStop(session);
|
|
57
|
+
try {
|
|
58
|
+
session.db.clearEnd();
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
session.out.warn(`could not clear the previous end record in ${session.db.file}: ${e.message}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function rememberQuota(session, quota, now) {
|
|
65
|
+
if (!quota)
|
|
66
|
+
return;
|
|
67
|
+
try {
|
|
68
|
+
session.db.recordQuota(quota, now);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function stopHint(reason) {
|
|
74
|
+
switch (reason) {
|
|
75
|
+
case 'host_token_invalid':
|
|
76
|
+
return "The host token was revoked or the host was deleted. Issue a new token on the dashboard and run '3minapi login'.";
|
|
77
|
+
case 'no_subscription':
|
|
78
|
+
return 'There is no active subscription for this account. Subscribe on the dashboard, then run the command again.';
|
|
79
|
+
case 'quota_exhausted':
|
|
80
|
+
return 'All API calls are stopped. After the quota resets or the plan is upgraded, run the command again.';
|
|
81
|
+
case 'cli_outdated':
|
|
82
|
+
return `All API calls are stopped until this CLI is updated. Run: ${updateCommand()}\n${updateNote(null)}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { listAllLogFiles } from "../logger.js";
|
|
2
|
+
export function hostUsage(db, logsDir) {
|
|
3
|
+
let controlDb = null;
|
|
4
|
+
try {
|
|
5
|
+
const { total, unfinished } = db.taskCounts();
|
|
6
|
+
controlDb = { file: db.file, bytes: db.fileBytes(), tasks: total, unfinished };
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
}
|
|
10
|
+
let files = [];
|
|
11
|
+
try {
|
|
12
|
+
files = listAllLogFiles(logsDir);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
controlDb,
|
|
18
|
+
logs: { dir: logsDir, bytes: files.reduce((n, f) => n + f.size, 0), files: files.length }
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { listChannelFiles } from "./logger.js";
|
|
3
|
+
export function tailLines(dir, count) {
|
|
4
|
+
const collected = [];
|
|
5
|
+
for (const f of listChannelFiles(dir).reverse()) {
|
|
6
|
+
const text = readIfExists(f.file);
|
|
7
|
+
if (text === null)
|
|
8
|
+
continue;
|
|
9
|
+
const lines = text.split('\n').filter((l) => l !== '');
|
|
10
|
+
collected.unshift(...lines);
|
|
11
|
+
if (collected.length >= count)
|
|
12
|
+
break;
|
|
13
|
+
}
|
|
14
|
+
return collected.slice(-count);
|
|
15
|
+
}
|
|
16
|
+
export function findLastRecord(dir, match, opts) {
|
|
17
|
+
let scanned = 0;
|
|
18
|
+
for (const f of listChannelFiles(dir).reverse()) {
|
|
19
|
+
if (scanned >= opts.maxBytes)
|
|
20
|
+
return null;
|
|
21
|
+
const text = readIfExists(f.file);
|
|
22
|
+
if (text === null)
|
|
23
|
+
continue;
|
|
24
|
+
scanned += f.size;
|
|
25
|
+
const lines = text.split('\n');
|
|
26
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
27
|
+
if (opts.contains !== undefined && !lines[i].includes(opts.contains))
|
|
28
|
+
continue;
|
|
29
|
+
const record = parseRecord(lines[i]);
|
|
30
|
+
if (record && match(record))
|
|
31
|
+
return record;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
export function parseRecord(line) {
|
|
37
|
+
if (line === '')
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const record = JSON.parse(line);
|
|
41
|
+
return record !== null && typeof record === 'object' && typeof record.timestamp === 'string'
|
|
42
|
+
? record
|
|
43
|
+
: null;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function readIfExists(file) {
|
|
50
|
+
try {
|
|
51
|
+
return readFileSync(file, 'utf8');
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
if (err.code === 'ENOENT')
|
|
55
|
+
return null;
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export { stripControl } from "./text.js";
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { appendFileSync, readdirSync, rmdirSync, rmSync, statSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { APP_LOG_DIR, LOG_FILE_EXTENSION, LOG_FILE_MAX_BYTES, LOG_STRING_MAX_CHARS, SECRET_FILE_MODE, WORKER_LOG_DIR } from "./domain/index.js";
|
|
4
|
+
import { redact, redactAndTruncate } from "./redact.js";
|
|
5
|
+
import { ensurePrivateDir } from "./secure-fs.js";
|
|
6
|
+
import { daysToMs, toIsoUtc, utcDate } from "./time.js";
|
|
7
|
+
const LOG_FILE = /^(\d{4}-\d{2}-\d{2})(?:\.(\d+))?\.jsonl$/;
|
|
8
|
+
export function channelDir(logsDir, worker) {
|
|
9
|
+
return worker === null
|
|
10
|
+
? path.join(logsDir, APP_LOG_DIR)
|
|
11
|
+
: path.join(logsDir, WORKER_LOG_DIR, worker);
|
|
12
|
+
}
|
|
13
|
+
export class Logger {
|
|
14
|
+
opts;
|
|
15
|
+
fileMaxBytes;
|
|
16
|
+
current = new Map();
|
|
17
|
+
lastMaintenanceDate = null;
|
|
18
|
+
reported = new Set();
|
|
19
|
+
constructor(opts) {
|
|
20
|
+
this.opts = opts;
|
|
21
|
+
this.fileMaxBytes = opts.fileMaxBytes ?? LOG_FILE_MAX_BYTES;
|
|
22
|
+
this.maintain(opts.now());
|
|
23
|
+
}
|
|
24
|
+
debug(body, attributes) {
|
|
25
|
+
this.write(null, 'debug', body, attributes);
|
|
26
|
+
}
|
|
27
|
+
info(body, attributes) {
|
|
28
|
+
this.write(null, 'info', body, attributes);
|
|
29
|
+
}
|
|
30
|
+
warn(body, attributes) {
|
|
31
|
+
this.write(null, 'warn', body, attributes);
|
|
32
|
+
}
|
|
33
|
+
error(body, attributes) {
|
|
34
|
+
this.write(null, 'error', body, attributes);
|
|
35
|
+
}
|
|
36
|
+
worker(name, level, body, attributes) {
|
|
37
|
+
this.write(name, level, body, attributes);
|
|
38
|
+
}
|
|
39
|
+
write(channel, level, body, attributes) {
|
|
40
|
+
const now = this.opts.now();
|
|
41
|
+
this.maintain(now);
|
|
42
|
+
const record = {
|
|
43
|
+
timestamp: toIsoUtc(now),
|
|
44
|
+
severity_text: level,
|
|
45
|
+
body: redactAndTruncate(body, LOG_STRING_MAX_CHARS)
|
|
46
|
+
};
|
|
47
|
+
if (attributes && Object.keys(attributes).length > 0) {
|
|
48
|
+
record.attributes = truncateStrings(redact(attributes));
|
|
49
|
+
}
|
|
50
|
+
const line = JSON.stringify(record) + '\n';
|
|
51
|
+
try {
|
|
52
|
+
const { file, rotated } = this.fileFor(channel, utcDate(now), Buffer.byteLength(line));
|
|
53
|
+
appendFileSync(file, line, { mode: SECRET_FILE_MODE });
|
|
54
|
+
if (rotated)
|
|
55
|
+
this.maintain(now, true);
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
this.report('write', `could not write to the log in ${this.opts.dir}: ${message(err)}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
fileFor(channel, date, bytes) {
|
|
62
|
+
const dir = channelDir(this.opts.dir, channel);
|
|
63
|
+
const key = channel ?? '';
|
|
64
|
+
let cur = this.current.get(key);
|
|
65
|
+
if (!cur || cur.date !== date) {
|
|
66
|
+
ensurePrivateDir(dir);
|
|
67
|
+
const latest = listChannelFiles(dir)
|
|
68
|
+
.filter((f) => f.date === date)
|
|
69
|
+
.at(-1);
|
|
70
|
+
cur = { date, index: latest?.index ?? 0, size: latest?.size ?? 0 };
|
|
71
|
+
this.current.set(key, cur);
|
|
72
|
+
}
|
|
73
|
+
let rotated = false;
|
|
74
|
+
if (cur.size > 0 && cur.size + bytes > this.fileMaxBytes) {
|
|
75
|
+
cur.index++;
|
|
76
|
+
cur.size = 0;
|
|
77
|
+
rotated = true;
|
|
78
|
+
}
|
|
79
|
+
cur.size += bytes;
|
|
80
|
+
return { file: path.join(dir, fileName(date, cur.index)), rotated };
|
|
81
|
+
}
|
|
82
|
+
maintain(now, force = false) {
|
|
83
|
+
const date = utcDate(now);
|
|
84
|
+
if (!force && date === this.lastMaintenanceDate)
|
|
85
|
+
return;
|
|
86
|
+
this.lastMaintenanceDate = date;
|
|
87
|
+
try {
|
|
88
|
+
this.opts.onMaintenance?.(now);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
this.report('maintenance', `log cleanup failed in ${this.opts.dir}: ${message(err)}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
report(kind, text) {
|
|
95
|
+
if (this.reported.has(kind))
|
|
96
|
+
return;
|
|
97
|
+
this.reported.add(kind);
|
|
98
|
+
this.opts.onError?.(text);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function fileName(date, index) {
|
|
102
|
+
return index === 0 ? `${date}${LOG_FILE_EXTENSION}` : `${date}.${index}${LOG_FILE_EXTENSION}`;
|
|
103
|
+
}
|
|
104
|
+
export function listChannelFiles(dir) {
|
|
105
|
+
const files = [];
|
|
106
|
+
for (const name of readdirOrEmpty(dir)) {
|
|
107
|
+
const m = LOG_FILE.exec(name);
|
|
108
|
+
if (!m)
|
|
109
|
+
continue;
|
|
110
|
+
const file = path.join(dir, name);
|
|
111
|
+
const st = statSync(file, { throwIfNoEntry: false });
|
|
112
|
+
if (!st?.isFile())
|
|
113
|
+
continue;
|
|
114
|
+
files.push({ file, dir, date: m[1], index: m[2] ? Number(m[2]) : 0, size: st.size });
|
|
115
|
+
}
|
|
116
|
+
return files.sort((a, b) => (a.date === b.date ? a.index - b.index : a.date < b.date ? -1 : 1));
|
|
117
|
+
}
|
|
118
|
+
export function listAllLogFiles(logsDir) {
|
|
119
|
+
const dirs = [channelDir(logsDir, null)];
|
|
120
|
+
const workersRoot = path.join(logsDir, WORKER_LOG_DIR);
|
|
121
|
+
for (const entry of readdirOrEmpty(workersRoot, true)) {
|
|
122
|
+
if (entry.isDirectory())
|
|
123
|
+
dirs.push(path.join(workersRoot, entry.name));
|
|
124
|
+
}
|
|
125
|
+
return dirs.flatMap(listChannelFiles);
|
|
126
|
+
}
|
|
127
|
+
export function pruneLogs({ dir, now, retentionDays, maxBytes }) {
|
|
128
|
+
const deleted = [];
|
|
129
|
+
const cutoffDate = utcDate(now - daysToMs(retentionDays));
|
|
130
|
+
const today = utcDate(now);
|
|
131
|
+
let files = listAllLogFiles(dir);
|
|
132
|
+
for (const f of files) {
|
|
133
|
+
if (f.date < cutoffDate)
|
|
134
|
+
remove(f, deleted);
|
|
135
|
+
}
|
|
136
|
+
files = files.filter((f) => f.date >= cutoffDate);
|
|
137
|
+
const active = new Set();
|
|
138
|
+
const byChannel = new Map();
|
|
139
|
+
for (const f of files)
|
|
140
|
+
if (f.date === today)
|
|
141
|
+
byChannel.set(f.dir, f);
|
|
142
|
+
for (const f of byChannel.values())
|
|
143
|
+
active.add(f.file);
|
|
144
|
+
let total = files.reduce((sum, f) => sum + f.size, 0);
|
|
145
|
+
const oldestFirst = [...files].sort((a, b) => a.date === b.date ? a.index - b.index : a.date < b.date ? -1 : 1);
|
|
146
|
+
for (const f of oldestFirst) {
|
|
147
|
+
if (total <= maxBytes)
|
|
148
|
+
break;
|
|
149
|
+
if (active.has(f.file))
|
|
150
|
+
continue;
|
|
151
|
+
remove(f, deleted);
|
|
152
|
+
total -= f.size;
|
|
153
|
+
}
|
|
154
|
+
removeEmptyWorkerDirs(dir);
|
|
155
|
+
return deleted;
|
|
156
|
+
}
|
|
157
|
+
function remove(f, deleted) {
|
|
158
|
+
rmSync(f.file, { force: true });
|
|
159
|
+
deleted.push(f.file);
|
|
160
|
+
}
|
|
161
|
+
function removeEmptyWorkerDirs(logsDir) {
|
|
162
|
+
const root = path.join(logsDir, WORKER_LOG_DIR);
|
|
163
|
+
for (const entry of readdirOrEmpty(root, true)) {
|
|
164
|
+
if (!entry.isDirectory())
|
|
165
|
+
continue;
|
|
166
|
+
const dir = path.join(root, entry.name);
|
|
167
|
+
if (readdirOrEmpty(dir).length > 0)
|
|
168
|
+
continue;
|
|
169
|
+
try {
|
|
170
|
+
rmdirSync(dir);
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
if (!['ENOTEMPTY', 'ENOENT', 'EEXIST'].includes(errno(err)))
|
|
174
|
+
throw err;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function readdirOrEmpty(dir, withFileTypes) {
|
|
179
|
+
try {
|
|
180
|
+
return withFileTypes ? readdirSync(dir, { withFileTypes: true }) : readdirSync(dir);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
if (errno(err) === 'ENOENT' || errno(err) === 'ENOTDIR')
|
|
184
|
+
return [];
|
|
185
|
+
throw err;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function errno(err) {
|
|
189
|
+
return err?.code ?? '';
|
|
190
|
+
}
|
|
191
|
+
function message(err) {
|
|
192
|
+
return err?.message ?? String(err);
|
|
193
|
+
}
|
|
194
|
+
function truncateStrings(value) {
|
|
195
|
+
if (typeof value === 'string') {
|
|
196
|
+
return value.length > LOG_STRING_MAX_CHARS
|
|
197
|
+
? value.slice(0, LOG_STRING_MAX_CHARS - 1) + '…'
|
|
198
|
+
: value;
|
|
199
|
+
}
|
|
200
|
+
if (Array.isArray(value))
|
|
201
|
+
return value.map(truncateStrings);
|
|
202
|
+
if (value !== null && typeof value === 'object') {
|
|
203
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, truncateStrings(v)]));
|
|
204
|
+
}
|
|
205
|
+
return value;
|
|
206
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { PULL_WORKER_NAME, RESERVED_NAME_PREFIX, UsageError, WINDOWS_RESERVED_NAME, isReservedWorkerName } from "./domain/index.js";
|
|
2
|
+
export function checkWorkerName(name) {
|
|
3
|
+
if (!PULL_WORKER_NAME.test(name)) {
|
|
4
|
+
return {
|
|
5
|
+
ok: false,
|
|
6
|
+
reason: 'use 1-50 characters of lowercase letters, digits, "_" or "-"'
|
|
7
|
+
};
|
|
8
|
+
}
|
|
9
|
+
if (WINDOWS_RESERVED_NAME.test(name)) {
|
|
10
|
+
return { ok: false, reason: `"${name}" is a reserved device name on Windows` };
|
|
11
|
+
}
|
|
12
|
+
if (isReservedWorkerName(name)) {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
reason: `names starting with "${RESERVED_NAME_PREFIX}" are reserved for built-in workers`
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return { ok: true };
|
|
19
|
+
}
|
|
20
|
+
export function invalidNameMessage(name, reason) {
|
|
21
|
+
return `invalid worker name "${name}": ${reason}`;
|
|
22
|
+
}
|
|
23
|
+
export function assertWorkerName(name) {
|
|
24
|
+
const check = checkWorkerName(name);
|
|
25
|
+
if (!check.ok)
|
|
26
|
+
throw new UsageError(invalidNameMessage(name, check.reason));
|
|
27
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { getDefaultAutoSelectFamilyAttemptTimeout, setDefaultAutoSelectFamilyAttemptTimeout } from 'node:net';
|
|
2
|
+
import { NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS } from "./domain/constants.js";
|
|
3
|
+
const FLAG = '--network-family-autoselection-attempt-timeout';
|
|
4
|
+
export function raiseConnectAttemptTimeout({ execArgv, nodeOptions }, target = NETWORK_FAMILY_ATTEMPT_TIMEOUT_MS) {
|
|
5
|
+
const userSet = execArgv.some((a) => a.startsWith(FLAG)) || (nodeOptions ?? '').includes(FLAG);
|
|
6
|
+
if (!userSet && getDefaultAutoSelectFamilyAttemptTimeout() < target) {
|
|
7
|
+
setDefaultAutoSelectFamilyAttemptTimeout(target);
|
|
8
|
+
}
|
|
9
|
+
return getDefaultAutoSelectFamilyAttemptTimeout();
|
|
10
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
export function openBrowser(url, platform) {
|
|
3
|
+
const [command, args] = platform === 'darwin'
|
|
4
|
+
? ['open', [url]]
|
|
5
|
+
: platform === 'win32'
|
|
6
|
+
?
|
|
7
|
+
['cmd', ['/c', 'start', '""', `"${url}"`]]
|
|
8
|
+
: ['xdg-open', [url]];
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
try {
|
|
11
|
+
const child = spawn(command, args, {
|
|
12
|
+
detached: true,
|
|
13
|
+
stdio: 'ignore',
|
|
14
|
+
windowsVerbatimArguments: platform === 'win32'
|
|
15
|
+
});
|
|
16
|
+
child.once('error', () => resolve(false));
|
|
17
|
+
child.once('spawn', () => {
|
|
18
|
+
child.unref();
|
|
19
|
+
resolve(true);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
resolve(false);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
}
|