@polderlabs/bizar 3.12.1 → 3.12.3
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/cli/bin.mjs +101 -0
- package/cli/dev-link.mjs +175 -0
- package/cli/dev-link.test.mjs +297 -0
- package/cli/doctor.mjs +285 -0
- package/cli/doctor.test.mjs +337 -0
- package/cli/install.mjs +138 -12
- package/cli/install.test.mjs +246 -0
- package/cli/update.mjs +123 -33
- package/package.json +1 -1
package/cli/install.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import boxen from 'boxen';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, lstatSync, rmSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
|
|
6
6
|
import { showBanner, showPantheon, sectionHeading } from './banner.mjs';
|
|
@@ -26,31 +26,93 @@ const AGENT_FILES = [
|
|
|
26
26
|
* `opencodeConfigDir()`). Otherwise, prints a hint directing the user to run
|
|
27
27
|
* `npm install -g @polderlabs/bizar-plugin`.
|
|
28
28
|
*
|
|
29
|
-
*
|
|
29
|
+
* Symlink guard (v3.12.2): if the dest is a symlink (e.g. created by
|
|
30
|
+
* `bizar dev-link`), do NOT overwrite it. The copy below would otherwise
|
|
31
|
+
* dereference the symlink and silently turn it back into a real directory,
|
|
32
|
+
* breaking the dev workflow. Pass `{ force: true }` to override (after
|
|
33
|
+
* confirming the user really wants the deployed copy back).
|
|
34
|
+
*
|
|
35
|
+
* node_modules copy: the plugin imports `@polderlabs/bizar-sdk`, a
|
|
36
|
+
* workspace-internal package not on the public registry. The npm source
|
|
37
|
+
* bundles the SDK into its own `node_modules/`. After copying the plugin
|
|
38
|
+
* files, this function ALSO copies the source's `node_modules/` to the
|
|
39
|
+
* deployed `node_modules/` so Bun can resolve the import when the plugin
|
|
40
|
+
* is loaded from `~/.config/opencode/plugins/bizar/`. Without this, the
|
|
41
|
+
* plugin silently fails to load.
|
|
42
|
+
*
|
|
43
|
+
* Pass `{ silent: true }` to suppress the warning prints; the function
|
|
44
|
+
* still returns `true` if the dest is already in the desired state.
|
|
45
|
+
*
|
|
46
|
+
* Pass `{ sourceDir: '/path/to/fake' }` (test seam) to bypass the
|
|
47
|
+
* `npm root -g` lookup and use a caller-supplied source path. Used by
|
|
48
|
+
* cli/install.test.mjs to drive the function against a controlled
|
|
49
|
+
* filesystem without touching the real global npm root.
|
|
50
|
+
*
|
|
51
|
+
* Returns `true` if the plugin was installed (or already in place),
|
|
52
|
+
* `false` otherwise. Never throws.
|
|
30
53
|
*/
|
|
31
|
-
export async function installPluginFromGlobal() {
|
|
54
|
+
export async function installPluginFromGlobal(opts = {}) {
|
|
32
55
|
const { execSync } = await import('node:child_process');
|
|
33
56
|
const { mkdir, readdir, copyFile } = await import('node:fs/promises');
|
|
34
57
|
const { join } = await import('node:path');
|
|
35
58
|
|
|
36
|
-
|
|
59
|
+
// ── Symlink guard ───────────────────────────────────────────────────────────
|
|
60
|
+
// If dest is a symlink (created by `bizar dev-link`), don't overwrite it.
|
|
61
|
+
// A plain copy would dereference the symlink and silently break the
|
|
62
|
+
// dev workflow. Print a hint unless silent, or honour an explicit
|
|
63
|
+
// --force from the caller.
|
|
64
|
+
const destDir = join(opencodeConfigDir(), 'plugins', 'bizar');
|
|
65
|
+
let destIsSymlink = false;
|
|
37
66
|
try {
|
|
38
|
-
|
|
39
|
-
.toString()
|
|
40
|
-
.trim();
|
|
67
|
+
destIsSymlink = lstatSync(destDir).isSymbolicLink();
|
|
41
68
|
} catch {
|
|
42
|
-
|
|
43
|
-
return false;
|
|
69
|
+
// doesn't exist yet — fine
|
|
44
70
|
}
|
|
45
71
|
|
|
46
|
-
|
|
72
|
+
if (destIsSymlink) {
|
|
73
|
+
if (opts.silent) {
|
|
74
|
+
// Called from dev-unlink, which has already removed the symlink.
|
|
75
|
+
// If we somehow still see one here, treat as "already restored".
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
console.log(
|
|
79
|
+
chalk.yellow(
|
|
80
|
+
' ⚠ ~/.config/opencode/plugins/bizar is a dev symlink — skipping npm copy.',
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
console.log(
|
|
84
|
+
chalk.dim(
|
|
85
|
+
' Run `bizar dev-unlink` to restore the deployed copy, or pass --force to overwrite.',
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
if (!opts.force) return false;
|
|
89
|
+
rmSync(destDir, { force: true });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Resolve source path ─────────────────────────────────────────────────────
|
|
93
|
+
// Production: `npm root -g` + `@polderlabs/bizar-plugin`. Tests can pass
|
|
94
|
+
// `opts.sourceDir` to bypass the lookup entirely.
|
|
95
|
+
let pluginPath;
|
|
96
|
+
if (opts.sourceDir) {
|
|
97
|
+
pluginPath = opts.sourceDir;
|
|
98
|
+
} else {
|
|
99
|
+
let globalRoot;
|
|
100
|
+
try {
|
|
101
|
+
globalRoot = execSync('npm root -g', { stdio: ['ignore', 'pipe', 'ignore'] })
|
|
102
|
+
.toString()
|
|
103
|
+
.trim();
|
|
104
|
+
} catch {
|
|
105
|
+
console.log(chalk.yellow(' ⚠ Could not determine npm global root — skipping plugin install'));
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
pluginPath = join(globalRoot, '@polderlabs', 'bizar-plugin');
|
|
109
|
+
}
|
|
47
110
|
if (!existsSync(pluginPath)) {
|
|
48
111
|
console.log(chalk.dim(' ℹ Bizar plugin not installed globally. To install it:'));
|
|
49
112
|
console.log(chalk.dim(' npm install -g @polderlabs/bizar-plugin'));
|
|
50
113
|
return false;
|
|
51
114
|
}
|
|
52
115
|
|
|
53
|
-
const destDir = join(opencodeConfigDir(), 'plugins', 'bizar');
|
|
54
116
|
await mkdir(destDir, { recursive: true });
|
|
55
117
|
|
|
56
118
|
async function copyRecursive(srcDir, dstDir) {
|
|
@@ -73,11 +135,75 @@ export async function installPluginFromGlobal() {
|
|
|
73
135
|
await copyRecursive(pluginPath, destDir);
|
|
74
136
|
console.log(chalk.green(` ✓ Bizar plugin installed from global package`));
|
|
75
137
|
console.log(chalk.dim(` ${pluginPath} → ${destDir}`));
|
|
76
|
-
return true;
|
|
77
138
|
} catch (err) {
|
|
78
139
|
console.log(chalk.yellow(` ⚠ Failed to copy Bizar plugin: ${err.message}`));
|
|
79
140
|
return false;
|
|
80
141
|
}
|
|
142
|
+
|
|
143
|
+
// ── node_modules copy ───────────────────────────────────────────────────────
|
|
144
|
+
// The plugin imports `@polderlabs/bizar-sdk`, a workspace-internal package
|
|
145
|
+
// not on the public registry. The npm source bundles the SDK into its
|
|
146
|
+
// own `node_modules/`, so loading from `$(npm root -g)/...` resolves
|
|
147
|
+
// correctly. The deployed copy at `~/.config/opencode/plugins/bizar/`
|
|
148
|
+
// has no `node_modules`, so Bun can't resolve the import — without this
|
|
149
|
+
// copy the plugin silently fails to load. This used to be papered over
|
|
150
|
+
// by a manual `cp -r $(npm root -g)/.../node_modules ~/.config/...` step
|
|
151
|
+
// that got wiped every time `bizar update` re-ran the outer copy. Doing
|
|
152
|
+
// it here makes the fix durable across updates.
|
|
153
|
+
//
|
|
154
|
+
// Semantics: mirror `cp -r` — merge into dest if it exists as a real
|
|
155
|
+
// directory (overwrite same-named files, don't delete extras), error
|
|
156
|
+
// if dest is a symlink (don't dereference through it). Idempotent —
|
|
157
|
+
// running twice never breaks; files whose contents already match are
|
|
158
|
+
// rewritten byte-identically.
|
|
159
|
+
try {
|
|
160
|
+
const srcNmDir = join(pluginPath, 'node_modules');
|
|
161
|
+
if (existsSync(srcNmDir)) {
|
|
162
|
+
const dstNmDir = join(destDir, 'node_modules');
|
|
163
|
+
let dstIsSymlink = false;
|
|
164
|
+
try {
|
|
165
|
+
dstIsSymlink = lstatSync(dstNmDir).isSymbolicLink();
|
|
166
|
+
} catch {
|
|
167
|
+
// doesn't exist — fine, we'll create it below
|
|
168
|
+
}
|
|
169
|
+
if (dstIsSymlink) {
|
|
170
|
+
console.log(
|
|
171
|
+
chalk.yellow(
|
|
172
|
+
` ⚠ ${dstNmDir} is a symlink — refusing to copy node_modules (remove manually).`,
|
|
173
|
+
),
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
// Top-level entry count (matches `npm ls --depth=0`) — gives the
|
|
177
|
+
// user a sense of what was bundled. e.g. "copied node_modules
|
|
178
|
+
// (25 packages) to deployed plugin".
|
|
179
|
+
const srcEntries = await readdir(srcNmDir, { withFileTypes: true });
|
|
180
|
+
await mkdir(dstNmDir, { recursive: true });
|
|
181
|
+
async function copyNodeModules(srcDir, dstDir) {
|
|
182
|
+
const entries = await readdir(srcDir, { withFileTypes: true });
|
|
183
|
+
for (const entry of entries) {
|
|
184
|
+
const src = join(srcDir, entry.name);
|
|
185
|
+
const dst = join(dstDir, entry.name);
|
|
186
|
+
if (entry.isDirectory()) {
|
|
187
|
+
await mkdir(dst, { recursive: true });
|
|
188
|
+
await copyNodeModules(src, dst);
|
|
189
|
+
} else {
|
|
190
|
+
await copyFile(src, dst);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
await copyNodeModules(srcNmDir, dstNmDir);
|
|
195
|
+
console.log(
|
|
196
|
+
chalk.green(
|
|
197
|
+
` ✓ copied node_modules (${srcEntries.length} packages) to deployed plugin`,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.log(chalk.yellow(` ⚠ Failed to copy node_modules: ${err.message}`));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return true;
|
|
81
207
|
}
|
|
82
208
|
|
|
83
209
|
export async function runInstaller() {
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/install.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* Tests for `installPluginFromGlobal()` in cli/install.mjs — specifically
|
|
5
|
+
* the node_modules copy step added in the "make the plugin-install fix
|
|
6
|
+
* durable" change.
|
|
7
|
+
*
|
|
8
|
+
* Strategy: mock HOME so opencodeConfigDir() resolves inside a tmpdir,
|
|
9
|
+
* and pass `opts.sourceDir` to bypass the `npm root -g` lookup with a
|
|
10
|
+
* caller-supplied fake plugin source.
|
|
11
|
+
*
|
|
12
|
+
* Mirrors the HOME-mocking pattern used by cli/dev-link.test.mjs.
|
|
13
|
+
*/
|
|
14
|
+
import { test, describe, beforeEach, afterEach, after } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import {
|
|
17
|
+
mkdtempSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
existsSync,
|
|
22
|
+
lstatSync,
|
|
23
|
+
symlinkSync,
|
|
24
|
+
} from 'node:fs';
|
|
25
|
+
import { tmpdir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
|
|
28
|
+
const { installPluginFromGlobal } = await import('./install.mjs');
|
|
29
|
+
|
|
30
|
+
const ORIG_HOME = process.env.HOME;
|
|
31
|
+
const ORIG_XDG = process.env.XDG_CONFIG_HOME;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Point HOME at a fresh tmpdir so the module's opencodeConfigDir()
|
|
35
|
+
* resolves inside it (via the fallback `<HOME>/.config/opencode`).
|
|
36
|
+
* Returns the tmpdir path.
|
|
37
|
+
*
|
|
38
|
+
* We deliberately do NOT set XDG_CONFIG_HOME. opencodeConfigDir() treats
|
|
39
|
+
* a set XDG_CONFIG_HOME as the direct parent (so `~/.config` →
|
|
40
|
+
* `~/.config/opencode`); setting it to a raw tmpdir would produce
|
|
41
|
+
* `<tmpdir>/opencode` instead of `<tmpdir>/.config/opencode` and break
|
|
42
|
+
* path alignment with the rest of the test.
|
|
43
|
+
*/
|
|
44
|
+
function freshHome() {
|
|
45
|
+
const home = mkdtempSync(join(tmpdir(), 'bizar-install-'));
|
|
46
|
+
process.env.HOME = home;
|
|
47
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
48
|
+
return home;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Path to the deployed plugin dir under the mocked HOME. */
|
|
52
|
+
function pluginDest(home) {
|
|
53
|
+
return join(home, '.config', 'opencode', 'plugins', 'bizar');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Build a fake plugin source tree, optionally including a `node_modules/`
|
|
58
|
+
* directory with a couple of packages. Used to drive the function under
|
|
59
|
+
* test without touching the real global npm install.
|
|
60
|
+
*/
|
|
61
|
+
function makeFakeSource({ withNodeModules = true } = {}) {
|
|
62
|
+
const src = mkdtempSync(join(tmpdir(), 'fake-plugin-src-'));
|
|
63
|
+
writeFileSync(
|
|
64
|
+
join(src, 'package.json'),
|
|
65
|
+
JSON.stringify({
|
|
66
|
+
name: '@polderlabs/bizar-plugin',
|
|
67
|
+
version: '0.0.0-test',
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
writeFileSync(join(src, 'index.ts'), '// fake plugin');
|
|
71
|
+
if (withNodeModules) {
|
|
72
|
+
// Mimics the real npm install layout:
|
|
73
|
+
// node_modules/
|
|
74
|
+
// @polderlabs/bizar-sdk/package.json <-- the workspace-internal import
|
|
75
|
+
// some-dep/package.json <-- a transitive dep
|
|
76
|
+
// some-dep/lib/index.js <-- nested file
|
|
77
|
+
mkdirSync(join(src, 'node_modules'));
|
|
78
|
+
mkdirSync(join(src, 'node_modules', '@polderlabs'));
|
|
79
|
+
mkdirSync(join(src, 'node_modules', '@polderlabs', 'bizar-sdk'));
|
|
80
|
+
writeFileSync(
|
|
81
|
+
join(src, 'node_modules', '@polderlabs', 'bizar-sdk', 'package.json'),
|
|
82
|
+
JSON.stringify({ name: '@polderlabs/bizar-sdk', version: '1.0.0' }),
|
|
83
|
+
);
|
|
84
|
+
mkdirSync(join(src, 'node_modules', 'some-dep'));
|
|
85
|
+
writeFileSync(
|
|
86
|
+
join(src, 'node_modules', 'some-dep', 'package.json'),
|
|
87
|
+
JSON.stringify({ name: 'some-dep', version: '2.0.0' }),
|
|
88
|
+
);
|
|
89
|
+
mkdirSync(join(src, 'node_modules', 'some-dep', 'lib'));
|
|
90
|
+
writeFileSync(
|
|
91
|
+
join(src, 'node_modules', 'some-dep', 'lib', 'index.js'),
|
|
92
|
+
'module.exports = 1;',
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
return src;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Restore env at the very end (individual tests restore in afterEach). */
|
|
99
|
+
after(() => {
|
|
100
|
+
if (ORIG_HOME === undefined) delete process.env.HOME;
|
|
101
|
+
else process.env.HOME = ORIG_HOME;
|
|
102
|
+
if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
103
|
+
else process.env.XDG_CONFIG_HOME = ORIG_XDG;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
// ── installPluginFromGlobal — node_modules copy ───────────────────────────────
|
|
107
|
+
|
|
108
|
+
describe('installPluginFromGlobal() — node_modules copy', () => {
|
|
109
|
+
let home;
|
|
110
|
+
let sourceDir;
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
home = freshHome();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(() => {
|
|
117
|
+
if (home && existsSync(home)) rmSync(home, { recursive: true, force: true });
|
|
118
|
+
if (sourceDir && existsSync(sourceDir)) rmSync(sourceDir, { recursive: true, force: true });
|
|
119
|
+
if (ORIG_HOME === undefined) delete process.env.HOME;
|
|
120
|
+
else process.env.HOME = ORIG_HOME;
|
|
121
|
+
if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
|
|
122
|
+
else process.env.XDG_CONFIG_HOME = ORIG_XDG;
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('copies node_modules from source when present', async () => {
|
|
126
|
+
sourceDir = makeFakeSource({ withNodeModules: true });
|
|
127
|
+
const dest = pluginDest(home);
|
|
128
|
+
|
|
129
|
+
const ok = await installPluginFromGlobal({ sourceDir });
|
|
130
|
+
assert.equal(ok, true);
|
|
131
|
+
|
|
132
|
+
// Plugin file copied.
|
|
133
|
+
assert.equal(existsSync(join(dest, 'index.ts')), true);
|
|
134
|
+
assert.equal(existsSync(join(dest, 'package.json')), true);
|
|
135
|
+
|
|
136
|
+
// node_modules copied (the actual fix).
|
|
137
|
+
const destNm = join(dest, 'node_modules');
|
|
138
|
+
assert.equal(existsSync(destNm), true);
|
|
139
|
+
assert.equal(
|
|
140
|
+
existsSync(join(destNm, 'some-dep', 'package.json')),
|
|
141
|
+
true,
|
|
142
|
+
'top-level dep copied',
|
|
143
|
+
);
|
|
144
|
+
assert.equal(
|
|
145
|
+
existsSync(join(destNm, 'some-dep', 'lib', 'index.js')),
|
|
146
|
+
true,
|
|
147
|
+
'nested file inside top-level dep copied',
|
|
148
|
+
);
|
|
149
|
+
assert.equal(
|
|
150
|
+
existsSync(join(destNm, '@polderlabs', 'bizar-sdk', 'package.json')),
|
|
151
|
+
true,
|
|
152
|
+
'scoped @polderlabs/bizar-sdk copied (the actual bug fix)',
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('no-op for node_modules when source has none', async () => {
|
|
157
|
+
sourceDir = makeFakeSource({ withNodeModules: false });
|
|
158
|
+
const dest = pluginDest(home);
|
|
159
|
+
|
|
160
|
+
const ok = await installPluginFromGlobal({ sourceDir });
|
|
161
|
+
assert.equal(ok, true);
|
|
162
|
+
|
|
163
|
+
// Plugin file copied.
|
|
164
|
+
assert.equal(existsSync(join(dest, 'index.ts')), true);
|
|
165
|
+
|
|
166
|
+
// node_modules should NOT exist — we don't create empty dirs.
|
|
167
|
+
const destNm = join(dest, 'node_modules');
|
|
168
|
+
assert.equal(existsSync(destNm), false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('idempotent: running twice does not error and preserves extras', async () => {
|
|
172
|
+
sourceDir = makeFakeSource({ withNodeModules: true });
|
|
173
|
+
const dest = pluginDest(home);
|
|
174
|
+
|
|
175
|
+
const ok1 = await installPluginFromGlobal({ sourceDir });
|
|
176
|
+
assert.equal(ok1, true);
|
|
177
|
+
|
|
178
|
+
// Drop a marker that simulates a user adding their own package.
|
|
179
|
+
// After a second run, this marker must STILL be present (cp -r
|
|
180
|
+
// semantics — don't delete, just overwrite/add).
|
|
181
|
+
const destNm = join(dest, 'node_modules');
|
|
182
|
+
mkdirSync(join(destNm, 'user-added'), { recursive: true });
|
|
183
|
+
writeFileSync(join(destNm, 'user-added', 'marker.json'), '{}');
|
|
184
|
+
|
|
185
|
+
const ok2 = await installPluginFromGlobal({ sourceDir });
|
|
186
|
+
assert.equal(ok2, true, 'second call should still return true');
|
|
187
|
+
|
|
188
|
+
// User's marker is still there.
|
|
189
|
+
assert.equal(
|
|
190
|
+
existsSync(join(destNm, 'user-added', 'marker.json')),
|
|
191
|
+
true,
|
|
192
|
+
'user-added extras preserved across re-runs',
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
// And the npm-bundled contents are still there.
|
|
196
|
+
assert.equal(
|
|
197
|
+
existsSync(join(destNm, '@polderlabs', 'bizar-sdk', 'package.json')),
|
|
198
|
+
true,
|
|
199
|
+
'@polderlabs/bizar-sdk still present after second run',
|
|
200
|
+
);
|
|
201
|
+
assert.equal(
|
|
202
|
+
existsSync(join(destNm, 'some-dep', 'lib', 'index.js')),
|
|
203
|
+
true,
|
|
204
|
+
'nested file still present after second run',
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
test('refuses to overwrite a symlinked node_modules', async () => {
|
|
209
|
+
sourceDir = makeFakeSource({ withNodeModules: true });
|
|
210
|
+
const dest = pluginDest(home);
|
|
211
|
+
|
|
212
|
+
// Pre-create dest as a real dir (the outer copy needs a real dest)
|
|
213
|
+
// AND pre-create dest/node_modules as a symlink to some unrelated
|
|
214
|
+
// location. The fix must NOT dereference through the symlink and
|
|
215
|
+
// must leave it intact.
|
|
216
|
+
mkdirSync(dest, { recursive: true });
|
|
217
|
+
const linkTarget = mkdtempSync(join(tmpdir(), 'nm-link-target-'));
|
|
218
|
+
const destNm = join(dest, 'node_modules');
|
|
219
|
+
symlinkSync(linkTarget, destNm);
|
|
220
|
+
|
|
221
|
+
try {
|
|
222
|
+
const ok = await installPluginFromGlobal({ sourceDir });
|
|
223
|
+
// Outer install succeeds (the file copy still works — it doesn't
|
|
224
|
+
// touch node_modules at the dest).
|
|
225
|
+
assert.equal(ok, true);
|
|
226
|
+
|
|
227
|
+
// The symlink must still be a symlink — we refused to dereference.
|
|
228
|
+
assert.equal(
|
|
229
|
+
lstatSync(destNm).isSymbolicLink(),
|
|
230
|
+
true,
|
|
231
|
+
'node_modules symlink preserved (not dereferenced)',
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// SDK NOT copied into the link target (we skipped the copy).
|
|
235
|
+
assert.equal(
|
|
236
|
+
existsSync(join(linkTarget, '@polderlabs')),
|
|
237
|
+
false,
|
|
238
|
+
'refused copy did not leak into link target',
|
|
239
|
+
);
|
|
240
|
+
} finally {
|
|
241
|
+
rmSync(linkTarget, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
console.log(' install.mjs tests loaded — run with: node --test cli/install.test.mjs');
|
package/cli/update.mjs
CHANGED
|
@@ -233,9 +233,18 @@ function latestVersion(pkg) {
|
|
|
233
233
|
/**
|
|
234
234
|
* Update opencode. Tries `opencode upgrade` first (the upstream installer's
|
|
235
235
|
* own command); falls back to `npm install -g opencode-ai@latest`.
|
|
236
|
+
* Pass `{ dryRun: true }` to print what would run without executing.
|
|
236
237
|
* Returns `{ ok: boolean, message: string }`.
|
|
237
238
|
*/
|
|
238
|
-
function updateOpencode() {
|
|
239
|
+
function updateOpencode({ dryRun = false } = {}) {
|
|
240
|
+
if (dryRun) {
|
|
241
|
+
console.log(
|
|
242
|
+
chalk.dim(
|
|
243
|
+
' [dry-run] would run: opencode upgrade (fallback: npm install -g opencode-ai@latest)',
|
|
244
|
+
),
|
|
245
|
+
);
|
|
246
|
+
return { ok: true, message: '[dry-run] opencode' };
|
|
247
|
+
}
|
|
239
248
|
const r1 = spawnSync('opencode', ['upgrade'], { stdio: 'inherit' });
|
|
240
249
|
if (r1.status === 0) {
|
|
241
250
|
return { ok: true, message: 'opencode updated via `opencode upgrade`' };
|
|
@@ -248,7 +257,13 @@ function updateOpencode() {
|
|
|
248
257
|
return { ok: false, message: 'opencode update failed — try `opencode upgrade` manually' };
|
|
249
258
|
}
|
|
250
259
|
|
|
251
|
-
function updatePackage(pkg) {
|
|
260
|
+
function updatePackage(pkg, { dryRun = false } = {}) {
|
|
261
|
+
if (dryRun) {
|
|
262
|
+
console.log(
|
|
263
|
+
chalk.dim(` [dry-run] would run: npm install -g ${pkg}@latest`),
|
|
264
|
+
);
|
|
265
|
+
return { ok: true, message: `[dry-run] ${pkg}` };
|
|
266
|
+
}
|
|
252
267
|
const r = spawnSync('npm', ['install', '-g', `${pkg}@latest`], { stdio: 'inherit' });
|
|
253
268
|
if (r.status !== 0) {
|
|
254
269
|
return { ok: false, message: `${pkg} update failed` };
|
|
@@ -467,27 +482,43 @@ export async function runUpdate(subargs = []) {
|
|
|
467
482
|
const assumeYes = subargs.includes('--yes') || subargs.includes('-y') || subargs.includes('--force');
|
|
468
483
|
const restartAfter = !subargs.includes('--no-restart');
|
|
469
484
|
const forceAll = subargs.includes('--all');
|
|
485
|
+
const dryRun = subargs.includes('--dry-run');
|
|
486
|
+
|
|
487
|
+
if (dryRun) {
|
|
488
|
+
console.log(
|
|
489
|
+
chalk.dim(' --dry-run set: no installs, kills, or restarts will be performed.\n'),
|
|
490
|
+
);
|
|
491
|
+
}
|
|
470
492
|
|
|
471
493
|
// 1. Detect running instances BEFORE doing anything else.
|
|
472
494
|
const instances = detectInstances();
|
|
473
495
|
const runningCount = (instances.service ? 1 : 0) + (instances.dashboard ? 1 : 0);
|
|
474
496
|
|
|
475
497
|
if (runningCount > 0) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
498
|
+
if (dryRun) {
|
|
499
|
+
const labels = [];
|
|
500
|
+
if (instances.service) labels.push(instances.service.label);
|
|
501
|
+
if (instances.dashboard) labels.push(instances.dashboard.label);
|
|
502
|
+
console.log(
|
|
503
|
+
chalk.dim(` [dry-run] would stop running instances: ${labels.join(', ')}`),
|
|
504
|
+
);
|
|
505
|
+
} else {
|
|
506
|
+
const ok = await confirmKill(instances, { assumeYes });
|
|
507
|
+
if (!ok) {
|
|
508
|
+
console.log(chalk.yellow('\n Update cancelled — instances still running.'));
|
|
509
|
+
console.log(chalk.dim(' Stop them with `bizar service stop` and `bizar dashboard stop`, then retry.'));
|
|
510
|
+
process.exit(1);
|
|
511
|
+
}
|
|
512
|
+
console.log(chalk.cyan('\n Stopping running instances...'));
|
|
513
|
+
const kills = await killInstances(instances);
|
|
514
|
+
for (const k of kills) {
|
|
515
|
+
const marker = k.ok ? chalk.green('✓') : chalk.red('✗');
|
|
516
|
+
console.log(` ${marker} ${k.name} stopped`);
|
|
517
|
+
}
|
|
518
|
+
// Give the kernel a moment to release any open file handles on the
|
|
519
|
+
// npm-global directory before npm tries to replace files.
|
|
520
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
487
521
|
}
|
|
488
|
-
// Give the kernel a moment to release any open file handles on the
|
|
489
|
-
// npm-global directory before npm tries to replace files.
|
|
490
|
-
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
491
522
|
} else {
|
|
492
523
|
console.log(chalk.dim(' No running Bizar instances detected.'));
|
|
493
524
|
}
|
|
@@ -523,8 +554,19 @@ export async function runUpdate(subargs = []) {
|
|
|
523
554
|
|
|
524
555
|
// 3. Decide what to update.
|
|
525
556
|
let selected;
|
|
526
|
-
if (forceAll || assumeYes) {
|
|
557
|
+
if (forceAll || assumeYes || dryRun) {
|
|
558
|
+
// dryRun defaults to showing everything (the whole point is to see
|
|
559
|
+
// what *would* change across the board). Pass a positional list to
|
|
560
|
+
// narrow the preview.
|
|
527
561
|
selected = new Set(COMPONENTS);
|
|
562
|
+
// If the user passed positional component names alongside --dry-run,
|
|
563
|
+
// narrow the selection to those.
|
|
564
|
+
const positional = subargs.filter((a) => !a.startsWith('-'));
|
|
565
|
+
if (positional.length > 0) {
|
|
566
|
+
const valid = new Set(COMPONENTS);
|
|
567
|
+
const narrowed = positional.filter((a) => valid.has(a));
|
|
568
|
+
if (narrowed.length > 0) selected = new Set(narrowed);
|
|
569
|
+
}
|
|
528
570
|
} else if (subargs.length === 0) {
|
|
529
571
|
selected = await promptForUpdates(false);
|
|
530
572
|
} else {
|
|
@@ -548,44 +590,57 @@ export async function runUpdate(subargs = []) {
|
|
|
548
590
|
const results = [];
|
|
549
591
|
if (selected.has('opencode')) {
|
|
550
592
|
console.log(chalk.bold(' → opencode'));
|
|
551
|
-
results.push(['opencode', updateOpencode()]);
|
|
593
|
+
results.push(['opencode', updateOpencode({ dryRun })]);
|
|
552
594
|
}
|
|
553
595
|
if (selected.has('bizar')) {
|
|
554
596
|
console.log(chalk.bold(` → ${PKG_MAIN}`));
|
|
555
|
-
results.push(['bizar', updatePackage(PKG_MAIN)]);
|
|
597
|
+
results.push(['bizar', updatePackage(PKG_MAIN, { dryRun })]);
|
|
556
598
|
}
|
|
557
599
|
if (selected.has('dash')) {
|
|
558
600
|
console.log(chalk.bold(` → ${PKG_DASH}`));
|
|
559
|
-
results.push(['dash', updatePackage(PKG_DASH)]);
|
|
601
|
+
results.push(['dash', updatePackage(PKG_DASH, { dryRun })]);
|
|
560
602
|
}
|
|
561
603
|
if (selected.has('plugin')) {
|
|
562
604
|
console.log(chalk.bold(` → ${PKG_PLUGIN}`));
|
|
563
|
-
results.push(['plugin', updatePackage(PKG_PLUGIN)]);
|
|
605
|
+
results.push(['plugin', updatePackage(PKG_PLUGIN, { dryRun })]);
|
|
564
606
|
}
|
|
565
607
|
|
|
566
608
|
// 4. Re-run the install script if anything relevant changed.
|
|
567
609
|
const anySuccess = results.some(([, r]) => r.ok);
|
|
568
610
|
if (anySuccess && (selected.has('bizar') || selected.has('plugin'))) {
|
|
569
611
|
console.log('');
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
612
|
+
if (dryRun) {
|
|
613
|
+
console.log(
|
|
614
|
+
chalk.dim(
|
|
615
|
+
' [dry-run] would re-run install script (bin.mjs --setup or install.sh)',
|
|
616
|
+
),
|
|
617
|
+
);
|
|
573
618
|
} else {
|
|
574
|
-
|
|
575
|
-
|
|
619
|
+
const rerun = rerunInstallScript();
|
|
620
|
+
if (rerun.ok) {
|
|
621
|
+
console.log(chalk.green(`\n ✓ ${rerun.message}`));
|
|
622
|
+
} else {
|
|
623
|
+
console.log(chalk.yellow(`\n ⚠ ${rerun.message}`));
|
|
624
|
+
console.log(chalk.dim(' Run `bash install.sh` from the Bizar repo manually.'));
|
|
625
|
+
}
|
|
576
626
|
}
|
|
577
627
|
}
|
|
578
628
|
|
|
579
629
|
// 5. Restart the dashboard if it was running before the update.
|
|
580
630
|
if (restartAfter && instances.dashboard && (selected.has('bizar') || selected.has('dash'))) {
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
if (res.ok) {
|
|
585
|
-
console.log(chalk.green(` ✓ ${res.message}`));
|
|
631
|
+
if (dryRun) {
|
|
632
|
+
console.log('');
|
|
633
|
+
console.log(chalk.dim(' [dry-run] would restart dashboard with the new code'));
|
|
586
634
|
} else {
|
|
587
|
-
console.log(
|
|
588
|
-
console.log(chalk.
|
|
635
|
+
console.log('');
|
|
636
|
+
console.log(chalk.cyan(' Restarting dashboard with the new code...'));
|
|
637
|
+
const res = spawnFreshDashboard({ port: instances.dashboard.port || undefined });
|
|
638
|
+
if (res.ok) {
|
|
639
|
+
console.log(chalk.green(` ✓ ${res.message}`));
|
|
640
|
+
} else {
|
|
641
|
+
console.log(chalk.yellow(` ⚠ ${res.message}`));
|
|
642
|
+
console.log(chalk.dim(' Start it manually with `bizar dash start --bg`.'));
|
|
643
|
+
}
|
|
589
644
|
}
|
|
590
645
|
}
|
|
591
646
|
|
|
@@ -602,5 +657,40 @@ export async function runUpdate(subargs = []) {
|
|
|
602
657
|
console.log(chalk.yellow('\n Some updates failed. See messages above.'));
|
|
603
658
|
process.exit(1);
|
|
604
659
|
}
|
|
660
|
+
if (dryRun) {
|
|
661
|
+
console.log(
|
|
662
|
+
chalk.green('\n ✓ Dry-run complete (no installs, kills, or restarts performed)\n'),
|
|
663
|
+
);
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
605
666
|
console.log(chalk.green('\n ✓ Update complete\n'));
|
|
667
|
+
|
|
668
|
+
// 7. Post-update health check (v3.12.2). Catches a bad config merge or
|
|
669
|
+
// missing files before the user discovers it via a broken opencode session.
|
|
670
|
+
try {
|
|
671
|
+
const { runDoctor } = await import('./doctor.mjs');
|
|
672
|
+
const result = await runDoctor({ silent: true });
|
|
673
|
+
if (result.failed > 0) {
|
|
674
|
+
console.log('');
|
|
675
|
+
console.log(
|
|
676
|
+
chalk.yellow(' ⚠ Post-update health check found issues:'),
|
|
677
|
+
);
|
|
678
|
+
for (const r of result.results) {
|
|
679
|
+
if (!r.ok) {
|
|
680
|
+
console.log(chalk.red(` ✗ ${r.name}: ${r.message}`));
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
console.log(chalk.dim(' Run `bizar doctor` for details.'));
|
|
684
|
+
process.exit(1);
|
|
685
|
+
}
|
|
686
|
+
} catch (err) {
|
|
687
|
+
// Doctor import or runtime failure shouldn't crash the update —
|
|
688
|
+
// log a hint and let the user run `bizar doctor` themselves.
|
|
689
|
+
console.log(
|
|
690
|
+
chalk.yellow(
|
|
691
|
+
` ⚠ Post-update health check could not run: ${err.message}`,
|
|
692
|
+
),
|
|
693
|
+
);
|
|
694
|
+
console.log(chalk.dim(' Run `bizar doctor` manually to verify the install.'));
|
|
695
|
+
}
|
|
606
696
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar",
|
|
3
|
-
"version": "3.12.
|
|
3
|
+
"version": "3.12.3",
|
|
4
4
|
"description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|