@base_bit/vmg-cli 0.0.3 → 0.0.5

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
@@ -32,6 +32,48 @@ absolute private writable base; `vmg doctor --json` reports the resource locatio
32
32
  A damaged cache is an error: stop processes using the affected hash, remove only
33
33
  that hash directory and retry. Older hashes are retained across upgrades.
34
34
 
35
- Upgrade with `npm install -g @base_bit/vmg-cli@latest`; uninstall with
35
+ Upgrade with `vmg update` (or `npm install -g @base_bit/vmg-cli@latest`); uninstall with
36
36
  `npm uninstall -g @base_bit/vmg-cli`. Cached runtime resources remain on disk.
37
37
  The independently distributed native binary continues to work without npm.
38
+
39
+ ## Updates
40
+
41
+ ```sh
42
+ vmg update --check # Query latest without installing
43
+ vmg update # Upgrade a confirmed npm global installation
44
+ vmg update --check --json # Structured result for scripts
45
+ vmg update --timeout-seconds 300
46
+ ```
47
+
48
+ The npm launcher queries the configured npm registry in global mode (including
49
+ user/global npm configuration and environment settings, excluding project
50
+ `.npmrc`). It compares semantic versions against `latest`, never downgrades, and
51
+ installs the exact checked version with its matching platform package. Automatic
52
+ installation requires the current package to be directly inside the active npm
53
+ global root with a matching prefix; linked packages and detected pnpm/yarn layouts
54
+ are excluded. If a different Node/npm installation is active, switch back to the
55
+ one that owns VMG. Project-local, npx and other package-manager installs receive
56
+ manual instructions. Standalone binaries must be replaced manually.
57
+
58
+ Updates retain the npm prefix, enable optional dependencies and disable install
59
+ scripts. Success means the entry package, platform package and native executable
60
+ all report the requested version. No administrator escalation is attempted. npm
61
+ installation is not an atomic transaction: errors, interruption or timeout can
62
+ leave an incomplete installation; follow the reported reinstall instruction.
63
+ Concurrent updates to the same installation wait for a lock within the timeout.
64
+ A stale lock error names the file to remove after confirming no update is running.
65
+ Old runtime caches are retained.
66
+
67
+ Ordinary interactive commands check in parallel at most once every **12 hours**,
68
+ with a three-second limit. Checks stop when the command exits. Notices go to
69
+ stderr and show the current/latest versions. Network or cache failures are silent
70
+ and do not change the command's result. Checks are disabled for non-interactive
71
+ runs, CI, `--json`, help and version output. Set `VMG_NO_UPDATE_NOTIFIER=1` to opt
72
+ out. Attempts and results are cached separately under `vmg/updates` in the user's
73
+ cache directory (on macOS, `~/Library/Caches`, or absolute `XDG_CACHE_HOME`).
74
+
75
+ Explicit `update --check` bypasses that cache and reports registry errors. Its
76
+ successful exit status is 0 even when an update is available. `--json` returns
77
+ `ok`, `currentVersion`, `latestVersion`, `updateAvailable` and `updated`; successful
78
+ installation additionally includes `installedVersion`. Errors and unsupported
79
+ installation attempts exit nonzero; npm logs never mix into JSON stdout.
@@ -0,0 +1,199 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { readFile, realpath, mkdir, open, rename, rm, access } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join, resolve, isAbsolute } from 'node:path';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
+
7
+ export const packageName = '@base_bit/vmg-cli';
8
+ export const checkInterval = 12 * 60 * 60 * 1000;
9
+ const shellQuote = value => "'" + value.replaceAll("'", "'\\''") + "'";
10
+ const repair = 'npm install -g @base_bit/vmg-cli@latest --include=optional --ignore-scripts';
11
+ const readJson = async path => JSON.parse(await readFile(path, 'utf8'));
12
+ const exists = async path => access(path).then(() => true, () => false);
13
+
14
+ function versionParts(value) {
15
+ if (typeof value !== 'string') throw new Error('Invalid npm version');
16
+ const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value);
17
+ if (!match || match[4]?.split('.').some(x => /^\d+$/.test(x) && x.length > 1 && x[0] === '0')) throw new Error(`Invalid npm version: ${value}`);
18
+ return { core: match.slice(1, 4).map(BigInt), pre: match[4]?.split('.') };
19
+ }
20
+ export function compareVersions(a, b) {
21
+ const left = versionParts(a), right = versionParts(b);
22
+ for (let i = 0; i < 3; i++) if (left.core[i] !== right.core[i]) return left.core[i] > right.core[i] ? 1 : -1;
23
+ if (!left.pre || !right.pre) return left.pre ? -1 : right.pre ? 1 : 0;
24
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
25
+ const x = left.pre[i], y = right.pre[i];
26
+ if (x === y) continue;
27
+ if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
28
+ const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
29
+ if (nx && ny) return BigInt(x) > BigInt(y) ? 1 : -1;
30
+ if (nx !== ny) return nx ? -1 : 1;
31
+ return x > y ? 1 : -1;
32
+ }
33
+ return 0;
34
+ }
35
+
36
+ // Parse only the update command. Every other command keeps Rust's parser.
37
+ export function parseUpdate(args) {
38
+ let command, check = false, json = false, timeout = 120, invalid;
39
+ for (let i = 0; i < args.length; i++) {
40
+ const arg = args[i];
41
+ if (arg === '--json') json = true;
42
+ else if (arg === '--timeout-seconds' || arg.startsWith('--timeout-seconds=')) {
43
+ const value = arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : args[++i];
44
+ if (!/^\d+$/.test(value ?? '') || +value < 1 || +value > 3600) invalid = 'timeout-seconds must be between 1 and 3600';
45
+ else timeout = +value;
46
+ } else if (!command && !arg.startsWith('-')) command = arg;
47
+ else if (arg === '--check' && command === 'update') check = true;
48
+ else if (arg === '--help' || arg === '-h') return null;
49
+ else invalid = `Unexpected argument: ${arg}`;
50
+ }
51
+ if (command !== 'update') return null;
52
+ if (invalid) throw new Error(invalid);
53
+ return { check, json, timeout };
54
+ }
55
+
56
+ // Own the npm process group; capture logs so --json remains a single result.
57
+ export function runCommand(program, args, { signal, cwd = homedir(), env = process.env } = {}) {
58
+ signal?.throwIfAborted();
59
+ return new Promise((resolveResult, reject) => {
60
+ const child = spawn(program, args, { cwd, env, shell: false, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
61
+ let stdout = '', stderr = '', failure, escalation;
62
+ const kill = sig => { try { process.kill(-child.pid, sig); } catch { child.kill(sig); } };
63
+ const stop = () => {
64
+ failure ??= signal?.reason ?? new Error('Command cancelled');
65
+ kill(failure.signal ?? 'SIGTERM');
66
+ escalation ??= setTimeout(() => kill('SIGKILL'), 1000);
67
+ };
68
+ const collect = stream => bytes => {
69
+ if (stdout.length + stderr.length + bytes.length > 2 * 1024 * 1024) { failure = new Error('npm output exceeded limit'); stop(); return; }
70
+ if (stream === 'stdout') stdout += bytes; else stderr += bytes;
71
+ };
72
+ child.stdout.on('data', collect('stdout')); child.stderr.on('data', collect('stderr'));
73
+ signal?.addEventListener('abort', stop, { once: true });
74
+ if (signal?.aborted) stop();
75
+ child.once('error', error => { failure = error; });
76
+ child.once('close', (code, termination) => {
77
+ clearTimeout(escalation); signal?.removeEventListener('abort', stop);
78
+ // No npm helper may outlive the owned operation.
79
+ try { process.kill(-child.pid, 'SIGKILL'); } catch {}
80
+ if (failure) reject(failure);
81
+ else if (code !== 0) reject(new Error(`${program} failed (${termination ?? code}): ${stderr.trim() || stdout.trim()}`));
82
+ else resolveResult(stdout.trim());
83
+ });
84
+ });
85
+ }
86
+
87
+ export function context({ signal, run = runCommand } = {}) {
88
+ const options = { signal, cwd: homedir() };
89
+ return { signal, npm: args => run('npm', args, options), run: (program, args) => run(program, args, options) };
90
+ }
91
+ async function latestVersion(ctx) {
92
+ const latest = JSON.parse(await ctx.npm(['view', `${packageName}@latest`, 'version', '--json', '--prefer-online', '--global']));
93
+ versionParts(latest);
94
+ return latest;
95
+ }
96
+ export async function installation(packageJson, ctx) {
97
+ const rootPath = await ctx.npm(['root', '--global']);
98
+ const prefix = await ctx.npm(['prefix', '--global']);
99
+ if (!isAbsolute(rootPath) || !isAbsolute(prefix)) return null;
100
+ const root = await realpath(rootPath).catch(() => null);
101
+ if (!root) return null;
102
+ const expected = join(root, packageName, 'package.json');
103
+ const actual = await realpath(packageJson);
104
+ // npm's layout is direct. pnpm stores and linked packages are not ours to mutate.
105
+ if (actual !== expected || await exists(join(root, '.modules.yaml')) || await exists(join(root, '.yarn-integrity'))) return null;
106
+ if (root !== await realpath(join(prefix, 'lib', 'node_modules')).catch(() => null)) return null;
107
+ if ((await readJson(actual)).name !== packageName) return null;
108
+ return { root, prefix, packageJson: actual };
109
+ }
110
+
111
+ async function lockInstallation(root, signal) {
112
+ const path = join(root, '@base_bit', '.vmg-update.lock');
113
+ for (;;) {
114
+ signal?.throwIfAborted();
115
+ try {
116
+ const file = await open(path, 'wx', 0o600);
117
+ try { await file.writeFile(JSON.stringify({ pid: process.pid, createdAt: Date.now() })); }
118
+ catch (error) { await file.close(); await rm(path, { force: true }); throw error; }
119
+ await file.close();
120
+ return () => rm(path, { force: true });
121
+ } catch (error) {
122
+ if (error.code !== 'EEXIST') throw error;
123
+ const owner = await readJson(path).catch(() => null);
124
+ if (Number.isSafeInteger(owner?.pid) && owner.pid > 0) {
125
+ try { process.kill(owner.pid, 0); }
126
+ catch (cause) {
127
+ if (cause.code === 'ESRCH') throw new Error(`Stale update lock: ${path}. Remove it after confirming no update is running.`);
128
+ }
129
+ }
130
+ await new Promise(resolveWait => setTimeout(resolveWait, 100));
131
+ }
132
+ }
133
+ }
134
+
135
+ export async function update(packageJson, options, { ctx = context(), platformName, resolveBinary } = {}) {
136
+ const original = await readJson(packageJson);
137
+ const latest = await latestVersion(ctx);
138
+ const summary = { ok: true, currentVersion: original.version, latestVersion: latest, updateAvailable: compareVersions(latest, original.version) > 0, updated: false };
139
+ if (options.check) return summary;
140
+ const location = await installation(packageJson, ctx);
141
+ if (!location) return { ...summary, ok: false, message: 'Automatic updates require a confirmed npm global installation. For a project dependency use its package manager; for npx run npx @base_bit/vmg-cli@latest; for pnpm/yarn use their global update command. For npm global installation: ' + repair };
142
+ if (!summary.updateAvailable) return summary;
143
+ const unlock = await lockInstallation(location.root, ctx.signal);
144
+ let installStarted = false;
145
+ try {
146
+ const current = await readJson(packageJson);
147
+ summary.currentVersion = current.version;
148
+ summary.updateAvailable = compareVersions(latest, current.version) > 0;
149
+ if (!summary.updateAvailable) return summary;
150
+ if (!platformName) throw new Error('This platform is not supported by VMG');
151
+ const available = JSON.parse(await ctx.npm(['view', `${platformName}@${latest}`, 'version', '--json', '--prefer-online', '--global']));
152
+ if (available !== latest) throw new Error(`Missing matching platform package ${platformName}@${latest}`);
153
+ ctx.signal?.throwIfAborted();
154
+ installStarted = true;
155
+ await ctx.npm(['install', '--global', '--prefix', location.prefix, `${packageName}@${latest}`, '--include=optional', '--ignore-scripts', '--no-audit', '--no-fund']);
156
+ ctx.signal?.throwIfAborted();
157
+ if ((await readJson(packageJson)).version !== latest) throw new Error('Installed entry package does not match the requested version');
158
+ const binary = resolveBinary(packageJson);
159
+ if (await ctx.run(binary, ['--version']) !== `vmg ${latest}`) throw new Error('Installed native binary version does not match the requested version');
160
+ return { ...summary, updated: true, installedVersion: latest };
161
+ } catch (error) {
162
+ if (!installStarted) throw error;
163
+ throw new Error(`${error.message}\nInstallation may be incomplete. Repair the same npm prefix with: npm install -g --prefix ${shellQuote(location.prefix)} ${packageName}@${latest} --include=optional --ignore-scripts`, { cause: error });
164
+ } finally { await unlock(); }
165
+ }
166
+
167
+ export function shouldNotify(args, env = process.env, tty = process.stdin.isTTY && process.stderr.isTTY) {
168
+ return Boolean(tty && !env.CI && env.VMG_NO_UPDATE_NOTIFIER !== '1' && !args.some(x => ['--json', '--help', '-h', '--version', '-V'].includes(x)));
169
+ }
170
+ export function notificationCache(packageJson) {
171
+ const base = process.env.XDG_CACHE_HOME && resolve(process.env.XDG_CACHE_HOME) === process.env.XDG_CACHE_HOME
172
+ ? process.env.XDG_CACHE_HOME : process.platform === 'darwin' ? join(homedir(), 'Library', 'Caches') : join(homedir(), '.cache');
173
+ return join(base, 'vmg', 'updates', createHash('sha256').update(packageJson).digest('hex') + '.json');
174
+ }
175
+ export async function notify(packageJson, { ctx = context(), cachePath = notificationCache(packageJson), now = Date.now(), emit = text => process.stderr.write(text) } = {}) {
176
+ try {
177
+ const current = (await readJson(packageJson)).version;
178
+ const cached = await readJson(cachePath).catch(() => null);
179
+ let latest = cached?.latest;
180
+ if (!(Number.isFinite(cached?.checkedAt) && now >= cached.checkedAt && now - cached.checkedAt < checkInterval)) {
181
+ await mkdir(dirname(cachePath), { recursive: true, mode: 0o700 });
182
+ // Record attempts too, so offline sessions do not repeatedly spawn npm.
183
+ const save = async value => {
184
+ const temporary = `${cachePath}.${randomUUID()}.tmp`;
185
+ try {
186
+ const file = await open(temporary, 'wx', 0o600);
187
+ try { await file.writeFile(JSON.stringify(value)); } finally { await file.close(); }
188
+ await rename(temporary, cachePath);
189
+ } finally { await rm(temporary, { force: true }); }
190
+ };
191
+ await save({ checkedAt: now });
192
+ latest = await latestVersion(ctx);
193
+ ctx.signal?.throwIfAborted();
194
+ await save({ checkedAt: now, latest });
195
+ }
196
+ ctx.signal?.throwIfAborted();
197
+ if (latest && compareVersions(latest, current) > 0) emit(`VMG update available: ${current} → ${latest}. Run vmg update (npm global installs); otherwise update with your package manager.\n`);
198
+ } catch { /* Best effort only: never change a normal command's outcome. */ }
199
+ }
package/bin/vmg.mjs CHANGED
@@ -4,6 +4,7 @@ import { readFileSync, realpathSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import { dirname, join, resolve } from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
+ import { parseUpdate, update, context, shouldNotify, notify } from './npm-update.mjs';
7
8
 
8
9
  export const platforms = Object.freeze({ 'darwin-arm64': '@base_bit/vmg-cli-darwin-arm64' });
9
10
 
@@ -24,23 +25,64 @@ export function resolveBinary(packageJson, platform = process.platform, arch = p
24
25
  }
25
26
 
26
27
  export function launch(binary, args) {
27
- // Keep Rust attached to the terminal. Rust owns the separately grouped bridge
28
- // and must finish reaping it before this launcher exits.
29
- const child = spawn(binary, args, { stdio: 'inherit', shell: false });
30
- const interrupt = () => child.kill('SIGINT'), terminate = () => child.kill('SIGTERM');
31
- process.on('SIGINT', interrupt); process.on('SIGTERM', terminate);
32
- const cleanup = () => { process.off('SIGINT', interrupt); process.off('SIGTERM', terminate); };
33
- let spawnFailed = false;
34
- child.once('error', error => { spawnFailed = true; cleanup(); console.error(`vmg: Cannot start platform binary: ${error.message}`); process.exitCode = 1; });
35
- child.once('close', (code, signal) => {
36
- cleanup();
37
- if (spawnFailed) return;
38
- if (signal) process.kill(process.pid, signal);
39
- else process.exitCode = code ?? 1;
28
+ return new Promise(resolveDone => {
29
+ // Keep Rust attached to the terminal. Rust owns the separately grouped bridge
30
+ // and must finish reaping it before this launcher exits.
31
+ const child = spawn(binary, args, { stdio: 'inherit', shell: false });
32
+ const interrupt = () => child.kill('SIGINT'), terminate = () => child.kill('SIGTERM');
33
+ process.on('SIGINT', interrupt); process.on('SIGTERM', terminate);
34
+ const cleanup = () => { process.off('SIGINT', interrupt); process.off('SIGTERM', terminate); };
35
+ let spawnFailed = false;
36
+ child.once('error', error => { spawnFailed = true; cleanup(); console.error(`vmg: Cannot start platform binary: ${error.message}`); process.exitCode = 1; });
37
+ child.once('close', (code, signal) => {
38
+ cleanup();
39
+ if (spawnFailed) { resolveDone(); return; }
40
+ process.exitCode = code ?? (signal === 'SIGINT' ? 130 : signal === 'SIGTERM' ? 143 : 1);
41
+ resolveDone(signal);
42
+ });
40
43
  });
41
44
  }
42
45
 
46
+ export async function main(args, packageJson) {
47
+ const options = parseUpdate(args);
48
+ const controller = new AbortController();
49
+ if (options) {
50
+ const stop = signal => {
51
+ const error = new Error(`Update interrupted by ${signal}`);
52
+ error.signal = signal; controller.abort(error);
53
+ process.exitCode = signal === 'SIGINT' ? 130 : 143;
54
+ };
55
+ const interrupt = () => stop('SIGINT'), terminate = () => stop('SIGTERM');
56
+ process.on('SIGINT', interrupt); process.on('SIGTERM', terminate);
57
+ const timer = setTimeout(() => controller.abort(new Error('Update timed out')), options.timeout * 1000);
58
+ try {
59
+ const result = await update(packageJson, options, { ctx: context({ signal: controller.signal }), platformName: platforms[`${process.platform}-${process.arch}`], resolveBinary });
60
+ controller.signal.throwIfAborted();
61
+ if (options.json) console.log(JSON.stringify(result));
62
+ else if (result.message) console.log(result.message);
63
+ else if (result.updated) console.log(`VMG updated: ${result.currentVersion} → ${result.installedVersion}`);
64
+ else console.log(result.updateAvailable ? `VMG update available: ${result.currentVersion} → ${result.latestVersion}. Run vmg update.` : `VMG ${result.currentVersion} is up to date (latest: ${result.latestVersion}).`);
65
+ if (!result.ok) process.exitCode = 1;
66
+ } finally {
67
+ clearTimeout(timer); process.off('SIGINT', interrupt); process.off('SIGTERM', terminate);
68
+ }
69
+ return;
70
+ }
71
+ const binary = resolveBinary(packageJson);
72
+ const finished = launch(binary, args);
73
+ const timer = setTimeout(() => controller.abort(new Error('Update check timed out')), 3000);
74
+ const notification = shouldNotify(args) ? notify(packageJson, { ctx: context({ signal: controller.signal }) }) : Promise.resolve();
75
+ const signal = await finished;
76
+ controller.abort(new Error('VMG command finished')); clearTimeout(timer);
77
+ await notification;
78
+ if (signal) process.kill(process.pid, signal);
79
+ }
80
+
43
81
  if (process.argv[1] && pathToFileURL(realpathSync(resolve(process.argv[1]))).href === import.meta.url) {
44
- try { launch(resolveBinary(fileURLToPath(new URL('../package.json', import.meta.url))), process.argv.slice(2)); }
45
- catch (error) { console.error(`vmg: ${error.message}`); process.exitCode = 1; }
82
+ try { await main(process.argv.slice(2), fileURLToPath(new URL('../package.json', import.meta.url))); }
83
+ catch (error) {
84
+ if (process.argv.slice(2).includes('--json')) console.error(JSON.stringify({ error: error.message }));
85
+ else console.error(`vmg: ${error.message}`);
86
+ process.exitCode ||= 1;
87
+ }
46
88
  }
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.3",
2
+ "version": "0.0.5",
3
3
  "license": "Apache-2.0",
4
4
  "type": "module",
5
5
  "engines": {
@@ -15,10 +15,11 @@
15
15
  "vmg": "bin/vmg.mjs"
16
16
  },
17
17
  "optionalDependencies": {
18
- "@base_bit/vmg-cli-darwin-arm64": "0.0.3"
18
+ "@base_bit/vmg-cli-darwin-arm64": "0.0.5"
19
19
  },
20
20
  "files": [
21
21
  "bin/vmg.mjs",
22
+ "bin/npm-update.mjs",
22
23
  "README.md",
23
24
  "LICENSE",
24
25
  "NOTICE",