flowviant 0.99.0 → 0.101.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.
@@ -0,0 +1,286 @@
1
+ import * as nodeFs from 'node:fs';
2
+ import { execFile as nodeExecFile } from 'node:child_process';
3
+ import { homedir } from 'node:os';
4
+ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { runningCompiledBinary } from './update.mjs';
7
+ import { VERSION } from './config.mjs';
8
+
9
+ const execFileAsync = promisify(nodeExecFile);
10
+ const inside = (path, dir) => path === dir || path.startsWith(`${dir}${sep}`);
11
+ const errorWords = (error) => String(error?.stderr || error?.message || error).trim();
12
+
13
+ function defaults(options = {}) {
14
+ return {
15
+ fs: nodeFs,
16
+ home: homedir(),
17
+ path: process.env.PATH || '',
18
+ execFile: (file, args, opts) => execFileAsync(file, args, opts),
19
+ pidLive: (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } },
20
+ procReader: (pid, file) => file === 'exe' || file === 'cwd'
21
+ ? nodeFs.readlinkSync(`/proc/${pid}/${file}`) : nodeFs.readFileSync(`/proc/${pid}/${file}`),
22
+ procAvailable: nodeFs.existsSync('/proc/self/cmdline'),
23
+ compiled: runningCompiledBinary(),
24
+ execPath: process.execPath,
25
+ argvPath: process.argv[1],
26
+ stopAllDaemons: null,
27
+ projects: null,
28
+ disconnect: null,
29
+ prompt: null,
30
+ tty: Boolean(process.stdin.isTTY),
31
+ log: (line) => console.error(line),
32
+ stdout: (line) => process.stdout.write(`${line}\n`),
33
+ ...options,
34
+ };
35
+ }
36
+
37
+ function real(fs, path) { try { return fs.realpathSync(path); } catch { return resolve(path); } }
38
+ function readVersion(fs, path) {
39
+ try {
40
+ const value = JSON.parse(fs.readFileSync(path, 'utf8')).version;
41
+ return typeof value === 'string' ? value : undefined;
42
+ } catch { return undefined; }
43
+ }
44
+ function files(fs, dir) { try { return fs.readdirSync(dir); } catch { return []; } }
45
+ function regular(fs, path) { try { return fs.statSync(path).isFile(); } catch { return false; } }
46
+ function executable(fs, path) { try { const stat = fs.statSync(path); return stat.isFile() && Boolean(stat.mode & 0o111); } catch { return false; } }
47
+ // A compiled flowviant is an ELF or Mach-O file. A shell wrapper someone named
48
+ // `flowviant` is not a copy of ours and is never removed.
49
+ function compiledExecutable(fs, path) {
50
+ try {
51
+ const fd = fs.openSync(path, 'r');
52
+ const head = Buffer.alloc(4);
53
+ try { fs.readSync(fd, head, 0, 4, 0); } finally { fs.closeSync(fd); }
54
+ const magic = head.readUInt32BE(0);
55
+ return magic === 0x7f454c46 || [0xfeedfacf, 0xcffaedfe, 0xfeedface, 0xcefaedfe, 0xcafebabe].includes(magic);
56
+ } catch { return false; }
57
+ }
58
+ function exactBlock(dir, rc) {
59
+ const line = basename(rc) === 'flowviant.fish'
60
+ ? `fish_add_path "${dir}"` : `export PATH="${dir}:$PATH"`;
61
+ return `\n# flowviant\n${line}\n`;
62
+ }
63
+
64
+ function liveDaemons(o) {
65
+ const found = [];
66
+ for (const name of files(o.fs, join(o.home, '.flowviant'))) {
67
+ if (!/^daemon-[0-9a-f]{12}\.lock$/.test(name)) continue;
68
+ try {
69
+ const pid = JSON.parse(o.fs.readFileSync(join(o.home, '.flowviant', name), 'utf8')).pid;
70
+ if (!Number.isInteger(pid) || pid <= 0 || !o.pidLive(pid)) continue;
71
+ let paths = [];
72
+ if (o.procAvailable) {
73
+ try {
74
+ const exe = o.procReader(pid, 'exe');
75
+ const command = o.procReader(pid, 'cmdline');
76
+ const args = Buffer.from(command).toString('utf8').split('\0').filter(Boolean);
77
+ let scripts = args.filter((arg) => isAbsolute(arg));
78
+ const relative = args.filter((arg) => !arg.startsWith('-') && arg.includes('/') && !isAbsolute(arg));
79
+ if (relative.length) {
80
+ const cwd = String(o.procReader(pid, 'cwd'));
81
+ scripts.push(...relative.map((arg) => resolve(cwd, arg)));
82
+ }
83
+ const executable = String(exe).replace(/ \(deleted\)$/, '');
84
+ paths = basename(executable).startsWith('node') && !scripts.length ? [] :
85
+ [executable, ...scripts].map((path) => real(o.fs, path));
86
+ } catch { /* The live pid is still protected when its command is hidden. */ }
87
+ }
88
+ found.push({ pid, paths });
89
+ } catch { /* An unreadable lock is not evidence of a live daemon. */ }
90
+ }
91
+ return found;
92
+ }
93
+
94
+ export async function buildUninstallPlan(options = {}) {
95
+ const o = defaults(options);
96
+ const copies = [];
97
+ const add = (kind, path, removal = path, version) => {
98
+ if (!copies.some((c) => c.kind === kind && c.path === path)) copies.push({ kind, path, removal, ...(version ? { version } : {}) });
99
+ };
100
+ let npmRoot;
101
+ try { npmRoot = String((await o.execFile('npm', ['root', '-g'], { timeout: 5000 })).stdout).trim(); }
102
+ catch { /* npm may not be installed. */ }
103
+ const npmPackage = npmRoot && join(npmRoot, 'flowviant');
104
+ if (npmPackage && o.fs.existsSync(npmPackage)) add('npm-global', npmPackage, npmPackage, readVersion(o.fs, join(npmPackage, 'package.json')));
105
+
106
+ const npxRoot = join(o.home, '.npm', '_npx');
107
+ for (const hash of files(o.fs, npxRoot)) {
108
+ const dir = join(npxRoot, hash);
109
+ const pkg = join(dir, 'node_modules', 'flowviant');
110
+ if (!o.fs.existsSync(pkg)) continue;
111
+ const version = readVersion(o.fs, join(pkg, 'package.json'));
112
+ let alone = false;
113
+ try {
114
+ const meta = JSON.parse(o.fs.readFileSync(join(dir, 'package.json'), 'utf8'));
115
+ const names = Object.keys({ ...meta.dependencies, ...meta.devDependencies, ...meta.optionalDependencies });
116
+ alone = names.length === 1 && names[0] === 'flowviant';
117
+ } catch { /* Unknown cache contents must not be removed as a whole. */ }
118
+ add('npx-cache', pkg, dir, version);
119
+ copies.at(-1).skipReason = alone ? null : 'cache contains other packages or its dependencies could not be read';
120
+ }
121
+
122
+ const ownBin = join(o.home, '.flowviant', 'bin');
123
+ // Trusted: the installer's default directory and any directory an installer
124
+ // PATH block names. Anything else on PATH must prove itself.
125
+ const trusted = new Set([real(o.fs, ownBin)]);
126
+ const addBinary = (dir, fromPath = false) => {
127
+ const candidate = join(dir, 'flowviant');
128
+ if (!(fromPath ? executable(o.fs, candidate) : regular(o.fs, candidate))) return;
129
+ const target = real(o.fs, candidate);
130
+ if (inside(target, npxRoot)) return;
131
+ const packagePath = target.match(/^(.*\/node_modules\/flowviant)(?:\/|$)/)?.[1];
132
+ if (packagePath) {
133
+ if (!copies.some((c) => c.kind === 'npm-global' && real(o.fs, c.path) === real(o.fs, packagePath))) {
134
+ let npmPrefix = dirname(dirname(packagePath));
135
+ if (basename(npmPrefix) === 'lib') npmPrefix = dirname(npmPrefix);
136
+ const version = readVersion(o.fs, join(packagePath, 'package.json'));
137
+ copies.push({ kind: 'npm-global', path: packagePath, removal: packagePath,
138
+ npmPrefix, ...(version ? { version } : {}) });
139
+ }
140
+ return;
141
+ }
142
+ // Outside the installer's own directory a file must PROVE it is ours:
143
+ // compiled, and answering `--version` with a version (checked below).
144
+ const proven = trusted.has(real(o.fs, dir));
145
+ if (!proven && !compiledExecutable(o.fs, target)) return;
146
+ add('binary', target);
147
+ if (!proven) copies.at(-1).mustProve = true;
148
+ };
149
+ addBinary(join(o.home, '.flowviant', 'bin'));
150
+ for (const dir of o.path.split(':').filter(Boolean)) addBinary(dir, true);
151
+ for (const rc of ['.zshrc', '.bashrc', '.profile', '.config/fish/conf.d/flowviant.fish']) {
152
+ const path = join(o.home, rc);
153
+ let content;
154
+ try { content = o.fs.readFileSync(path, 'utf8'); } catch { continue; }
155
+ const pattern = basename(path) === 'flowviant.fish'
156
+ ? /\n# flowviant\nfish_add_path "([^"\n]+)"\n/g
157
+ : /\n# flowviant\nexport PATH="([^"\n]+):\$PATH"\n/g;
158
+ for (const match of content.matchAll(pattern)) {
159
+ const dir = match[1];
160
+ if (match[0] !== exactBlock(dir, path)) continue;
161
+ copies.push({ kind: 'path-line', path, removal: path, block: match[0] });
162
+ trusted.add(real(o.fs, dir));
163
+ addBinary(dir);
164
+ }
165
+ }
166
+ const own = real(o.fs, o.compiled ? o.execPath : o.argvPath || '');
167
+ if (o.compiled && regular(o.fs, own)) add('binary', own);
168
+ for (const copy of copies.filter((c) => c.kind === 'binary')) {
169
+ if (o.compiled && copy.path === own) { copy.version = VERSION; continue; }
170
+ try {
171
+ const output = await o.execFile(copy.path, ['--version'], { timeout: 5000 });
172
+ const version = String(output.stdout).trim();
173
+ if (/^\d+\.\d+\.\d+/.test(version)) copy.version = version;
174
+ } catch { /* A foreign or older binary may not answer. */ }
175
+ }
176
+ for (let i = copies.length - 1; i >= 0; i--) {
177
+ if (copies[i].mustProve && !copies[i].version && !(o.compiled && copies[i].path === own)) copies.splice(i, 1);
178
+ else delete copies[i].mustProve;
179
+ }
180
+ const daemons = liveDaemons(o);
181
+ for (const copy of copies) {
182
+ const source = real(o.fs, copy.path);
183
+ copy.current = copy.kind === 'path-line' ? false :
184
+ (copy.kind === 'binary' ? source === own : inside(own, source));
185
+ const daemon = daemons.find((d) => !o.procAvailable || !d.paths.length || d.paths.some((p) =>
186
+ copy.kind === 'binary' ? p === source : inside(p, source)));
187
+ if (daemon && copy.kind !== 'path-line') copy.runningPid = daemon.pid;
188
+ }
189
+ return { copies, daemons, home: o.home };
190
+ }
191
+
192
+ function removePathBlock(o, path, block) {
193
+ const before = o.fs.readFileSync(path);
194
+ const at = before.indexOf(Buffer.from(block));
195
+ if (at < 0) return false;
196
+ const after = Buffer.concat([before.subarray(0, at), before.subarray(at + Buffer.byteLength(block))]);
197
+ const mode = o.fs.statSync(path).mode & 0o7777;
198
+ const temp = `${path}.${process.pid}.flowviant-tmp`;
199
+ try {
200
+ o.fs.writeFileSync(temp, after, { mode });
201
+ o.fs.chmodSync(temp, mode);
202
+ o.fs.renameSync(temp, path);
203
+ } finally { try { o.fs.rmSync(temp, { force: true }); } catch { /* Renamed. */ } }
204
+ return true;
205
+ }
206
+
207
+ export async function runUninstall(options = {}) {
208
+ const o = defaults(options);
209
+ const { others = false, purge = false, yes = false, json = false } = options;
210
+ const result = { ok: true, removed: [], skipped: [], kept: [], errors: [] };
211
+ const line = (m) => o.log(m);
212
+ const plan = await buildUninstallPlan(o);
213
+ if (!json || !yes) {
214
+ for (const c of plan.copies) line(`${c.kind} ${c.path}${c.version ? ` (${c.version})` : ''}${c.runningPid ? ` (daemon pid ${c.runningPid})` : ''}`);
215
+ if (!plan.copies.length && !purge) line('no flowviant copies found on this machine.');
216
+ }
217
+ if (!yes && (plan.copies.length || purge)) {
218
+ if (!o.tty || json) result.errors.push('re-run with --yes to remove');
219
+ else if (!/^y(es)?$/i.test(await o.prompt?.('Remove these? [y/N] ') || '')) result.errors.push('cancelled');
220
+ if (result.errors.length) {
221
+ result.ok = false;
222
+ if (json) o.stdout(JSON.stringify(result)); else result.errors.forEach(line);
223
+ return result;
224
+ }
225
+ }
226
+
227
+ if (!others) {
228
+ try {
229
+ const stop = o.stopAllDaemons || (await import('./instance.mjs')).stopAllDaemons;
230
+ const tally = await stop({ log: line });
231
+ if (tally.failed) result.errors.push(`${tally.failed} daemon(s) could not be stopped`);
232
+ } catch (e) { result.errors.push(`stopping daemons: ${errorWords(e)}`); }
233
+ }
234
+ if (purge && !others) {
235
+ try {
236
+ const projects = o.projects || (await import('./credentials.mjs')).listStoredProjects;
237
+ const disconnect = o.disconnect || (async (entry, log) => {
238
+ const { disconnectHere, realDisconnectDeps, leaveUrlFrom } = await import('./machines.mjs');
239
+ const { FLEET_URL } = await import('./config.mjs');
240
+ return disconnectHere(entry, await realDisconnectDeps({ url: leaveUrlFrom(FLEET_URL), log }), { log });
241
+ });
242
+ for (const entry of await projects()) {
243
+ try {
244
+ const messages = [];
245
+ const res = await disconnect(entry, (m) => { messages.push(m); line(m); });
246
+ const failure = messages.find((m) => /could not tell the app|does not take a leave|not disconnected|could not forget/.test(m));
247
+ if (!res.ok || failure) result.errors.push(`registry removal for ${entry.projectId}: ${failure || messages.at(-1) || 'failed'}`);
248
+ } catch (e) { result.errors.push(`registry removal for ${entry.projectId}: ${errorWords(e)}`); }
249
+ }
250
+ } catch (e) { result.errors.push(`reading projects: ${errorWords(e)}`); }
251
+ }
252
+
253
+ for (const c of plan.copies) {
254
+ const item = { kind: c.kind, path: c.path };
255
+ let reason = c.skipReason;
256
+ if (others && c.current) reason = 'running copy';
257
+ if (others && c.runningPid) reason = `a daemon is running from it (pid ${c.runningPid})`;
258
+ if (others && c.kind === 'path-line' && plan.daemons.length && !o.procAvailable) reason = 'a daemon may be using this copy';
259
+ if (others && c.kind === 'path-line' && plan.copies.some((copy) => copy.kind === 'binary' && copy.current && c.block === exactBlock(dirname(copy.path), c.path))) reason = 'needed by the running binary';
260
+ if (reason) {
261
+ result.skipped.push({ ...item, reason });
262
+ if (!json) line(`skipped ${c.path}: ${reason}`);
263
+ continue;
264
+ }
265
+ try {
266
+ if (c.kind === 'npm-global') await o.execFile('npm',
267
+ c.npmPrefix ? ['uninstall', '-g', '--prefix', c.npmPrefix, 'flowviant'] : ['uninstall', '-g', 'flowviant'],
268
+ { timeout: 120000 });
269
+ else if (c.kind === 'path-line') removePathBlock(o, c.path, c.block);
270
+ else o.fs.rmSync(c.removal, { recursive: c.kind === 'npx-cache', force: true });
271
+ result.removed.push(item);
272
+ if (!json) line(`removed ${c.path}`);
273
+ } catch (e) { result.errors.push(`${c.path}: ${errorWords(e)}`); }
274
+ }
275
+ if (purge && !others) {
276
+ try { o.fs.rmSync(join(o.home, '.flowviant'), { recursive: true, force: true }); }
277
+ catch (e) { result.errors.push(`local purge: ${errorWords(e)}`); }
278
+ } else {
279
+ result.kept.push(join(o.home, '.flowviant'));
280
+ if (!json && !others) line('Kept logins and local state in ~/.flowviant; reinstalling resumes them.');
281
+ }
282
+ result.ok = result.errors.length === 0;
283
+ for (const error of result.errors) if (!json) line(`error: ${error}`);
284
+ if (json) o.stdout(JSON.stringify(result));
285
+ return result;
286
+ }
@@ -30,7 +30,14 @@
30
30
  */
31
31
 
32
32
  import { execFileSync, spawn } from 'node:child_process';
33
- import { fileURLToPath } from 'node:url';
33
+ import { createHash } from 'node:crypto';
34
+ import { createReadStream, createWriteStream } from 'node:fs';
35
+ import { chmod, mkdtemp, open, rename, rm } from 'node:fs/promises';
36
+ import { dirname, join } from 'node:path';
37
+ import { platform, arch } from 'node:process';
38
+ import { pipeline } from 'node:stream/promises';
39
+ import { Readable } from 'node:stream';
40
+ import { runningViaNpx, terminalCommand } from './launchCommand.mjs';
34
41
  import { VERSION } from './config.mjs';
35
42
  import { note, ok, warn } from './ui.mjs';
36
43
 
@@ -45,22 +52,66 @@ export function cmpVersion(a, b) {
45
52
  return 0;
46
53
  }
47
54
 
48
- /**
49
- * npx runs from a per-invocation cache dir. `npm i -g` would install to a
50
- * DIFFERENT location than the one executing, so re-execing our own path would
51
- * loop on the stale cached copy. Detect npx and skip the install (nag instead —
52
- * relaunching with `npx flowviant@latest` is the npx-native update).
53
- */
54
- export function runningViaNpx() {
55
- const ua = process.env.npm_config_user_agent || '';
56
- const argv1 = process.argv[1] || '';
57
- let self = '';
55
+ /** Bun marks compiled entrypoints with a virtual /$bunfs/ script path. */
56
+ export function runningCompiledBinary() {
57
+ return Boolean(process.versions.bun && process.argv[1]?.startsWith('/$bunfs/'));
58
+ }
59
+
60
+ const RELEASE_ORIGIN = 'https://api.flowviant.com';
61
+
62
+ /** Download and verify before replacing the executable. Exposed for temp-dir tests. */
63
+ export async function installBinaryUpdate({
64
+ executable = process.execPath,
65
+ target = `${platform === 'darwin' ? 'darwin' : platform}-${arch}`,
66
+ fetchImpl = fetch,
67
+ origin = RELEASE_ORIGIN,
68
+ minimumVersion = null,
69
+ } = {}) {
70
+ if (!/^(linux|darwin)-(x64|arm64)$/.test(target)) throw new Error(`unsupported binary target ${target}`);
71
+ const manifestResponse = await fetchImpl(`${origin}/dl/latest.json`);
72
+ if (!manifestResponse.ok) throw new Error(`release manifest: HTTP ${manifestResponse.status}`);
73
+ const manifest = await manifestResponse.json();
74
+ const version = manifest?.version;
75
+ const file = manifest?.files?.[target];
76
+ if (!/^\d+\.\d+\.\d+$/.test(version) ||
77
+ file?.name !== `flowviant-${version}-${target}` ||
78
+ !/^[a-f0-9]{64}$/.test(file?.sha256) ||
79
+ !Number.isSafeInteger(file?.bytes) || file.bytes <= 0) {
80
+ throw new Error('invalid release manifest');
81
+ }
82
+ if (minimumVersion && cmpVersion(version, minimumVersion) < 0) {
83
+ throw new Error(`release manifest still serves ${version}; waiting for ${minimumVersion}`);
84
+ }
85
+ const binaryResponse = await fetchImpl(`${origin}/dl/${version}/${file.name}`);
86
+ if (!binaryResponse.ok || !binaryResponse.body) throw new Error(`binary download: HTTP ${binaryResponse.status}`);
87
+ const parent = dirname(executable);
88
+ const temporaryDir = await mkdtemp(join(parent, '.flowviant-update-'));
89
+ const temporaryFile = join(temporaryDir, file.name);
58
90
  try {
59
- self = fileURLToPath(import.meta.url);
60
- } catch {
61
- /* non-file URL — ignore */
91
+ await pipeline(Readable.fromWeb(binaryResponse.body), createWriteStream(temporaryFile, { mode: 0o755 }));
92
+ const digest = createHash('sha256');
93
+ let bytes = 0;
94
+ for await (const chunk of createReadStream(temporaryFile)) {
95
+ digest.update(chunk);
96
+ bytes += chunk.length;
97
+ }
98
+ if (bytes !== file.bytes || digest.digest('hex') !== file.sha256) {
99
+ throw new Error('downloaded binary failed SHA-256 or size verification');
100
+ }
101
+ await chmod(temporaryFile, 0o755);
102
+ const handle = await open(temporaryFile, 'r');
103
+ try { await handle.sync(); } finally { await handle.close(); }
104
+ await rename(temporaryFile, executable);
105
+ // The rename has committed. Directory fsync is best effort on filesystems
106
+ // that do not support it; a failure here must not claim the binary survived.
107
+ try {
108
+ const dirHandle = await open(parent, 'r');
109
+ try { await dirHandle.sync(); } finally { await dirHandle.close(); }
110
+ } catch { /* rename already succeeded */ }
111
+ return version;
112
+ } finally {
113
+ await rm(temporaryDir, { recursive: true, force: true }).catch(() => {});
62
114
  }
63
- return /\bnpx\b/.test(ua) || /[\\/]_npx[\\/]/.test(argv1) || /[\\/]_npx[\\/]/.test(self);
64
115
  }
65
116
 
66
117
  /**
@@ -70,7 +121,7 @@ export function runningViaNpx() {
70
121
  * this process alive only as a thin proxy waiting on the child, so the user's
71
122
  * shell stays attached to one foreground process.
72
123
  */
73
- function reexec(teardown, { viaNpx = false, target = null } = {}) {
124
+ function reexec(teardown, { viaNpx = false, viaBinary = false, target = null } = {}) {
74
125
  try {
75
126
  teardown?.();
76
127
  } catch {
@@ -84,7 +135,7 @@ function reexec(teardown, { viaNpx = false, target = null } = {}) {
84
135
  // on npx's install prompt — the same rule FLOWVIANT_REEXEC keeps below.
85
136
  const [cmd, args] = viaNpx
86
137
  ? ['npx', ['-y', 'flowviant@latest', ...process.argv.slice(2)]]
87
- : [process.execPath, process.argv.slice(1)];
138
+ : [process.execPath, process.argv.slice(viaBinary ? 2 : 1)];
88
139
  const child = spawn(cmd, args, {
89
140
  stdio: 'inherit',
90
141
  // MARK THE CHILD AS A RESTART, not as a person typing `flowviant`.
@@ -113,6 +164,7 @@ function reexec(teardown, { viaNpx = false, target = null } = {}) {
113
164
  // the global path there is no install step whose failure would throw and
114
165
  // stop it.
115
166
  ...(target ? { FLOWVIANT_UPDATE_TARGET: target } : {}),
167
+ FLOWVIANT_UPDATE_FROM: VERSION,
116
168
  },
117
169
  });
118
170
  child.on('exit', (code) => process.exit(code ?? 0));
@@ -138,9 +190,18 @@ function installLatest() {
138
190
 
139
191
  /** `flowviant update` — explicit, manual update. Does not re-exec into a daemon
140
192
  * (the user ran a one-shot command); it installs and tells them to relaunch. */
141
- export function runUpdateCommand() {
193
+ export async function runUpdateCommand() {
194
+ if (runningCompiledBinary()) {
195
+ try {
196
+ const version = await installBinaryUpdate();
197
+ ok(`updated to ${version}. Relaunch \`flowviant\` to run the new version.`);
198
+ } catch (e) {
199
+ warn(`update failed (${e?.message ?? e}) — running binary untouched.`);
200
+ }
201
+ return;
202
+ }
142
203
  if (runningViaNpx()) {
143
- note('running via npx — just relaunch with `npx flowviant@latest` to get the newest.');
204
+ note('running via npx — relaunch `flowviant` to get the newest.');
144
205
  return;
145
206
  }
146
207
  try {
@@ -148,7 +209,7 @@ export function runUpdateCommand() {
148
209
  installLatest();
149
210
  ok('updated. Relaunch `flowviant` to run the new version.');
150
211
  } catch (e) {
151
- warn(`update failed (${e?.message ?? e}). Try: npm i -g flowviant@latest`);
212
+ warn(`update failed (${e?.message ?? e}). Try: ${terminalCommand('update')}`);
152
213
  }
153
214
  }
154
215
 
@@ -170,13 +231,14 @@ const INSTALL_RETRY_MS = 15 * 60_000;
170
231
  * React to the server's {latest, min} signal from a roster poll.
171
232
  * @returns true if it kicked off a self-update + re-exec (caller must stop).
172
233
  */
173
- export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, teardown }) {
234
+ export async function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, teardown }) {
174
235
  const cur = VERSION;
175
236
  const belowMin = min && cmpVersion(cur, min) < 0;
176
237
  const belowLatest = latest && cmpVersion(cur, latest) < 0;
177
238
  if (!belowMin && !belowLatest) return false; // current — nothing to do
178
239
  const target = latest || min;
179
240
  const npx = runningViaNpx();
241
+ const binary = runningCompiledBinary();
180
242
  const wantInstall = belowMin || autoUpdate;
181
243
 
182
244
  // A RESTART THAT DID NOT TAKE must not be tried again on the next poll. The
@@ -189,13 +251,42 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
189
251
  naggedFor = target;
190
252
  warn(
191
253
  `restarted to pick up ${target} but came back as ${cur} — staying put. Update by hand: ${
192
- npx ? 'relaunch with `npx flowviant@latest`' : 'npm i -g flowviant@latest'
254
+ binary ? 'run `flowviant update`' : npx ? 'relaunch `flowviant`' : `run \`${terminalCommand('update')}\``
193
255
  }.`
194
256
  );
195
257
  }
196
258
  return false;
197
259
  }
198
260
 
261
+ if (wantInstall && binary) {
262
+ if (!safeToUpdate) {
263
+ if (naggedFor !== target) {
264
+ naggedFor = target;
265
+ note(`flowviant ${cur} → ${target} available — self-updating once no turn is running.`);
266
+ }
267
+ return false;
268
+ }
269
+ if (Date.now() - lastInstallFailAt < INSTALL_RETRY_MS) return false;
270
+ try {
271
+ note(`flowviant ${cur} → ${target}: downloading binary…`);
272
+ const installed = await installBinaryUpdate({ minimumVersion: target });
273
+ ok('updated — restarting into the new version.');
274
+ // The replacement is committed. Older copies are cleaned by the NEW
275
+ // binary in a detached child: in-process, the re-exec below could cut an
276
+ // `npm uninstall` off halfway and leave a broken global package. The
277
+ // child skips any copy a daemon still runs from.
278
+ try {
279
+ spawn(process.execPath, ['uninstall', '--others', '--yes', '--json'], { detached: true, stdio: 'ignore' }).unref();
280
+ } catch (e) { warn(`older-copy cleanup: ${e?.message ?? e}`); }
281
+ reexec(teardown, { viaBinary: true, target: installed });
282
+ return true;
283
+ } catch (e) {
284
+ lastInstallFailAt = Date.now();
285
+ warn(`binary self-update failed (${e?.message ?? e}) — running binary untouched; retrying in 15m.`);
286
+ return false;
287
+ }
288
+ }
289
+
199
290
  // UNDER NPX THERE IS NOTHING TO INSTALL — the relaunch IS the update, because
200
291
  // `npx -y flowviant@latest` resolves `latest` against the registry. Same two
201
292
  // gates as the global path: the operator's AUTO_UPDATE choice, and an idle
@@ -215,7 +306,7 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
215
306
  return true;
216
307
  }
217
308
 
218
- if (wantInstall && !npx) {
309
+ if (wantInstall && !npx && !binary) {
219
310
  if (!safeToUpdate) {
220
311
  // Outdated but a turn is running — wait until the machine is quiet. Nag
221
312
  // once meanwhile.
@@ -265,7 +356,7 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
265
356
  } catch (e) {
266
357
  lastInstallFailAt = Date.now();
267
358
  warn(
268
- `self-update failed (${e?.message ?? e}) — retrying in 15m; to fix it now: npm i -g flowviant@latest`
359
+ `self-update failed (${e?.message ?? e}) — retrying in 15m; to fix it now: ${terminalCommand('update')}`
269
360
  );
270
361
  return false;
271
362
  }
@@ -274,7 +365,7 @@ export function handleVersionSignal({ latest, min, autoUpdate, safeToUpdate, tea
274
365
  // Can't or won't auto-install → nag once per target version.
275
366
  if (naggedFor !== target) {
276
367
  naggedFor = target;
277
- const how = npx ? 'relaunch with `npx flowviant@latest`' : 'run `npm i -g flowviant@latest`';
368
+ const how = binary ? 'run `flowviant update`' : npx ? 'relaunch `flowviant`' : `run \`${terminalCommand('update')}\``;
278
369
  if (belowMin) {
279
370
  warn(`flowviant ${cur} is below the minimum ${min} — live mode may not work. Update: ${how}.`);
280
371
  } else {
@@ -2,7 +2,8 @@
2
2
  import { limitLine } from './workAgentReview.mjs';
3
3
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
- import { FLEET_URL, FLEET_TOKEN, USER_AGENT } from './config.mjs';
5
+ import { FLEET_URL, FLEET_TOKEN, USER_AGENT, PROJECT_ID } from './config.mjs';
6
+ import { setRuntimeLimit } from './desktopContract.mjs';
6
7
  import { git, isSafePathSegment } from './git.mjs';
7
8
  import { parseTurnResult } from './agentPlan.mjs';
8
9
  import { stashCard } from './agentCards.mjs';
@@ -813,7 +814,9 @@ export function createWorkAgentTurns({
813
814
  * sentence it failed with.
814
815
  */
815
816
  const limit = res ? null : limitLine(out);
817
+ if (res) setRuntimeLimit(PROJECT_ID, rt, null);
816
818
  if (limit) {
819
+ setRuntimeLimit(PROJECT_ID, rt, limit);
817
820
  // Every agent ON THIS CLI parks, because that account is shared: one
818
821
  // hitting the limit means all of them have. Named since 2026-09-24 —
819
822
  // a machine can hold a Claude login and a Codex login, and a Codex
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.99.0",
3
+ "version": "0.101.0",
4
4
  "description": "Run your own coding CLIs as build agents for Flowviant — Claude Code, Codex or Antigravity, on your own credentials. Holds your sessions, keeps a worktree per tab, and ships branches on your word.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,6 @@
19
19
  "test": "node --test bin/lib/*.test.mjs"
20
20
  },
21
21
  "dependencies": {
22
- "@anthropic-ai/claude-agent-sdk": "^0.3.0",
23
22
  "libsodium-wrappers-sumo": "^0.8.4",
24
23
  "ws": "^8.18.0"
25
24
  },