mindvest-atlas 0.58.0 → 0.63.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/commands/alerts.js +75 -4
- package/src/commands/remote-control.js +3 -1
- package/src/commands/share.js +4 -2
- package/src/config.js +1 -1
- package/src/helpers/atlas_env.js +22 -1
- package/src/helpers/atlas_env.py +32 -3
- package/src/sharing.js +135 -6
- package/src/util/background.js +385 -0
- package/src/util/help.js +1 -0
package/package.json
CHANGED
package/src/commands/alerts.js
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
// `atlas alerts` — subscribe to the live alert-fire SSE feed, print each fire,
|
|
2
2
|
// and optionally decode the attached chart PNG to a file.
|
|
3
|
+
//
|
|
4
|
+
// ONE STREAM PER COMPUTER, and it outlives the terminal. Both are in
|
|
5
|
+
// `background`: a second stream would hand a reader every event twice, and a
|
|
6
|
+
// stream that dies with the window is the wrong lifetime for something whose
|
|
7
|
+
// job is to be there when an alert fires. `--detach` puts it in its own
|
|
8
|
+
// session, `--stop` ends it, and a plain `atlas alerts` is refused while one is
|
|
9
|
+
// already up.
|
|
3
10
|
import fs from 'node:fs';
|
|
4
11
|
import path from 'node:path';
|
|
5
12
|
import { API } from '../config.js';
|
|
6
13
|
import { runStream } from '../util/stream-loop.js';
|
|
7
14
|
import { formatAlert } from '../util/format.js';
|
|
8
15
|
import { color, println, eprintln } from '../util/ui.js';
|
|
16
|
+
import * as background from '../util/background.js';
|
|
17
|
+
|
|
18
|
+
// Names the slot, the claim file and the log, and it is the command's own name
|
|
19
|
+
// so the advice this prints ("atlas alerts --stop") is always the truth.
|
|
20
|
+
const SLOT = 'alerts';
|
|
21
|
+
|
|
22
|
+
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
9
23
|
|
|
10
24
|
function sanitizeFilename(name) {
|
|
11
25
|
return String(name || '').replace(/[^A-Za-z0-9._-]/g, '_') || 'alert.png';
|
|
@@ -13,6 +27,21 @@ function sanitizeFilename(name) {
|
|
|
13
27
|
|
|
14
28
|
export async function alertsCommand(args) {
|
|
15
29
|
const flags = args.flags;
|
|
30
|
+
if (flags.stop) return background.stop(SLOT);
|
|
31
|
+
if (flags.detach) return background.detach(SLOT);
|
|
32
|
+
const holder = background.holdUntilStopped(SLOT);
|
|
33
|
+
if (holder) {
|
|
34
|
+
background.alreadyRunning(SLOT, holder);
|
|
35
|
+
return 1;
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return await stream(flags);
|
|
39
|
+
} finally {
|
|
40
|
+
background.release(SLOT);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function stream(flags) {
|
|
16
45
|
const qs = new URLSearchParams();
|
|
17
46
|
if (flags.symbol) qs.set('symbol', String(flags.symbol).toUpperCase());
|
|
18
47
|
const name = flags.name || flags.type || flags['alert-type'];
|
|
@@ -20,8 +49,18 @@ export async function alertsCommand(args) {
|
|
|
20
49
|
// --no-chart (flags.chart === false) suppresses chart payloads server-side.
|
|
21
50
|
if (flags.chart === false || flags['no-chart']) qs.set('chart', '0');
|
|
22
51
|
|
|
23
|
-
|
|
52
|
+
let saveDir = (typeof flags['save-charts'] === 'string' && flags['save-charts'])
|
|
24
53
|
|| (typeof flags.out === 'string' && flags.out);
|
|
54
|
+
// DETACHED, THE CHARTS NEED SOMEWHERE TO GO. With no terminal there is nobody
|
|
55
|
+
// to have passed --save-charts, and the alternative is that every chart lives
|
|
56
|
+
// only as base64 inside a log that the size cap then rolls away. So a
|
|
57
|
+
// background stream saves them beside its log unless it was told not to fetch
|
|
58
|
+
// charts at all, or told where to put them.
|
|
59
|
+
let ownDir = false;
|
|
60
|
+
if (!saveDir && qs.get('chart') !== '0' && background.isBackground(SLOT)) {
|
|
61
|
+
saveDir = background.chartsDir(SLOT);
|
|
62
|
+
ownDir = true;
|
|
63
|
+
}
|
|
25
64
|
if (saveDir) fs.mkdirSync(saveDir, { recursive: true });
|
|
26
65
|
|
|
27
66
|
const json = !!flags.json;
|
|
@@ -31,23 +70,40 @@ export async function alertsCommand(args) {
|
|
|
31
70
|
eprintln(color.dim('Listening for alert fires' + (flags.symbol ? ` on ${String(flags.symbol).toUpperCase()}` : '') + (name ? ` (${name})` : '') + '…'));
|
|
32
71
|
}
|
|
33
72
|
|
|
34
|
-
|
|
73
|
+
const onStream = {
|
|
35
74
|
label: 'alerts',
|
|
36
75
|
// `--no-reconnect` parses to flags.reconnect === false (the --no- convention).
|
|
37
76
|
reconnect: flags.reconnect !== false,
|
|
38
77
|
onEvent: ({ event, payload }) => {
|
|
39
78
|
// Decode + save the chart if present and requested.
|
|
79
|
+
let saved = null;
|
|
40
80
|
if (saveDir && payload.chart && payload.chart.image_base64) {
|
|
41
|
-
|
|
81
|
+
// The server names every chart after its alert ("alert_SPY.png"), so in
|
|
82
|
+
// a stream that runs for months each fire would overwrite the one before
|
|
83
|
+
// it and only the latest per symbol would survive. A stamp in front
|
|
84
|
+
// keeps them all. Only in our OWN directory: somebody who passed
|
|
85
|
+
// --save-charts is relying on the name they already get.
|
|
86
|
+
let base = sanitizeFilename(payload.chart.filename);
|
|
87
|
+
if (ownDir) base = `${new Date().toISOString().replace(/[-:]/g, '').slice(0, 15)}_${base}`;
|
|
88
|
+
const file = path.join(saveDir, base);
|
|
42
89
|
try {
|
|
43
90
|
fs.writeFileSync(file, Buffer.from(payload.chart.image_base64, 'base64'));
|
|
91
|
+
saved = file;
|
|
44
92
|
if (!json) eprintln(color.green(' ↳ chart saved: ') + file);
|
|
45
93
|
} catch (err) {
|
|
46
94
|
eprintln(color.red(' ↳ could not save chart: ') + err.message);
|
|
47
95
|
}
|
|
96
|
+
if (ownDir) background.pruneDir(background.chartsDir(SLOT));
|
|
48
97
|
}
|
|
49
98
|
|
|
50
99
|
if (json) {
|
|
100
|
+
// The picture is already on disk, so the line points AT it instead of
|
|
101
|
+
// carrying a second copy as base64. That is what keeps a JSON log small
|
|
102
|
+
// enough to be worth keeping.
|
|
103
|
+
if (saved) {
|
|
104
|
+
const { image_base64: _drop, ...rest } = payload.chart;
|
|
105
|
+
payload = { ...payload, chart: { ...rest, file: saved } };
|
|
106
|
+
}
|
|
51
107
|
println(JSON.stringify(payload));
|
|
52
108
|
return;
|
|
53
109
|
}
|
|
@@ -56,5 +112,20 @@ export async function alertsCommand(args) {
|
|
|
56
112
|
for (const b of payload.bullets) println(' ' + color.dim(b));
|
|
57
113
|
}
|
|
58
114
|
},
|
|
59
|
-
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
if (flags.reconnect === false) return runStream(pathname, onStream);
|
|
118
|
+
|
|
119
|
+
// RUNS UNTIL SOMEBODY STOPS IT. runStream RETURNS only when it has given up,
|
|
120
|
+
// and it gives up on things that pass: an expired sign-in whose refresh
|
|
121
|
+
// happened to fail, a refusal from the server. A stream that is detached,
|
|
122
|
+
// with nobody watching the terminal, must not end for good on one of those -
|
|
123
|
+
// it would sit there dead and nothing would say so. A clean stop (Ctrl-C, or
|
|
124
|
+
// `--stop`) is the only thing that ends this loop.
|
|
125
|
+
for (;;) {
|
|
126
|
+
const code = await runStream(pathname, onStream);
|
|
127
|
+
if (code === 0) return 0;
|
|
128
|
+
eprintln(color.yellow('● the feed stopped. Trying again in a minute.'));
|
|
129
|
+
await sleep(60_000);
|
|
130
|
+
}
|
|
60
131
|
}
|
|
@@ -195,7 +195,9 @@ async function handleAsk(ask, root) {
|
|
|
195
195
|
else if (ask.op === 'publish') {
|
|
196
196
|
out = await sharing.publish(ask.job || '', root, {
|
|
197
197
|
name: ask.name, description: ask.description || '', notes: ask.notes || '',
|
|
198
|
-
asNew:
|
|
198
|
+
asNew: (ask.as_new === undefined || ask.as_new === null)
|
|
199
|
+
? null : Boolean(ask.as_new),
|
|
200
|
+
force: Boolean(ask.force),
|
|
199
201
|
exclude: ask.exclude,
|
|
200
202
|
});
|
|
201
203
|
} else if (ask.op === 'unshare') out = await sharing.unpublish(ask.job || '', root);
|
package/src/commands/share.js
CHANGED
|
@@ -49,7 +49,8 @@ const SUB_HELP = `atlas share <state|publish|off|peek|file|install|updates|fetch
|
|
|
49
49
|
|
|
50
50
|
state --job <name> is this project shared, and its link
|
|
51
51
|
publish --job <name> [--notes …] put the current files out as a new version
|
|
52
|
-
[--as-new] [--force] [--name …] [--description …]
|
|
52
|
+
[--as-new | --same] [--force] [--name …] [--description …]
|
|
53
|
+
--as-new: a different project. --same: the one you already share.
|
|
53
54
|
[--hold-back runs,notes.md] files to leave out (kept in atlas.json)
|
|
54
55
|
off --job <name> stop sharing; the link stops working
|
|
55
56
|
peek --link <id:token> download and describe, without installing
|
|
@@ -78,7 +79,8 @@ export async function shareCommand(args) {
|
|
|
78
79
|
name: flag(args, 'name'),
|
|
79
80
|
description: String(flag(args, 'description') || ''),
|
|
80
81
|
notes: String(flag(args, 'notes') || ''),
|
|
81
|
-
asNew:
|
|
82
|
+
asNew: flag(args, 'as-new', 'as_new', 'new') ? true
|
|
83
|
+
: (flag(args, 'same', 'existing') ? false : null),
|
|
82
84
|
force: Boolean(flag(args, 'force')),
|
|
83
85
|
exclude: holdBack(args),
|
|
84
86
|
});
|
package/src/config.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
|
|
5
|
-
export const VERSION = '0.
|
|
5
|
+
export const VERSION = '0.63.0';
|
|
6
6
|
export const DEFAULT_BASE_URL = 'https://atlasmcp.finmanagerai.com';
|
|
7
7
|
export const DEFAULT_SCOPE = 'atlas broker';
|
|
8
8
|
export const CLIENT_NAME = 'Atlas CLI';
|
package/src/helpers/atlas_env.js
CHANGED
|
@@ -167,10 +167,27 @@ export async function* alerts({ match = null, timeoutMs = null, reconnect = true
|
|
|
167
167
|
const deadline = timeoutMs == null ? null : Date.now() + Number(timeoutMs);
|
|
168
168
|
let attempt = 0;
|
|
169
169
|
while (deadline == null || Date.now() < deadline) {
|
|
170
|
+
let quiet = null;
|
|
171
|
+
let untilDeadline = null;
|
|
170
172
|
try {
|
|
173
|
+
// TWO DIFFERENT CLOCKS, and only one of them was here before. The
|
|
174
|
+
// deadline is how long the CALLER still wants to wait. The watchdog is
|
|
175
|
+
// how long SILENCE is allowed on a feed that is meant to be up: without
|
|
176
|
+
// it a half-open socket (a laptop that slept, a NAT that dropped the
|
|
177
|
+
// mapping) never errors and never delivers, so the read below waits for
|
|
178
|
+
// ever on a connection that is already dead, the job looks alive, and no
|
|
179
|
+
// alert arrives again. A keepalive lands every 20s, so a minute of
|
|
180
|
+
// nothing means gone and the reconnect underneath gets its turn.
|
|
181
|
+
const ac = new AbortController();
|
|
182
|
+
const heard = () => {
|
|
183
|
+
if (quiet) clearTimeout(quiet);
|
|
184
|
+
quiet = setTimeout(() => ac.abort(), 60000);
|
|
185
|
+
};
|
|
186
|
+
if (deadline != null) untilDeadline = setTimeout(() => ac.abort(), Math.max(1, deadline - Date.now()));
|
|
187
|
+
heard();
|
|
171
188
|
const res = await fetch(`${base}/api/v1/alerts?chart=0`, {
|
|
172
189
|
headers: { Authorization: `Bearer ${token}`, Accept: "text/event-stream" },
|
|
173
|
-
signal:
|
|
190
|
+
signal: ac.signal,
|
|
174
191
|
});
|
|
175
192
|
if (!res.ok || !res.body) throw new Error(`alerts feed ${res.status}`);
|
|
176
193
|
attempt = 0;
|
|
@@ -180,6 +197,7 @@ export async function* alerts({ match = null, timeoutMs = null, reconnect = true
|
|
|
180
197
|
for (;;) {
|
|
181
198
|
const { done, value } = await reader.read();
|
|
182
199
|
if (done) break;
|
|
200
|
+
heard(); // it spoke, so the silence starts over
|
|
183
201
|
buffer += decoder.decode(value, { stream: true });
|
|
184
202
|
let cut;
|
|
185
203
|
// A BLANK LINE ENDS A FRAME. Both endings, because a feed read on
|
|
@@ -210,6 +228,9 @@ export async function* alerts({ match = null, timeoutMs = null, reconnect = true
|
|
|
210
228
|
if (deadline != null) nap = Math.min(nap, Math.max(0, deadline - Date.now()));
|
|
211
229
|
if (nap <= 0) return;
|
|
212
230
|
await new Promise((r) => setTimeout(r, nap));
|
|
231
|
+
} finally {
|
|
232
|
+
if (quiet) clearTimeout(quiet);
|
|
233
|
+
if (untilDeadline) clearTimeout(untilDeadline);
|
|
213
234
|
}
|
|
214
235
|
}
|
|
215
236
|
}
|
package/src/helpers/atlas_env.py
CHANGED
|
@@ -226,8 +226,19 @@ def alerts(match=None, timeout=None, reconnect=True):
|
|
|
226
226
|
base + "/api/v1/alerts?chart=0",
|
|
227
227
|
headers={"Authorization": "Bearer " + token, "Accept": "text/event-stream"},
|
|
228
228
|
)
|
|
229
|
+
# TWO DIFFERENT CLOCKS, and only one of them was here before.
|
|
230
|
+
# `left` is how long the CALLER still wants to wait. The read
|
|
231
|
+
# timeout is how long SILENCE is allowed on a feed that is supposed
|
|
232
|
+
# to be up, and without it a half-open socket - a laptop that
|
|
233
|
+
# slept, a NAT that dropped the mapping, a load balancer that
|
|
234
|
+
# reaped an idle connection - never raises and never delivers: the
|
|
235
|
+
# loop below blocks for ever on a connection that is already dead,
|
|
236
|
+
# the job looks alive, and no alert ever arrives again. The feed
|
|
237
|
+
# sends a keepalive every 20s, so a minute of nothing means gone,
|
|
238
|
+
# and the reconnect underneath finally gets its turn.
|
|
229
239
|
left = None if deadline is None else max(1, deadline - _time.time())
|
|
230
|
-
|
|
240
|
+
quiet = 60 if left is None else min(left, 60)
|
|
241
|
+
with urllib.request.urlopen(req, timeout=quiet) as r:
|
|
231
242
|
attempt = 0
|
|
232
243
|
kind, data = "", []
|
|
233
244
|
for raw in r:
|
|
@@ -2421,7 +2432,7 @@ def _sleep_or_stop(seconds, slice_s=1.0):
|
|
|
2421
2432
|
return _still_mine()
|
|
2422
2433
|
|
|
2423
2434
|
|
|
2424
|
-
def watch(instruction, every=5, most=200, hours=8, look=True):
|
|
2435
|
+
def watch(instruction, every=5, most=200, hours=8, look=True, model=""):
|
|
2425
2436
|
"""Watch the screen and act when it is time. Runs until it is done.
|
|
2426
2437
|
|
|
2427
2438
|
THE SHAPE OF A JOB THAT IS MOSTLY WAITING (owner, 2026-09-06: "watch this
|
|
@@ -2454,6 +2465,24 @@ def watch(instruction, every=5, most=200, hours=8, look=True):
|
|
|
2454
2465
|
choosing, and why a model that says "nothing is happening, look again in an
|
|
2455
2466
|
hour" is doing its job rather than being lazy.
|
|
2456
2467
|
|
|
2468
|
+
A SMALL MODEL CAN DO THE WATCHING (owner, 2026-09-10: "a smaller model to
|
|
2469
|
+
watch continuously, then the router is able to route to larger to call tools
|
|
2470
|
+
and do other stuff"). `model=` names who is asked each round, and the round
|
|
2471
|
+
is the cheap half of this: nothing has changed, look again later. It is the
|
|
2472
|
+
ACTING that wants a big model, and the watcher hands that over the same way
|
|
2473
|
+
anything else does, by calling ask() with one:
|
|
2474
|
+
|
|
2475
|
+
watch("Watch the SPY 5-minute chart. Say QUIET and nothing else while "
|
|
2476
|
+
"it is quiet. When the opening range high breaks on rising "
|
|
2477
|
+
"volume, call: "
|
|
2478
|
+
" ask('Break just happened, here is the screen: <path>. Decide "
|
|
2479
|
+
" the trade and place it.', model='claude-opus-5')",
|
|
2480
|
+
every=2, model="claude-haiku-4-5")
|
|
2481
|
+
|
|
2482
|
+
Whoever is watching still has this whole computer, so a watcher that can
|
|
2483
|
+
decide for itself should just act. Route when the decision is worth the
|
|
2484
|
+
bigger model, not on principle. models() lists what this machine can reach.
|
|
2485
|
+
|
|
2457
2486
|
Ends when the model writes DONE on a line by itself, when `most` rounds have
|
|
2458
2487
|
passed, or when `hours` have. Pressing Stop ends it like anything else.
|
|
2459
2488
|
"""
|
|
@@ -2523,7 +2552,7 @@ def watch(instruction, every=5, most=200, hours=8, look=True):
|
|
|
2523
2552
|
else "The screen could not be photographed this round.")
|
|
2524
2553
|
)
|
|
2525
2554
|
try:
|
|
2526
|
-
answer = ask(asked)
|
|
2555
|
+
answer = ask(asked, model=model)
|
|
2527
2556
|
except Exception as err:
|
|
2528
2557
|
answer = ""
|
|
2529
2558
|
seen.append({"round": rounds, "error": str(err)})
|
package/src/sharing.js
CHANGED
|
@@ -287,6 +287,35 @@ export function describeProject(dir) {
|
|
|
287
287
|
return '';
|
|
288
288
|
}
|
|
289
289
|
|
|
290
|
+
/**
|
|
291
|
+
* Settings in `atlas.json` that describe the AUTHOR's publishing rather than
|
|
292
|
+
* the project's behaviour. They are theirs, they mean nothing in a copy, and
|
|
293
|
+
* they are the shape of remnant nobody would think to look for: an importer
|
|
294
|
+
* opening the share dialog on their own copy found the author's ticks already
|
|
295
|
+
* in it, holding back files they had never chosen to hold back.
|
|
296
|
+
*
|
|
297
|
+
* The schedule, the run line and `on_alert` are NOT here: those are what the
|
|
298
|
+
* project DOES, and a copy that did not do it would not be a copy.
|
|
299
|
+
*/
|
|
300
|
+
const AUTHOR_ONLY_SETTINGS = [EXCLUDE_KEY];
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* One file's bytes as they should arrive in somebody else's project.
|
|
304
|
+
*
|
|
305
|
+
* Only `atlas.json` is touched, and only to drop the author's own publishing
|
|
306
|
+
* settings. Anything unreadable passes through untouched: a file we cannot
|
|
307
|
+
* parse is a file we have no business editing.
|
|
308
|
+
*/
|
|
309
|
+
function forTheCopy(rel, data) {
|
|
310
|
+
if (rel !== 'atlas.json') return data;
|
|
311
|
+
let raw;
|
|
312
|
+
try { raw = JSON.parse(data.toString('utf8')); } catch { return data; }
|
|
313
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return data;
|
|
314
|
+
if (!AUTHOR_ONLY_SETTINGS.some((k) => k in raw)) return data;
|
|
315
|
+
for (const key of AUTHOR_ONLY_SETTINGS) delete raw[key];
|
|
316
|
+
return Buffer.from(JSON.stringify(raw, null, 2));
|
|
317
|
+
}
|
|
318
|
+
|
|
290
319
|
export function packProject(job, root, { name, description = '', exclude } = {}) {
|
|
291
320
|
const dir = projectDir(job, root);
|
|
292
321
|
if (!fs.existsSync(dir)) return { ok: false, error: `There is no project called ${job} here.` };
|
|
@@ -303,6 +332,10 @@ export function packProject(job, root, { name, description = '', exclude } = {})
|
|
|
303
332
|
for (const rel of files) {
|
|
304
333
|
let data;
|
|
305
334
|
try { data = fs.readFileSync(path.join(dir, rel)); } catch { continue; }
|
|
335
|
+
// Hashed AFTER, so the manifest describes what actually arrives — an update
|
|
336
|
+
// comparing against a hash of something else would report every copy as
|
|
337
|
+
// locally modified.
|
|
338
|
+
data = forTheCopy(rel, data);
|
|
306
339
|
hashes[rel] = sha(data);
|
|
307
340
|
entries.push({ name: rel, data });
|
|
308
341
|
}
|
|
@@ -321,7 +354,7 @@ export function packProject(job, root, { name, description = '', exclude } = {})
|
|
|
321
354
|
// ── publishing ─────────────────────────────────────────────────────────────
|
|
322
355
|
|
|
323
356
|
export async function publish(job, root, {
|
|
324
|
-
name, description = '', notes = '', asNew =
|
|
357
|
+
name, description = '', notes = '', asNew = null, force = false, exclude,
|
|
325
358
|
} = {}) {
|
|
326
359
|
const dir = projectDir(job, root);
|
|
327
360
|
// The choice is REMEMBERED before it is used, so the next publish holds the
|
|
@@ -332,6 +365,27 @@ export async function publish(job, root, {
|
|
|
332
365
|
if (!packed.ok) return packed;
|
|
333
366
|
|
|
334
367
|
const pin = readPin(dir) || {};
|
|
368
|
+
// WHOSE PACKAGE THIS PUBLISH IS A VERSION OF.
|
|
369
|
+
//
|
|
370
|
+
// `.atlas-source.json` answers two questions with one field: on the author's
|
|
371
|
+
// own folder `package_id` is "the package I publish", on an IMPORTED copy it
|
|
372
|
+
// is "the package I came from", which belongs to somebody else. Sending it
|
|
373
|
+
// either way asked the server to publish a new version of a stranger's
|
|
374
|
+
// package and came back "That package is not yours, or no longer exists" —
|
|
375
|
+
// a refusal about ownership, worded as if the project had vanished (owner
|
|
376
|
+
// report 2026-09-09; "this should create its own link").
|
|
377
|
+
//
|
|
378
|
+
// A copy you have changed is your own work built on theirs: it publishes as
|
|
379
|
+
// YOUR package, under your own link, with where it came from recorded rather
|
|
380
|
+
// than pretended away.
|
|
381
|
+
const mine = Boolean(pin.author) || !pin.package_id;
|
|
382
|
+
const upstream = mine ? null : {
|
|
383
|
+
package_id: pin.package_id,
|
|
384
|
+
version: pin.version,
|
|
385
|
+
share: pin.share,
|
|
386
|
+
name: pin.name,
|
|
387
|
+
author_name: pin.author_name,
|
|
388
|
+
};
|
|
335
389
|
const fields = {
|
|
336
390
|
slug: path.basename(dir),
|
|
337
391
|
name: name || pin.name || path.basename(dir),
|
|
@@ -344,9 +398,14 @@ export async function publish(job, root, {
|
|
|
344
398
|
machine_id: machineId(),
|
|
345
399
|
machine_name: computerName(),
|
|
346
400
|
};
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if (
|
|
401
|
+
// Only an author's own pin names the package being versioned. A copy has no
|
|
402
|
+
// package of its own yet, so it asks for one.
|
|
403
|
+
if (mine && pin.package_id) fields.package_id = String(pin.package_id);
|
|
404
|
+
if (mine && pin.version) fields.base_version = String(pin.version);
|
|
405
|
+
// Three-valued: null = nobody has been asked yet (the server may come back
|
|
406
|
+
// with the slug conflict), true = "a new project", false = "yes, that one —
|
|
407
|
+
// publish its next version". Sent only when there IS an answer.
|
|
408
|
+
if (asNew !== null && asNew !== undefined) fields.as_new = asNew ? 'true' : 'false';
|
|
350
409
|
if (force) fields.force = 'true';
|
|
351
410
|
|
|
352
411
|
let status; let data;
|
|
@@ -376,13 +435,24 @@ export async function publish(job, root, {
|
|
|
376
435
|
// button has a LINK to offer, not a line of hex.
|
|
377
436
|
share_url: data.share_url,
|
|
378
437
|
name: fields.name,
|
|
438
|
+
// WHAT THE PROJECT IS, KEPT (owner, 2026-09-09: "when publishing changes,
|
|
439
|
+
// it always ask what the project is for — if the user has added this just
|
|
440
|
+
// recall it"). It was sent to the server and forgotten locally, so the
|
|
441
|
+
// publish dialog opened with an empty box on every single release and the
|
|
442
|
+
// author retyped the same sentence or, more often, shipped without one.
|
|
443
|
+
description: fields.description,
|
|
379
444
|
author: true,
|
|
445
|
+
// Where it started, kept for as long as the copy exists. Provenance, not a
|
|
446
|
+
// subscription: the update check follows `package_id`, which is now this
|
|
447
|
+
// person's own package.
|
|
448
|
+
...(upstream ? { forked_from: upstream } : {}),
|
|
380
449
|
files: packed.manifest.files,
|
|
381
450
|
at: Math.floor(Date.now() / 1000),
|
|
382
451
|
});
|
|
383
452
|
return {
|
|
384
453
|
ok: true, share: data.share, share_url: data.share_url, package_id: data.package_id,
|
|
385
454
|
version: data.version, requires: packed.manifest.requires,
|
|
455
|
+
...(upstream ? { forked_from: upstream } : {}),
|
|
386
456
|
};
|
|
387
457
|
}
|
|
388
458
|
|
|
@@ -454,9 +524,16 @@ export function shareState(job, root) {
|
|
|
454
524
|
share: pin.share,
|
|
455
525
|
package_id: pin.package_id,
|
|
456
526
|
version: pin.version,
|
|
457
|
-
//
|
|
527
|
+
// Whether this folder is the one YOU publish. An imported copy starts
|
|
528
|
+
// False; publishing it makes a package of your own and flips it. Not a lock
|
|
529
|
+
// on the button — a copy you have changed is your own work, and the link it
|
|
530
|
+
// gets is your own.
|
|
458
531
|
mine: Boolean(pin.author),
|
|
459
532
|
author_name: pin.author_name,
|
|
533
|
+
forked_from: pin.forked_from,
|
|
534
|
+
// What this project IS, as last published. The publish dialog seeds its
|
|
535
|
+
// box from here, so a re-release starts with the sentence already in it.
|
|
536
|
+
description: pin.description,
|
|
460
537
|
};
|
|
461
538
|
}
|
|
462
539
|
|
|
@@ -595,6 +672,46 @@ function extract(zipPath, target, priorHashes) {
|
|
|
595
672
|
return { written, kept, backed };
|
|
596
673
|
}
|
|
597
674
|
|
|
675
|
+
/**
|
|
676
|
+
* Empty a folder into `.atlas-backup/<ts>/`, and say what moved.
|
|
677
|
+
*
|
|
678
|
+
* A COPY ARRIVES UNSEEDED (owner rule 2026-09-09: "when someone sends a copy,
|
|
679
|
+
* or when they fork out, let everything be unseeded — there is an issue where
|
|
680
|
+
* the seeding is even affecting the keys").
|
|
681
|
+
*
|
|
682
|
+
* An install used to write the package's files ON TOP of whatever was already
|
|
683
|
+
* in the folder and remove nothing, so a project taking the name of one that
|
|
684
|
+
* was there before inherited it: its leftover scripts, its `runs/`, its
|
|
685
|
+
* `trades.json`, its `atlas.json` schedule — and its `.env`, which is how a
|
|
686
|
+
* brand-new copy came up already holding somebody else's keys. Files the
|
|
687
|
+
* package happened to also contain were replaced; everything else stayed,
|
|
688
|
+
* mixed in and indistinguishable from the copy itself.
|
|
689
|
+
*
|
|
690
|
+
* So a NEW project starts on an empty folder. Nothing is deleted — the old
|
|
691
|
+
* contents move, whole, into `.atlas-backup/<ts>/`, which is where this module
|
|
692
|
+
* already puts anything it would otherwise overwrite — and the backup folder
|
|
693
|
+
* itself stays put, so a second import never buries the first.
|
|
694
|
+
*
|
|
695
|
+
* An UPDATE of the same package does NOT come through here: keeping your keys
|
|
696
|
+
* and your edits across an update is the entire point of the pin.
|
|
697
|
+
*/
|
|
698
|
+
function sweepAside(target) {
|
|
699
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|
700
|
+
const keep = path.join(target, BACKUP_DIR, stamp);
|
|
701
|
+
const moved = [];
|
|
702
|
+
let entries = [];
|
|
703
|
+
try { entries = fs.readdirSync(target).sort(); } catch { return moved; }
|
|
704
|
+
for (const name of entries) {
|
|
705
|
+
if (name === BACKUP_DIR) continue; // where things go; never a thing that goes
|
|
706
|
+
try {
|
|
707
|
+
fs.mkdirSync(keep, { recursive: true });
|
|
708
|
+
fs.renameSync(path.join(target, name), path.join(keep, name));
|
|
709
|
+
moved.push(name);
|
|
710
|
+
} catch { /* unmovable (locked, in use) — one leftover, not a failed install */ }
|
|
711
|
+
}
|
|
712
|
+
return moved;
|
|
713
|
+
}
|
|
714
|
+
|
|
598
715
|
export function install(packageId, root, { job } = {}) {
|
|
599
716
|
const stage = stagingDir(root, packageId);
|
|
600
717
|
const zipPath = path.join(stage, 'package.zip');
|
|
@@ -609,10 +726,18 @@ export function install(packageId, root, { job } = {}) {
|
|
|
609
726
|
|
|
610
727
|
const slug = safeSlug(job || manifest.slug || 'project');
|
|
611
728
|
const target = projectDir(slug, root);
|
|
612
|
-
const
|
|
729
|
+
const pinBefore = readPin(target) || {};
|
|
730
|
+
let prior = pinBefore.files || {};
|
|
731
|
+
// AN UPDATE, or A NEW PROJECT LANDING HERE? The pin tells them apart: same
|
|
732
|
+
// package = the author published again and this copy moves forward (keys,
|
|
733
|
+
// edits and all). Anything else — no pin, or a pin for a different package —
|
|
734
|
+
// is a new project taking this folder, and it starts clean. See sweepAside.
|
|
735
|
+
const isUpdate = String(pinBefore.package_id || '') === String(packageId);
|
|
613
736
|
try { fs.mkdirSync(target, { recursive: true }); } catch (err) {
|
|
614
737
|
return { ok: false, error: `Could not make the folder: ${err.message}` };
|
|
615
738
|
}
|
|
739
|
+
const swept = isUpdate ? [] : sweepAside(target);
|
|
740
|
+
if (swept.length) prior = {}; // nothing here came from this package
|
|
616
741
|
|
|
617
742
|
let result;
|
|
618
743
|
try { result = extract(zipPath, target, prior); } catch (err) {
|
|
@@ -637,6 +762,10 @@ export function install(packageId, root, { job } = {}) {
|
|
|
637
762
|
written: result.written,
|
|
638
763
|
kept: result.kept,
|
|
639
764
|
backed_up: result.backed,
|
|
765
|
+
// What was in the folder before and is now in `.atlas-backup/`. Named so a
|
|
766
|
+
// screen can say it: somebody who had something here deserves to be told
|
|
767
|
+
// where it went, and somebody who did not sees an empty list.
|
|
768
|
+
moved_aside: swept,
|
|
640
769
|
requires,
|
|
641
770
|
missing_keys: missingKeys(target, requires, root),
|
|
642
771
|
};
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
// One named background process per computer: claim it, detach it, stop it.
|
|
2
|
+
//
|
|
3
|
+
// `atlas alerts` holds a live connection to the alert feed. Two things follow
|
|
4
|
+
// from that, and this is where both are answered:
|
|
5
|
+
//
|
|
6
|
+
// ONE AT A TIME. The stream is a thing you can act on, so a second one on the
|
|
7
|
+
// same computer is a second copy of every event: doubled lines in the log a
|
|
8
|
+
// reader is tailing, and doubled work in whatever reads it. The slot is
|
|
9
|
+
// CHECKED, never assumed - a claim whose process is gone, or that has not said
|
|
10
|
+
// it is here inside STALE_MS, is holding nothing, so a crash costs the next
|
|
11
|
+
// start no wait.
|
|
12
|
+
//
|
|
13
|
+
// IT SHOULD OUTLIVE THE TERMINAL. A stream that dies when you close the
|
|
14
|
+
// window, or when you run another atlas command, is the wrong lifetime for
|
|
15
|
+
// something whose whole job is to be there when an alert fires. `--detach`
|
|
16
|
+
// gives it its own session; `--stop` ends it.
|
|
17
|
+
//
|
|
18
|
+
// State lives beside the CLI's own config, not in a workspace, because a stream
|
|
19
|
+
// has nothing to do with any project folder.
|
|
20
|
+
//
|
|
21
|
+
// Mirror of python/atlas_cli/background.py.
|
|
22
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
23
|
+
import fs from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { configDir } from '../config.js';
|
|
26
|
+
import { color, println, eprintln } from './ui.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* This process, named. Regenerated every start, so a claim written by an
|
|
30
|
+
* EARLIER one is recognisable as belonging to a process that is gone.
|
|
31
|
+
*/
|
|
32
|
+
export const PROCESS_ID = `${Date.now()}-${process.pid}`;
|
|
33
|
+
|
|
34
|
+
/** How often a background process says it is still here, and how long that is
|
|
35
|
+
* worth believing. Three missed beats means it is not coming back. */
|
|
36
|
+
export const BEAT_MS = 20_000;
|
|
37
|
+
export const STALE_MS = 90_000;
|
|
38
|
+
|
|
39
|
+
/** How large the log may get, and how often that is checked. A stream meant to
|
|
40
|
+
* run for months writes for months, and an uncapped log is a disk that fills
|
|
41
|
+
* up silently at 3am. */
|
|
42
|
+
export const LOG_MAX_BYTES = Number(process.env.ATLAS_LOG_MAX_BYTES) || 16 * 1024 * 1024;
|
|
43
|
+
export const LOG_CHECK_MS = 60_000;
|
|
44
|
+
|
|
45
|
+
/** The same, for saved charts. One PNG per fire across every alert adds up. */
|
|
46
|
+
export const CHARTS_MAX_BYTES = Number(process.env.ATLAS_CHARTS_MAX_BYTES) || 256 * 1024 * 1024;
|
|
47
|
+
|
|
48
|
+
/** Set on the child `detach` starts, so it can tell it has no terminal: that is
|
|
49
|
+
* what makes it keep its own log in check and save charts somewhere findable
|
|
50
|
+
* rather than assuming a person is watching. */
|
|
51
|
+
export const BACKGROUND_ENV = 'ATLAS_BACKGROUND';
|
|
52
|
+
|
|
53
|
+
/** True inside the process `detach` started. */
|
|
54
|
+
export function isBackground(name) {
|
|
55
|
+
const got = (process.env[BACKGROUND_ENV] || '').trim();
|
|
56
|
+
return !!got && (name === undefined || got === name);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const chartsDir = (name) => path.join(configDir(), `${name}-charts`);
|
|
60
|
+
|
|
61
|
+
/** How many full logs are kept behind the live one. Total on disk is therefore
|
|
62
|
+
* bounded by LOG_MAX_BYTES * (LOG_KEEP + 1). */
|
|
63
|
+
export const LOG_KEEP = 5;
|
|
64
|
+
|
|
65
|
+
export const oldLog = (name, i) => path.join(configDir(), `${name}.${i}.log`);
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Full? Start a NEW file, and shift the old ones along.
|
|
69
|
+
*
|
|
70
|
+
* `alerts.log` is always the live one, `alerts.1.log` the last full one, and so
|
|
71
|
+
* on to LOG_KEEP, past which the oldest is dropped. So the name a reader tails
|
|
72
|
+
* never changes, and history stays in whole files instead of a window that
|
|
73
|
+
* keeps eating its own beginning.
|
|
74
|
+
*
|
|
75
|
+
* THE WRITER IS THIS PROCESS. Renaming alone would not be enough: our stdout is
|
|
76
|
+
* an append-mode fd on the file we just moved, so we would go on writing into
|
|
77
|
+
* `alerts.1.log` and the new `alerts.log` would stay empty for ever. Node has
|
|
78
|
+
* no dup2, but close(1) then open() hands back the lowest free descriptor,
|
|
79
|
+
* which IS 1 - that is what moves the writing.
|
|
80
|
+
*/
|
|
81
|
+
export function rollLog(name, cap = LOG_MAX_BYTES) {
|
|
82
|
+
const log = logFile(name);
|
|
83
|
+
try {
|
|
84
|
+
if (fs.statSync(log).size < cap) return false;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const oldest = oldLog(name, LOG_KEEP);
|
|
90
|
+
if (fs.existsSync(oldest)) fs.unlinkSync(oldest);
|
|
91
|
+
for (let i = LOG_KEEP - 1; i >= 1; i -= 1) {
|
|
92
|
+
const src = oldLog(name, i);
|
|
93
|
+
if (fs.existsSync(src)) fs.renameSync(src, oldLog(name, i + 1));
|
|
94
|
+
}
|
|
95
|
+
fs.renameSync(log, oldLog(name, 1));
|
|
96
|
+
} catch {
|
|
97
|
+
// Windows will not rename a file this process holds open. Falling back
|
|
98
|
+
// keeps the log bounded there too, just as a window rather than files.
|
|
99
|
+
return trimLog(log, cap);
|
|
100
|
+
}
|
|
101
|
+
// ONLY THE WRITER MOVES ITS OWN OUTPUT. These are fd 1 and 2 of THIS process,
|
|
102
|
+
// so anything else calling rollLog (a test, a tidy-up) would otherwise find
|
|
103
|
+
// its own stdout pointing into the log it just rolled.
|
|
104
|
+
if (isBackground(name)) {
|
|
105
|
+
for (const want of [1, 2]) {
|
|
106
|
+
try {
|
|
107
|
+
fs.closeSync(want);
|
|
108
|
+
const got = fs.openSync(log, 'a');
|
|
109
|
+
if (got !== want) { // somebody took it first: do not write blind
|
|
110
|
+
fs.closeSync(got);
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
} catch { /* nothing more we can do about this descriptor */ }
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Keep the END of the log and drop the beginning, IN PLACE.
|
|
121
|
+
*
|
|
122
|
+
* The fallback for when rolling is not possible (Windows holds the open file
|
|
123
|
+
* against a rename). The last line before the cut is dropped rather than left
|
|
124
|
+
* half-written, so a reader parsing one JSON object per line never meets a
|
|
125
|
+
* broken one.
|
|
126
|
+
*/
|
|
127
|
+
export function trimLog(file, cap = LOG_MAX_BYTES) {
|
|
128
|
+
let size;
|
|
129
|
+
try {
|
|
130
|
+
size = fs.statSync(file).size;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
if (size <= cap) return false;
|
|
135
|
+
const keep = Math.floor(cap / 2);
|
|
136
|
+
try {
|
|
137
|
+
const fd = fs.openSync(file, 'r');
|
|
138
|
+
const buf = Buffer.alloc(keep);
|
|
139
|
+
fs.readSync(fd, buf, 0, keep, size - keep);
|
|
140
|
+
fs.closeSync(fd);
|
|
141
|
+
const cut = buf.indexOf(0x0a);
|
|
142
|
+
const tail = cut === -1 ? buf : buf.subarray(cut + 1);
|
|
143
|
+
fs.writeFileSync(file, Buffer.concat([
|
|
144
|
+
Buffer.from('... earlier lines dropped: the log passed its size cap ...\n'), tail,
|
|
145
|
+
]));
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Keep a directory under a size cap, oldest file first. */
|
|
153
|
+
export function pruneDir(dir, cap = CHARTS_MAX_BYTES) {
|
|
154
|
+
let files;
|
|
155
|
+
try {
|
|
156
|
+
files = fs.readdirSync(dir).map((n) => {
|
|
157
|
+
const f = path.join(dir, n);
|
|
158
|
+
const st = fs.statSync(f);
|
|
159
|
+
return st.isFile() ? { f, mtime: st.mtimeMs, size: st.size } : null;
|
|
160
|
+
}).filter(Boolean);
|
|
161
|
+
} catch {
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
let total = files.reduce((a, x) => a + x.size, 0);
|
|
165
|
+
if (total <= cap) return 0;
|
|
166
|
+
let dropped = 0;
|
|
167
|
+
for (const x of files.sort((a, b) => a.mtime - b.mtime)) {
|
|
168
|
+
if (total <= cap) break;
|
|
169
|
+
try {
|
|
170
|
+
fs.unlinkSync(x.f);
|
|
171
|
+
total -= x.size;
|
|
172
|
+
dropped += 1;
|
|
173
|
+
} catch { /* someone else got there first */ }
|
|
174
|
+
}
|
|
175
|
+
return dropped;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Keep this background process's own log under the cap, for as long as it runs. */
|
|
179
|
+
export function startLogCap(name) {
|
|
180
|
+
const charts = chartsDir(name);
|
|
181
|
+
const t = setInterval(() => {
|
|
182
|
+
try {
|
|
183
|
+
rollLog(name);
|
|
184
|
+
if (fs.existsSync(charts)) pruneDir(charts);
|
|
185
|
+
} catch { /* tidying is never worth dying over */ }
|
|
186
|
+
}, LOG_CHECK_MS);
|
|
187
|
+
t.unref?.();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export const claimFile = (name) => path.join(configDir(), `${name}.json`);
|
|
191
|
+
export const logFile = (name) => path.join(configDir(), `${name}.log`);
|
|
192
|
+
|
|
193
|
+
/** {id, pid, beat} of whoever holds this slot, or null. */
|
|
194
|
+
export function readClaim(name) {
|
|
195
|
+
let raw;
|
|
196
|
+
try {
|
|
197
|
+
raw = JSON.parse(fs.readFileSync(claimFile(name), 'utf8'));
|
|
198
|
+
} catch {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (!raw || typeof raw !== 'object' || !raw.id) return null;
|
|
202
|
+
return { id: String(raw.id), pid: Number(raw.pid) || 0, beat: Number(raw.beat) || 0 };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function pidAlive(pid) {
|
|
206
|
+
if (!pid || pid <= 0) return false;
|
|
207
|
+
try {
|
|
208
|
+
process.kill(pid, 0);
|
|
209
|
+
return true;
|
|
210
|
+
} catch (err) {
|
|
211
|
+
return err.code === 'EPERM'; // someone else's, but it exists
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The OTHER process holding this slot right now, or null.
|
|
217
|
+
*
|
|
218
|
+
* HELD IS A CHECKED FACT. A file on disk only says somebody once started; it
|
|
219
|
+
* takes a live pid and a recent beat to say somebody is still going.
|
|
220
|
+
*/
|
|
221
|
+
export function heldByOther(name, nowMs = Date.now()) {
|
|
222
|
+
const info = readClaim(name);
|
|
223
|
+
if (!info || info.id === PROCESS_ID) return null;
|
|
224
|
+
if (nowMs - info.beat * 1000 > STALE_MS) return null;
|
|
225
|
+
if (info.pid && !pidAlive(info.pid)) return null;
|
|
226
|
+
return info;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Say this process is still here. False once the slot is somebody else's. */
|
|
230
|
+
export function beat(name) {
|
|
231
|
+
if (heldByOther(name)) return false;
|
|
232
|
+
try {
|
|
233
|
+
const f = claimFile(name);
|
|
234
|
+
fs.mkdirSync(path.dirname(f), { recursive: true });
|
|
235
|
+
fs.writeFileSync(f, JSON.stringify({ id: PROCESS_ID, pid: process.pid, beat: Date.now() / 1000 }));
|
|
236
|
+
} catch { /* a beat that cannot be written is not fatal */ }
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Take the slot, or return the holder that already has it. */
|
|
241
|
+
export function claim(name, nowMs = Date.now()) {
|
|
242
|
+
const holder = heldByOther(name, nowMs);
|
|
243
|
+
if (holder) return holder;
|
|
244
|
+
beat(name);
|
|
245
|
+
// Two starts in the same instant can both find it free. The file ends up with
|
|
246
|
+
// one name in it, so read it back: whoever is not in it stands down.
|
|
247
|
+
const again = readClaim(name);
|
|
248
|
+
if (again && again.id !== PROCESS_ID) return again;
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Give the slot up on the way out. Only ever ours to drop. */
|
|
253
|
+
export function release(name) {
|
|
254
|
+
try {
|
|
255
|
+
const info = readClaim(name);
|
|
256
|
+
if (info && info.id !== PROCESS_ID) return;
|
|
257
|
+
fs.unlinkSync(claimFile(name));
|
|
258
|
+
} catch { /* nothing there to give up */ }
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Claim the slot for this process and arrange to give it back.
|
|
263
|
+
*
|
|
264
|
+
* Returns the holder when the slot is taken (the caller should stop), or null
|
|
265
|
+
* once it is ours.
|
|
266
|
+
*/
|
|
267
|
+
export function holdUntilStopped(name) {
|
|
268
|
+
const holder = claim(name);
|
|
269
|
+
if (holder) return holder;
|
|
270
|
+
const go = () => { release(name); process.exit(0); };
|
|
271
|
+
process.once('SIGTERM', go); // `--stop` asks with this
|
|
272
|
+
process.once('exit', () => release(name));
|
|
273
|
+
const t = setInterval(() => { try { beat(name); } catch { /* not fatal */ } }, BEAT_MS);
|
|
274
|
+
t.unref?.(); // a heartbeat must not hold the process up
|
|
275
|
+
if (isBackground(name)) startLogCap(name);
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Run this same command again, off the terminal, and hand the prompt back.
|
|
283
|
+
*
|
|
284
|
+
* Same command, same flags, its own session, so nothing about how it behaves
|
|
285
|
+
* depends on having been detached.
|
|
286
|
+
*
|
|
287
|
+
* A pid is not a running stream. This waits for the child to CLAIM the slot
|
|
288
|
+
* before it says anything started, because "we spawned something" and "it is
|
|
289
|
+
* up" are different facts and only the second is worth printing.
|
|
290
|
+
*/
|
|
291
|
+
export async function detach(name, flag = '--detach') {
|
|
292
|
+
const holder = heldByOther(name);
|
|
293
|
+
if (holder) {
|
|
294
|
+
alreadyRunning(name, holder);
|
|
295
|
+
return 1;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const log = logFile(name);
|
|
299
|
+
let child;
|
|
300
|
+
let fd;
|
|
301
|
+
try {
|
|
302
|
+
fs.mkdirSync(path.dirname(log), { recursive: true });
|
|
303
|
+
fd = fs.openSync(log, 'a');
|
|
304
|
+
const argv = process.argv.slice(2).filter((a) => a !== flag && a !== `${flag}=true`);
|
|
305
|
+
child = spawn(process.execPath, [process.argv[1], ...argv], {
|
|
306
|
+
detached: true, // its own session: closing the window
|
|
307
|
+
stdio: ['ignore', fd, fd], // cannot hang it up
|
|
308
|
+
windowsHide: true,
|
|
309
|
+
env: { ...process.env, [BACKGROUND_ENV]: name },
|
|
310
|
+
});
|
|
311
|
+
child.unref();
|
|
312
|
+
} catch (err) {
|
|
313
|
+
eprintln(color.red(`Could not start it in the background: ${err.message}`));
|
|
314
|
+
return 1;
|
|
315
|
+
} finally {
|
|
316
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* the child holds it now */ } }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let exited = false;
|
|
320
|
+
child.once('exit', () => { exited = true; });
|
|
321
|
+
const end = Date.now() + 15_000;
|
|
322
|
+
while (Date.now() < end) {
|
|
323
|
+
const info = readClaim(name);
|
|
324
|
+
if (info && info.pid === child.pid) {
|
|
325
|
+
println(color.green('Streaming in the background.') + color.dim(` pid ${child.pid}`));
|
|
326
|
+
println(color.dim(` Log: ${log}`));
|
|
327
|
+
println(color.dim(` Read: tail -f ${log}`));
|
|
328
|
+
println(color.dim(` Stop: atlas ${name} --stop`));
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
if (exited) break; // it stopped instead of taking the slot
|
|
332
|
+
await sleep(200);
|
|
333
|
+
}
|
|
334
|
+
eprintln(color.red('It started but never took the slot.'));
|
|
335
|
+
eprintln(color.dim(` What it wrote: ${log}`));
|
|
336
|
+
return 1;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Stop the background process holding this slot, detached or not.
|
|
341
|
+
*
|
|
342
|
+
* STOPPED IS A FACT WE CHECK, not one we assume: asking is not the same as it
|
|
343
|
+
* having happened. This waits for the slot to come free and says which of the
|
|
344
|
+
* two actually did.
|
|
345
|
+
*/
|
|
346
|
+
export async function stop(name) {
|
|
347
|
+
const holder = heldByOther(name);
|
|
348
|
+
if (!holder) {
|
|
349
|
+
println(color.dim('Nothing is streaming here.'));
|
|
350
|
+
return 0;
|
|
351
|
+
}
|
|
352
|
+
if (!holder.pid) {
|
|
353
|
+
eprintln(color.red('Something holds the slot but wrote no pid, so there is nothing '
|
|
354
|
+
+ `to stop. It frees itself within ${STALE_MS / 1000}s.`));
|
|
355
|
+
return 1;
|
|
356
|
+
}
|
|
357
|
+
try {
|
|
358
|
+
if (process.platform === 'win32') {
|
|
359
|
+
spawnSync('taskkill', ['/PID', String(holder.pid), '/T', '/F'], { stdio: 'ignore' });
|
|
360
|
+
} else {
|
|
361
|
+
process.kill(holder.pid, 'SIGTERM');
|
|
362
|
+
}
|
|
363
|
+
} catch (err) {
|
|
364
|
+
eprintln(color.red(`Could not stop pid ${holder.pid}: ${err.message}`));
|
|
365
|
+
return 1;
|
|
366
|
+
}
|
|
367
|
+
const end = Date.now() + 15_000;
|
|
368
|
+
while (Date.now() < end) {
|
|
369
|
+
if (heldByOther(name) === null) {
|
|
370
|
+
println(color.green('Stopped.') + color.dim(` (pid ${holder.pid})`));
|
|
371
|
+
return 0;
|
|
372
|
+
}
|
|
373
|
+
await sleep(200);
|
|
374
|
+
}
|
|
375
|
+
eprintln(color.yellow(`Asked pid ${holder.pid} to stop, and it is still going after 15s.`));
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Say who has the slot, for a caller that has just been refused. */
|
|
380
|
+
export function alreadyRunning(name, holder) {
|
|
381
|
+
eprintln(color.red('Already streaming on this computer.') + color.dim(` pid ${holder.pid}`));
|
|
382
|
+
eprintln(color.dim(' One stream at a time, so a reader never sees an event twice.'));
|
|
383
|
+
eprintln(color.dim(` Stop it with: atlas ${name} --stop`));
|
|
384
|
+
eprintln(color.dim(` Or watch what it writes: tail -f ${logFile(name)}`));
|
|
385
|
+
}
|
package/src/util/help.js
CHANGED
|
@@ -30,6 +30,7 @@ export function printRootHelp() {
|
|
|
30
30
|
println(c.bold('STREAMS'));
|
|
31
31
|
println(' alerts [--symbol SPY] [--name greek_exposure] Live alert feed (SSE)');
|
|
32
32
|
println(' [--save-charts <dir>] [--no-chart] [--json]');
|
|
33
|
+
println(' [--detach] [--stop] Stream in the background, or stop it');
|
|
33
34
|
println(' flow stream --symbol NVDA [--side call] Live options-flow feed (SSE)');
|
|
34
35
|
println(' remote-control [--dir DIR] Let Atlas run this computer\'s jobs');
|
|
35
36
|
println(' update-cli Install the newest Atlas CLI');
|