flowviant 0.100.0 → 0.101.2

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 CHANGED
@@ -52,6 +52,10 @@ flowviant login
52
52
 
53
53
  The binary and global npm installs update when idle. The binary fetches the release manifest and verifies the new executable before replacing itself. The global install refreshes from npm before restarting. `flowviant update` checks on demand. `FLOWVIANT_NO_UPDATE=1` makes updates manual.
54
54
 
55
+ ## Uninstall
56
+
57
+ `flowviant uninstall` stops local daemons and removes installed copies. It keeps logins and local state in `~/.flowviant`, so reinstalling resumes them. Use `flowviant uninstall --purge` to disconnect this box from stored projects and delete that local data too. Add `--yes` to skip confirmation.
58
+
55
59
  ## Machines on this box
56
60
 
57
61
  One box can serve several projects — one daemon per repository directory, each connected with its own `flowviant login` run inside that repo. `flowviant machines` lists every project connected on this box with the id and date it was connected, and under each one every computer that has polled it (the app's Home lists the same across your whole account). Two projects bound to one repository are named out loud at the foot of the listing, because that is the one shape that reads as "the same project connected twice" and is not — and a directory serves one project, so `flowviant` there refuses to start until one of them goes.
package/bin/cli.mjs CHANGED
@@ -70,10 +70,15 @@
70
70
  * env (the box keypair, the uplink scrubber, the env-comparison scan) + vault
71
71
  * (the knowledge wiki, not secrets), resources, deploy, shot.
72
72
  */
73
+ // The tray app reads `login --json` through a pipe (wsl.exe). A line written
74
+ // just before process.exit() can be dropped there, and then all the tray knows
75
+ // is that the login ended; wait for stdout to drain before exiting.
76
+ const flushStdout = () => new Promise((resolve) => process.stdout.write('', resolve));
77
+
73
78
  // Resolve --dir before config reads the project bound to cwd. A tray process
74
79
  // starts outside the checkout; its explicit folder is the daemon's checkout.
75
80
  const dirAt = process.argv.indexOf('--dir');
76
- const noCheckoutCommand = new Set(['--version', '-v', 'version', 'status', 'stop', 'machines', 'projects', 'update', 'shot', 'clean', 'gh-auth', 'mcp']);
81
+ const noCheckoutCommand = new Set(['--version', '-v', 'version', 'status', 'stop', 'uninstall', 'machines', 'projects', 'update', 'shot', 'clean', 'gh-auth', 'mcp']);
77
82
  if (dirAt >= 0 && !noCheckoutCommand.has(process.argv[2])) {
78
83
  const dir = process.argv[dirAt + 1];
79
84
  try {
@@ -83,6 +88,7 @@ if (dirAt >= 0 && !noCheckoutCommand.has(process.argv[2])) {
83
88
  if (process.argv.includes('--json') && process.argv[2] === 'login')
84
89
  process.stdout.write(`${JSON.stringify({ event: 'error', message: `Cannot use directory ${dir ?? ''}: ${e.message}` })}\n`);
85
90
  else console.error(`error: cannot use directory ${dir ?? ''}: ${e.message}`);
91
+ await flushStdout();
86
92
  process.exit(1);
87
93
  }
88
94
  }
@@ -122,6 +128,7 @@ if (process.argv[2] === 'login') {
122
128
  // A login the person CANCELLED at the second-project question saved nothing,
123
129
  // so there is no credential for the child to serve — starting it would end
124
130
  // in "no credential found" over a choice they just made on purpose.
131
+ if (json) await flushStdout();
125
132
  if (!login?.saved) process.exit(1); // refused: the directory already serves another project
126
133
  if (noStart) process.exit(0);
127
134
  // Re-exec as a plain `flowviant` rather than falling through. config.mjs reads
@@ -243,6 +250,19 @@ if (process.argv[2] === 'stop') {
243
250
  process.exit(failed > 0 ? 1 : 0);
244
251
  }
245
252
 
253
+ if (process.argv[2] === 'uninstall') {
254
+ const { runUninstall } = await import('./lib/uninstall.mjs');
255
+ const { askWithTimeout } = await import('./lib/tty.mjs');
256
+ const result = await runUninstall({
257
+ others: process.argv.includes('--others'),
258
+ purge: process.argv.includes('--purge'),
259
+ yes: process.argv.includes('--yes'),
260
+ json: process.argv.includes('--json'),
261
+ prompt: (query) => askWithTimeout(query, undefined, process.stderr),
262
+ });
263
+ process.exit(result.ok ? 0 : 1);
264
+ }
265
+
246
266
  // `flowviant projects` — every project this box has a credential for, which
247
267
  // repo each is bound to, and which the legacy mirror points at. Needs no
248
268
  // network: it reads the store, which is the exact thing a confused person is
package/bin/lib/login.mjs CHANGED
@@ -51,10 +51,14 @@ export async function runLogin({ thenStart = false, json = false, dir = process.
51
51
  // section deleted 2026-08-17; connecting a machine is offered on the surface
52
52
  // you are on when it matters, and for a new operator that is the Workbench —
53
53
  // the project's empty state says so before it can show you any sessions.
54
- if (json) event({ event: 'open_url', url: APP_URL, code: pretty });
54
+ // `/connect?code=` (2026-09-25) takes the code, asks which project, and
55
+ // approves: the code no longer has to be carried by hand into a project's
56
+ // Connect a machine panel, which is where people got lost.
57
+ const approveUrl = `${APP_URL}/connect?code=${encodeURIComponent(pretty)}`;
58
+ if (json) event({ event: 'open_url', url: approveUrl, code: pretty });
55
59
  else {
56
- console.log(` 1. Open ${c.cyan(APP_URL)} → your project → the ${c.bold('Workbench')} → ${c.bold('Connect a machine')}.`);
57
- console.log(` 2. Enter this code: ${c.bold(c.green(pretty))}\n`);
60
+ console.log(` Open ${c.cyan(approveUrl)}`);
61
+ console.log(` and approve this computer for your project. Check the code there is ${c.bold(c.green(pretty))}.\n`);
58
62
  info('waiting for you to approve…');
59
63
  }
60
64
 
package/bin/lib/tty.mjs CHANGED
@@ -112,7 +112,7 @@ export function boundedTimer(timeoutMs, fire) {
112
112
  * answer everywhere (the binding confirm serves unbound; the project picker
113
113
  * refuses, exactly as it does headless).
114
114
  */
115
- export async function askWithTimeout(query, timeoutMs) {
115
+ export async function askWithTimeout(query, timeoutMs, output = process.stdout) {
116
116
  const noop = () => {};
117
117
  // Replacing the DEFAULT disposition is the whole point — an empty handler is
118
118
  // enough, and it is what keeps the timer below able to run at all.
@@ -120,7 +120,7 @@ export async function askWithTimeout(query, timeoutMs) {
120
120
  process.on('SIGTTOU', noop);
121
121
  const rl = (await import('node:readline/promises')).createInterface({
122
122
  input: process.stdin,
123
- output: process.stdout,
123
+ output,
124
124
  });
125
125
  const ac = new AbortController();
126
126
  const timer = boundedTimer(timeoutMs, () => ac.abort());
@@ -0,0 +1,287 @@
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
+ // The copy doing the work is not news; every other skip is.
263
+ if (!json && reason !== 'running copy') line(`skipped ${c.path}: ${reason}`);
264
+ continue;
265
+ }
266
+ try {
267
+ if (c.kind === 'npm-global') await o.execFile('npm',
268
+ c.npmPrefix ? ['uninstall', '-g', '--prefix', c.npmPrefix, 'flowviant'] : ['uninstall', '-g', 'flowviant'],
269
+ { timeout: 120000 });
270
+ else if (c.kind === 'path-line') removePathBlock(o, c.path, c.block);
271
+ else o.fs.rmSync(c.removal, { recursive: c.kind === 'npx-cache', force: true });
272
+ result.removed.push(item);
273
+ if (!json) line(c.kind === 'path-line' ? `removed the flowviant PATH line from ${c.path}` : `removed ${c.path}`);
274
+ } catch (e) { result.errors.push(`${c.path}: ${errorWords(e)}`); }
275
+ }
276
+ if (purge && !others) {
277
+ try { o.fs.rmSync(join(o.home, '.flowviant'), { recursive: true, force: true }); }
278
+ catch (e) { result.errors.push(`local purge: ${errorWords(e)}`); }
279
+ } else {
280
+ result.kept.push(join(o.home, '.flowviant'));
281
+ if (!json && !others) line('Kept logins and local state in ~/.flowviant; reinstalling resumes them.');
282
+ }
283
+ result.ok = result.errors.length === 0;
284
+ for (const error of result.errors) if (!json) line(`error: ${error}`);
285
+ if (json) o.stdout(JSON.stringify(result));
286
+ return result;
287
+ }
@@ -271,6 +271,13 @@ export async function handleVersionSignal({ latest, min, autoUpdate, safeToUpdat
271
271
  note(`flowviant ${cur} → ${target}: downloading binary…`);
272
272
  const installed = await installBinaryUpdate({ minimumVersion: target });
273
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}`); }
274
281
  reexec(teardown, { viaBinary: true, target: installed });
275
282
  return true;
276
283
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowviant",
3
- "version": "0.100.0",
3
+ "version": "0.101.2",
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": {