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.
- package/README.md +18 -12
- package/bin/cli.mjs +108 -25
- package/bin/lib/config.mjs +12 -8
- package/bin/lib/credentials.mjs +2 -1
- package/bin/lib/desktopContract.mjs +227 -0
- package/bin/lib/fleet.mjs +22 -2
- package/bin/lib/instance.mjs +14 -0
- package/bin/lib/launchCommand.mjs +23 -0
- package/bin/lib/login.mjs +34 -10
- package/bin/lib/machines.mjs +8 -7
- package/bin/lib/mcp-cli.mjs +4 -3
- package/bin/lib/preflight.mjs +2 -1
- package/bin/lib/tty.mjs +2 -2
- package/bin/lib/uninstall.mjs +286 -0
- package/bin/lib/update.mjs +116 -25
- package/bin/lib/workAgentTurns.mjs +4 -1
- package/package.json +1 -2
|
@@ -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
|
+
}
|
package/bin/lib/update.mjs
CHANGED
|
@@ -30,7 +30,14 @@
|
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import { execFileSync, spawn } from 'node:child_process';
|
|
33
|
-
import {
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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 —
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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.
|
|
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
|
},
|