mindvest-atlas 0.96.1 → 0.98.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mindvest-atlas",
3
- "version": "0.96.1",
3
+ "version": "0.98.0",
4
4
  "description": "Atlas CLI \u2014 OAuth login, tool calls, and live alert/flow streaming for the Atlas trading API",
5
5
  "type": "module",
6
6
  "bin": {
package/src/access.js ADDED
@@ -0,0 +1,113 @@
1
+ // May this account use Atlas on this computer right now?
2
+ //
3
+ // OWNER, 2026-10-14: "every user must have at least pro plan. first time user
4
+ // can use studio free for 1 week ... it does not matter if the user never used
5
+ // atlas tool at all. we still have web control relay network etc." and, of the
6
+ // command line: "not just command line, if they don't pay, they do not see",
7
+ // "no runs ever runs and no new orders".
8
+ //
9
+ // The server decides (POST /api/computer/access): a paid plan, or the one free
10
+ // week, which it starts the first time an account uses a computer and keeps
11
+ // against the account (in its own settings), never here. This file only asks, keeps
12
+ // the last answer, and lets a computer that cannot reach Atlas carry on until
13
+ // the `good_until` the server gave it (two days after a paid plan was last
14
+ // seen, the end of the free week otherwise).
15
+ //
16
+ // What it gates is decided by its callers: STARTING a run. Nothing here stops
17
+ // a run already going, and nothing closes, cancels or watches less.
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { configDir } from './config.js';
21
+ import { requestJson, AuthError } from './api/client.js';
22
+
23
+ export const ACCESS_PATH = '/api/computer/access';
24
+ export const SUBSCRIBE_URL = 'https://mind-vest.io/atlas/subscribe';
25
+ const FILE = 'access.json';
26
+ // Asked at most this often while the answer is yes; a no is asked again sooner,
27
+ // so somebody who has just paid is not kept waiting.
28
+ export const FRESH_MS = 10 * 60_000;
29
+ export const FRESH_NO_MS = 60_000;
30
+
31
+ export const LOCKED_LINE =
32
+ `Your free week has ended. Pick a plan in your account to run things on this computer again: ${SUBSCRIBE_URL}`;
33
+
34
+ function file() {
35
+ return path.join(configDir(), FILE);
36
+ }
37
+
38
+ export function readAccess() {
39
+ try {
40
+ const raw = JSON.parse(fs.readFileSync(file(), 'utf8'));
41
+ return raw && typeof raw === 'object' ? raw : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function writeAccess(rec) {
48
+ try {
49
+ fs.mkdirSync(configDir(), { recursive: true });
50
+ fs.writeFileSync(file(), JSON.stringify(rec, null, 2));
51
+ } catch { /* the answer still holds for this process */ }
52
+ }
53
+
54
+ /** The server's answer, reduced to the fields this file trusts. */
55
+ export function fromServer(data, now = Date.now()) {
56
+ const d = data && typeof data === 'object' ? data : {};
57
+ const state = ['paid', 'trial', 'ended'].includes(d.state) ? d.state : 'unknown';
58
+ const good = Date.parse(d.good_until || '');
59
+ return {
60
+ allowed: d.allowed === true,
61
+ state,
62
+ trial_ends_at: typeof d.trial_ends_at === 'string' ? d.trial_ends_at : null,
63
+ days_left: Number.isFinite(d.days_left) ? d.days_left : null,
64
+ good_until: Number.isFinite(good) ? new Date(good).toISOString() : null,
65
+ checked_at: new Date(now).toISOString(),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Pure: what a kept answer means at `now`. A yes holds until its good_until;
71
+ * past that it is a no until the server is asked again. Nothing kept is a no,
72
+ * with its own sentence: the first answer needs the internet.
73
+ */
74
+ export function verdict(rec, now = Date.now()) {
75
+ if (!rec) {
76
+ return { allowed: false, state: 'unknown',
77
+ message: 'Connect this computer to the internet once so Atlas can check your plan.' };
78
+ }
79
+ const until = Date.parse(rec.good_until || '');
80
+ const allowed = rec.allowed === true && Number.isFinite(until) && now < until;
81
+ return { ...rec, allowed, message: allowed ? null : LOCKED_LINE };
82
+ }
83
+
84
+ function fresh(rec, now) {
85
+ const at = Date.parse(rec?.checked_at || '');
86
+ if (!Number.isFinite(at)) return false;
87
+ return now - at < (rec.allowed ? FRESH_MS : FRESH_NO_MS);
88
+ }
89
+
90
+ /**
91
+ * Ask (or reuse a recent answer). `ask` is for tests.
92
+ *
93
+ * A server that does not know the question (404: this CLI is newer than the
94
+ * server) is a yes: a release must never lock everybody out before the server
95
+ * that decides has gone live. Any other failure falls back to the kept answer.
96
+ */
97
+ export async function computerAccess({ now = Date.now(), ask = requestJson, force = false } = {}) {
98
+ const kept = readAccess();
99
+ if (!force && kept && fresh(kept, now)) return verdict(kept, now);
100
+ try {
101
+ // Nothing about this computer is sent: only who is asking (the sign-in).
102
+ const data = await ask(ACCESS_PATH, { method: 'POST', body: {} });
103
+ const rec = fromServer(data, now);
104
+ writeAccess(rec);
105
+ return verdict(rec, now);
106
+ } catch (err) {
107
+ if (err && err.status === 404) return { allowed: true, state: 'unknown', message: null };
108
+ if (err instanceof AuthError && !verdict(kept, now).allowed) {
109
+ return { allowed: false, state: 'unknown', message: 'Sign in first: atlas login' };
110
+ }
111
+ return verdict(kept, now);
112
+ }
113
+ }
package/src/cli.js CHANGED
@@ -18,6 +18,8 @@ import { remoteControlCommand } from './commands/remote-control.js';
18
18
  import { updateCliCommand } from './commands/update-cli.js';
19
19
  import { screenshotCommand } from './commands/screenshot.js';
20
20
  import { runCommand } from './commands/run.js';
21
+ import { computerAccess } from './access.js';
22
+ import { installCommand } from './commands/install.js';
21
23
  import { computerUseCommand } from './commands/computer-use.js';
22
24
  import { dashboardCommand } from './commands/dashboard.js';
23
25
  import { chatSearchCommand } from './commands/chat-search.js';
@@ -119,8 +121,20 @@ export async function main(rawArgs) {
119
121
  // RUN SOMETHING INSIDE A PROJECT, which had no entry point at all: an
120
122
  // agent that had correctly worked out atlas_env only exists in project
121
123
  // code then had nowhere to try it (owner, 2026-09-05).
122
- case 'run':
124
+ // LIBRARIES FOR THIS COMPUTER'S PROJECTS (owner, 2026-10-14), one shared
125
+ // environment under the Atlas folder; Studio's Libraries tab uses it too.
126
+ case 'install':
127
+ return await installCommand(withoutCommand(args));
128
+ case 'run': {
129
+ // A run on this computer needs a plan (or the free week): asked
130
+ // before anything starts, never in the middle of one.
131
+ const access = await computerAccess();
132
+ if (!access.allowed) {
133
+ eprintln(color.yellow(access.message));
134
+ return 1;
135
+ }
123
136
  return runCommand(withoutCommand(args));
137
+ }
124
138
  // SEEING AND USING THIS COMPUTER, from a shell. It existed only in the
125
139
  // Python package, and Atlas Studio bundles THIS one, so the app
126
140
  // answered "Unknown command or tool: computer-use" on the very machine
@@ -0,0 +1,147 @@
1
+ // `atlas install`: Python libraries for this computer's projects, installed on
2
+ // request (owner, 2026-10-14: "let there be local atlas cli (install - this)
3
+ // which downloads, but users can also install this too").
4
+ //
5
+ // One shared environment under the Atlas folder (`.libraries`), which every
6
+ // project without its own venv runs with (remote-control.js venvPython). It is
7
+ // made the first time something is installed. `uv` is used when it is on this
8
+ // computer (much faster), pip otherwise. Atlas Studio's Libraries tab drives
9
+ // this same command with --json, so there is one way to install.
10
+ //
11
+ // atlas install <name>[==version] ... install (or bring up to date)
12
+ // atlas install --update <name> ... the newest version
13
+ // atlas install --remove <name> ... take it out
14
+ // atlas install --list [--latest] what is installed (and what is newer)
15
+ // --json one JSON line, for a program
16
+ import { spawnSync } from 'node:child_process';
17
+ import fs from 'node:fs';
18
+ import { color, println, eprintln } from '../util/ui.js';
19
+ import { ambientPython, envPython, librariesVenv, whichSync } from '../remote-control.js';
20
+
21
+ /** A package as pip spells it, with nothing that could be read as an option. */
22
+ export const PACKAGE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9,._-]+\])?((==|>=|<=|~=)[A-Za-z0-9.*+!_-]+)?$/;
23
+
24
+ export function packageName(spec) {
25
+ return String(spec).replace(/\[.*$/, '').replace(/(==|>=|<=|~=).*$/, '').toLowerCase().replace(/_/g, '-');
26
+ }
27
+
28
+ const MINUTES = 60_000;
29
+
30
+ function run(cmd, args, timeoutMs) {
31
+ const out = spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutMs, windowsHide: true, maxBuffer: 32 * 1024 * 1024 });
32
+ return { code: out.status ?? 1, stdout: out.stdout || '', stderr: out.stderr || '', error: out.error };
33
+ }
34
+
35
+ /** The shared environment, made if it is not there yet. */
36
+ export function ensureEnv(root) {
37
+ const dir = librariesVenv(root);
38
+ const py = envPython(dir);
39
+ if (fs.existsSync(py)) return { ok: true, python: py, dir, made: false };
40
+ const uv = whichSync('uv');
41
+ const made = uv
42
+ ? run(uv, ['venv', dir], 5 * MINUTES)
43
+ : run(ambientPython()[0], [...ambientPython().slice(1), '-m', 'venv', dir], 5 * MINUTES);
44
+ if (made.code !== 0 || !fs.existsSync(py)) {
45
+ return { ok: false, error: `Could not make the libraries folder: ${(made.stderr || made.error?.message || '').trim().split('\n').pop() || 'Python is not on this computer.'}` };
46
+ }
47
+ return { ok: true, python: py, dir, made: true };
48
+ }
49
+
50
+ function pipArgs(py, verb, extra) {
51
+ const uv = whichSync('uv');
52
+ if (uv) return [uv, ['pip', verb, '--python', py, ...extra]];
53
+ if (verb === 'uninstall') extra = ['-y', ...extra];
54
+ return [py, ['-m', 'pip', verb, '--disable-pip-version-check', ...extra]];
55
+ }
56
+
57
+ /** What is installed: [{name, version}]. */
58
+ export function installed(root) {
59
+ const py = envPython(librariesVenv(root));
60
+ if (!fs.existsSync(py)) return [];
61
+ const [cmd, args] = pipArgs(py, 'list', ['--format', 'json']);
62
+ const out = run(cmd, args, 2 * MINUTES);
63
+ try {
64
+ const rows = JSON.parse(out.stdout || '[]');
65
+ return rows.map((r) => ({ name: String(r.name), version: String(r.version) }));
66
+ } catch {
67
+ return [];
68
+ }
69
+ }
70
+
71
+ /** The newest version and one line about a package, from PyPI (null when unknown). */
72
+ export async function latest(name, fetchImpl = fetch) {
73
+ try {
74
+ const r = await fetchImpl(`https://pypi.org/pypi/${encodeURIComponent(name)}/json`, { signal: AbortSignal.timeout(15_000) });
75
+ if (!r.ok) return null;
76
+ const d = await r.json();
77
+ return { version: String(d?.info?.version || ''), summary: String(d?.info?.summary || '').slice(0, 200) };
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ export async function installCommand(args) {
84
+ // A switch followed by a name takes the name as its value (`--remove pandas`):
85
+ // the switch is on, and the name is one of the names.
86
+ const flags = { ...(args.flags || {}) };
87
+ const names = (args.positional || args._ || []).map(String).filter(Boolean);
88
+ for (const k of ['remove', 'update', 'list', 'latest', 'json']) {
89
+ const v = flags[k];
90
+ for (const one of Array.isArray(v) ? v : [v]) if (typeof one === 'string' && one) names.push(one);
91
+ if (v !== undefined && v !== false) flags[k] = true;
92
+ }
93
+ const json = !!flags.json;
94
+ const say = (obj, human) => {
95
+ if (json) println(JSON.stringify(obj));
96
+ else human();
97
+ return obj.ok ? 0 : 1;
98
+ };
99
+
100
+ if (flags.list) {
101
+ const list = installed();
102
+ if (flags.latest) {
103
+ for (const p of list) {
104
+ const l = await latest(p.name);
105
+ if (l?.version) p.latest = l.version;
106
+ }
107
+ }
108
+ return say({ ok: true, installed: list }, () => {
109
+ if (!list.length) return println(color.dim('Nothing installed yet. `atlas install <name>`'));
110
+ for (const p of list) {
111
+ const newer = p.latest && p.latest !== p.version ? color.yellow(` ${p.latest} is out`) : '';
112
+ println(`${p.name} ${color.dim(p.version)}${newer}`);
113
+ }
114
+ });
115
+ }
116
+
117
+ if (!names.length) {
118
+ eprintln(color.red('Which library? `atlas install pandas`'));
119
+ eprintln(color.dim(' --update <name> the newest version'));
120
+ eprintln(color.dim(' --remove <name> take it out'));
121
+ eprintln(color.dim(' --list what is installed'));
122
+ return 1;
123
+ }
124
+ const bad = names.find((n) => !PACKAGE_RE.test(n));
125
+ if (bad) return say({ ok: false, error: `"${bad}" is not a library name.` }, () => eprintln(color.red(`"${bad}" is not a library name.`)));
126
+
127
+ const env = ensureEnv();
128
+ if (!env.ok) return say(env, () => eprintln(color.red(env.error)));
129
+
130
+ const verb = flags.remove ? 'uninstall' : 'install';
131
+ const extra = flags.remove ? names : [...(flags.update ? ['--upgrade'] : []), ...names];
132
+ if (!json) eprintln(color.dim(`● ${flags.remove ? 'Removing' : 'Installing'} ${names.join(', ')}…`));
133
+ const [cmd, cmdArgs] = pipArgs(env.python, verb, extra);
134
+ const out = run(cmd, cmdArgs, 20 * MINUTES);
135
+ if (out.code !== 0) {
136
+ const why = (out.stderr || out.stdout || out.error?.message || '').trim().split('\n').filter(Boolean).pop() || 'it did not finish';
137
+ return say({ ok: false, error: `Could not ${flags.remove ? 'remove' : 'install'} ${names.join(', ')}: ${why}` },
138
+ () => eprintln(color.red(`✗ ${why}`)));
139
+ }
140
+ const now = installed();
141
+ const wanted = new Set(names.map(packageName));
142
+ const these = now.filter((p) => wanted.has(packageName(p.name)));
143
+ return say({ ok: true, installed: these, removed: flags.remove ? names.map(packageName) : undefined }, () => {
144
+ if (flags.remove) println(color.green(`✓ removed ${names.join(', ')}`));
145
+ else for (const p of these) println(color.green(`✓ ${p.name} ${p.version}`));
146
+ });
147
+ }