@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 CHANGED
@@ -88,6 +88,9 @@ function showHelp() {
88
88
  service Manage the background service daemon
89
89
  bg <subcommand> Manage background agents (list/view/kill/logs)
90
90
  dash <subcommand> Manage the dashboard (start/stop/status/tui)
91
+ dev-link [src] Symlink the local plugin source into opencode's plugin dir
92
+ dev-unlink Remove the dev symlink and restore the deployed copy
93
+ doctor Check the BizarHarness install for health issues
91
94
 
92
95
  Examples:
93
96
  bizar install
@@ -96,6 +99,8 @@ function showHelp() {
96
99
  bizar dash start --bg
97
100
  bizar dash stop
98
101
  bizar dash status
102
+ bizar doctor
103
+ bizar update --all --dry-run
99
104
 
100
105
  Run \`bizar <command> --help\` for per-command help.
101
106
 
@@ -185,6 +190,7 @@ function showUpdateHelp() {
185
190
  bizar update opencode bizar dash Update specific components
186
191
  bizar update --all --yes Update everything + auto-kill running instances
187
192
  bizar update --no-restart Don't auto-restart the dashboard after update
193
+ bizar update --dry-run Print what would happen, change nothing
188
194
  bizar update --help Show this help
189
195
 
190
196
  Components:
@@ -206,11 +212,17 @@ function showUpdateHelp() {
206
212
  • If the dashboard was running and bizar or dash was updated,
207
213
  spawns a fresh detached dashboard process with the new code
208
214
  (skipped with --no-restart).
215
+ • Runs \`bizar doctor\` after a successful update to catch config
216
+ regressions before opencode tries to start.
217
+ • If ~/.config/opencode/plugins/bizar is a dev symlink (created
218
+ by \`bizar dev-link\`), the copy step is skipped to preserve the
219
+ link. Use \`bizar dev-unlink\` first, or pass --force.
209
220
 
210
221
  Examples:
211
222
  bizar update --all --yes Headless full update + restart
212
223
  bizar update dash Update only the dashboard
213
224
  bizar update plugin --no-restart Plugin-only, leave dashboard alone
225
+ bizar update --all --dry-run See what would change without doing it
214
226
  `);
215
227
  }
216
228
 
@@ -220,6 +232,69 @@ function showTestGateHelp() {
220
232
  `);
221
233
  }
222
234
 
235
+ function showDevLinkHelp() {
236
+ console.log(`
237
+ bizar dev-link / dev-unlink — Manage a symlink from the opencode plugin dir
238
+ to a local source checkout, so edits propagate to opencode on next session.
239
+
240
+ Usage:
241
+ bizar dev-link [source-dir] Symlink source-dir (default: ./plugins/bizar)
242
+ to ~/.config/opencode/plugins/bizar
243
+ bizar dev-link --force Replace an existing deployed copy
244
+ bizar dev-unlink Remove the dev symlink + restore from npm
245
+ bizar dev-unlink --force Remove even if not a symlink (destructive)
246
+
247
+ Description:
248
+ By default, opencode loads the Bizar plugin from
249
+ ~/.config/opencode/plugins/bizar, which is a real directory copied
250
+ from the npm package. Edits to plugins/bizar/ in the BizarHarness
251
+ repo don't propagate until you re-run the installer.
252
+
253
+ \`bizar dev-link\` replaces that directory with a symlink pointing
254
+ at your local checkout, so source edits are picked up immediately.
255
+ \`bizar dev-unlink\` reverses the change by removing the symlink
256
+ and re-installing the deployed copy from the npm package.
257
+
258
+ While the dev link is in place, \`bizar update\` will skip the
259
+ plugin-copy step (and print a warning) so it doesn't clobber the
260
+ link. Run \`bizar dev-unlink\` first, or pass --force to overwrite.
261
+
262
+ Examples:
263
+ bizar dev-link
264
+ bizar dev-link /home/me/projects/bizar/plugins/bizar
265
+ bizar dev-link --force
266
+ bizar dev-unlink
267
+ `);
268
+ }
269
+
270
+ function showDoctorHelp() {
271
+ console.log(`
272
+ bizar doctor — Check the BizarHarness install for health issues
273
+
274
+ Usage:
275
+ bizar doctor
276
+
277
+ Description:
278
+ Runs a battery of health checks against the local install:
279
+ • opencode CLI reachable
280
+ • ~/.config/opencode/opencode.json parses as JSON
281
+ • the Bizar plugin is registered
282
+ • plugin path resolves
283
+ • @polderlabs/bizar-plugin is installed globally
284
+ • core agent files are installed (odin, quick, thor, tyr)
285
+ • rtk / semble / skills on PATH (lenient — at least one)
286
+ • dashboard reachable (skipped if no port file)
287
+ • provider.openrouter block + MiniMax model flags are sane
288
+
289
+ Prints ✓/✗ for each check and a final summary. Exits non-zero
290
+ if any check fails. Use \`bizar doctor\` after a manual config
291
+ edit or to diagnose "why is opencode misbehaving?" questions.
292
+
293
+ Related:
294
+ bizar update Update + auto-run doctor on success
295
+ `);
296
+ }
297
+
223
298
  function showServiceHelp() {
224
299
  const bizarConfigDir = getBizarConfigDir();
225
300
  console.log(`
@@ -424,6 +499,32 @@ async function main() {
424
499
  } else if (args[0] === 'update') {
425
500
  if (isHelpRequest) showUpdateHelp();
426
501
  else await runUpdate(args.slice(1));
502
+ } else if (args[0] === 'dev-link') {
503
+ if (isHelpRequest) showDevLinkHelp();
504
+ else {
505
+ const { createDevLink } = await import('./dev-link.mjs');
506
+ const positional = args.slice(1).filter((a) => !a.startsWith('-'));
507
+ const flags = args.slice(1).filter((a) => a.startsWith('-'));
508
+ const sourceDir = positional[0] ?? null;
509
+ const force = flags.includes('--force') || flags.includes('-f');
510
+ const ok = createDevLink(sourceDir, { force });
511
+ if (!ok) process.exit(1);
512
+ }
513
+ } else if (args[0] === 'dev-unlink') {
514
+ if (isHelpRequest) showDevLinkHelp();
515
+ else {
516
+ const { removeDevLink } = await import('./dev-link.mjs');
517
+ const force = args.includes('--force') || args.includes('-f');
518
+ const ok = await removeDevLink({ force });
519
+ if (!ok) process.exit(1);
520
+ }
521
+ } else if (args[0] === 'doctor') {
522
+ if (isHelpRequest) showDoctorHelp();
523
+ else {
524
+ const { runDoctor } = await import('./doctor.mjs');
525
+ const result = await runDoctor();
526
+ if (result.failed > 0) process.exit(1);
527
+ }
427
528
  } else if (args[0] === 'plan') {
428
529
  await runPlan(args.slice(1), {});
429
530
  } else if (args[0] === 'install') {
@@ -0,0 +1,175 @@
1
+ /**
2
+ * cli/dev-link.mjs
3
+ *
4
+ * v3.12.2 — `bizar dev-link` / `bizar dev-unlink` subcommands.
5
+ *
6
+ * The Bizar opencode plugin lives at:
7
+ * ~/.config/opencode/plugins/bizar (the deployed copy, written by installPluginFromGlobal)
8
+ *
9
+ * When developing the plugin source under <repo>/plugins/bizar/, edits
10
+ * don't auto-propagate to opencode because the deployed copy is a real
11
+ * directory. `bizar dev-link` creates a symlink so that source edits
12
+ * are picked up on the next opencode session. `bizar dev-unlink`
13
+ * restores the deployed copy from the npm package.
14
+ *
15
+ * Symlink guard: installPluginFromGlobal refuses to overwrite a
16
+ * symlink (the underlying `cp -r` would dereference it and silently
17
+ * break the dev workflow). Combined with that guard, `bizar update` is
18
+ * safe to run while a dev link is in place — it prints a warning and
19
+ * leaves the link alone (use `--force` or `bizar dev-unlink` first).
20
+ */
21
+ import chalk from 'chalk';
22
+ import {
23
+ lstatSync,
24
+ rmSync,
25
+ symlinkSync,
26
+ existsSync,
27
+ mkdirSync,
28
+ readFileSync,
29
+ } from 'node:fs';
30
+ import { join, resolve } from 'node:path';
31
+ import { execSync } from 'node:child_process';
32
+ import { opencodeConfigDir } from './utils.mjs';
33
+
34
+ /**
35
+ * Path of the deployed plugin. Reads XDG_CONFIG_HOME / HOME at call
36
+ * time so tests can mock HOME.
37
+ */
38
+ function pluginDest() {
39
+ return join(opencodeConfigDir(), 'plugins', 'bizar');
40
+ }
41
+
42
+ /**
43
+ * Create a symlink from ~/.config/opencode/plugins/bizar to the
44
+ * local source dir (default: <cwd>/plugins/bizar).
45
+ *
46
+ * Behavior:
47
+ * - dest does not exist → create symlink
48
+ * - dest is a symlink → replace it
49
+ * - dest is a real directory → refuse unless opts.force
50
+ *
51
+ * Returns true on success, false on refusal.
52
+ */
53
+ export function createDevLink(sourceDir = null, opts = {}) {
54
+ const resolvedSource = resolve(process.cwd(), sourceDir ?? 'plugins/bizar');
55
+ const dest = pluginDest();
56
+ const parentDir = join(dest, '..');
57
+
58
+ // Make sure ~/.config/opencode/plugins/ exists.
59
+ if (!existsSync(parentDir)) {
60
+ mkdirSync(parentDir, { recursive: true });
61
+ }
62
+
63
+ // Probe existing dest state. Use lstat so we see the symlink itself,
64
+ // not its target.
65
+ let existing = null;
66
+ let isSymlink = false;
67
+ try {
68
+ existing = lstatSync(dest);
69
+ isSymlink = existing.isSymbolicLink();
70
+ } catch {
71
+ // doesn't exist — we will create.
72
+ }
73
+
74
+ if (existing && !isSymlink) {
75
+ if (!opts.force) {
76
+ console.log(chalk.yellow(` ⚠ ${dest} exists and is not a symlink.`));
77
+ console.log(chalk.yellow(` Refusing to overwrite the deployed copy.`));
78
+ console.log(
79
+ chalk.dim(
80
+ ` Use --force to replace it, or remove it manually before running \`bizar dev-link\`.`,
81
+ ),
82
+ );
83
+ return false;
84
+ }
85
+ console.log(chalk.yellow(` ⚠ Replacing existing deployed copy at ${dest}`));
86
+ rmSync(dest, { recursive: true, force: true });
87
+ }
88
+
89
+ if (isSymlink) {
90
+ // Existing symlink (possibly stale) — remove before re-creating.
91
+ rmSync(dest, { force: true });
92
+ }
93
+
94
+ symlinkSync(resolvedSource, dest);
95
+ console.log(
96
+ chalk.green(` ✓ dev link created: ${dest} → ${resolvedSource}`),
97
+ );
98
+ console.log(
99
+ chalk.dim(
100
+ ` Edits to the source dir will now propagate to opencode on next session.`,
101
+ ),
102
+ );
103
+ return true;
104
+ }
105
+
106
+ /**
107
+ * Remove the dev symlink and restore the deployed plugin from npm.
108
+ * Refuses to remove a real directory (would destroy the deployed copy)
109
+ * unless opts.force is set.
110
+ *
111
+ * Returns true on success, false on refusal or error.
112
+ */
113
+ export async function removeDevLink(opts = {}) {
114
+ const dest = pluginDest();
115
+ let isSymlink = false;
116
+ let exists = false;
117
+ try {
118
+ const st = lstatSync(dest);
119
+ exists = true;
120
+ isSymlink = st.isSymbolicLink();
121
+ } catch {
122
+ // doesn't exist
123
+ }
124
+
125
+ if (!exists) {
126
+ console.log(chalk.red(` ✗ ${dest} does not exist — nothing to unlink.`));
127
+ return false;
128
+ }
129
+
130
+ if (!isSymlink) {
131
+ if (!opts.force) {
132
+ console.log(
133
+ chalk.red(
134
+ ` ✗ ${dest} is not a symlink. Refusing to remove a real directory.`,
135
+ ),
136
+ );
137
+ console.log(chalk.dim(` Use --force if you really mean it.`));
138
+ return false;
139
+ }
140
+ console.log(chalk.yellow(` ⚠ Force-removing real directory at ${dest}`));
141
+ rmSync(dest, { recursive: true, force: true });
142
+ } else {
143
+ rmSync(dest, { force: true });
144
+ }
145
+
146
+ // Restore from npm. installPluginFromGlobal is silent because we
147
+ // already announced the unlink above; it will print its own brief
148
+ // success message on the next line if a real copy was installed.
149
+ try {
150
+ const { installPluginFromGlobal } = await import('./install.mjs');
151
+ await installPluginFromGlobal({ silent: true });
152
+ } catch (err) {
153
+ console.log(chalk.yellow(` ⚠ restore from npm failed: ${err.message}`));
154
+ }
155
+
156
+ // Read the installed version for the success message.
157
+ let version = 'unknown';
158
+ try {
159
+ const root = execSync('npm root -g', { encoding: 'utf8', timeout: 5000 }).trim();
160
+ const pkgPath = join(root, '@polderlabs', 'bizar-plugin', 'package.json');
161
+ if (existsSync(pkgPath)) {
162
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
163
+ version = pkg.version ?? 'unknown';
164
+ }
165
+ } catch {
166
+ // informational only
167
+ }
168
+
169
+ console.log(
170
+ chalk.green(
171
+ ` ✓ dev link removed; deployed plugin restored from @polderlabs/bizar-plugin@${version}`,
172
+ ),
173
+ );
174
+ return true;
175
+ }
@@ -0,0 +1,297 @@
1
+ /**
2
+ * cli/dev-link.test.mjs
3
+ *
4
+ * Tests for the `bizar dev-link` / `bizar dev-unlink` subcommands.
5
+ * Uses Node's built-in node:test (no external test framework).
6
+ *
7
+ * Strategy: mock HOME (and XDG_CONFIG_HOME) so opencodeConfigDir() in
8
+ * cli/utils.mjs returns a path inside a tmpdir, and exercise the
9
+ * create/remove symlink behavior against a controlled filesystem.
10
+ *
11
+ * Note: We mock HOME *before* importing dev-link.mjs because the module
12
+ * imports opencodeConfigDir transitively from utils.mjs. The path is
13
+ * resolved at call time, though, so the mock just needs to be in place
14
+ * when the functions run.
15
+ */
16
+ import { test, describe, before, after, beforeEach, afterEach } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import {
19
+ mkdtempSync,
20
+ mkdirSync,
21
+ writeFileSync,
22
+ rmSync,
23
+ symlinkSync,
24
+ lstatSync,
25
+ existsSync,
26
+ readlinkSync,
27
+ } from 'node:fs';
28
+ import { tmpdir } from 'node:os';
29
+ import { join, dirname } from 'node:path';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const __dirname = dirname(fileURLToPath(import.meta.url));
33
+ const PROJECT_ROOT = join(__dirname, '..');
34
+
35
+ const { createDevLink, removeDevLink } = await import('./dev-link.mjs');
36
+
37
+ // ── HOME mocking ────────────────────────────────────────────────────────────
38
+
39
+ const ORIG_HOME = process.env.HOME;
40
+ const ORIG_XDG = process.env.XDG_CONFIG_HOME;
41
+
42
+ /**
43
+ * Point HOME at a fresh tmpdir so the module's opencodeConfigDir()
44
+ * resolves inside it (via the fallback `<HOME>/.config/opencode`).
45
+ * Returns the tmpdir path.
46
+ *
47
+ * Note: we deliberately do NOT set XDG_CONFIG_HOME here. The
48
+ * opencodeConfigDir() helper treats a set XDG_CONFIG_HOME as the
49
+ * direct parent (so `XDG_CONFIG_HOME=~/.config` → `~/.config/opencode`,
50
+ * matching the standard layout). Setting it to a raw tmpdir would
51
+ * produce `<tmpdir>/opencode` instead of the expected
52
+ * `<tmpdir>/.config/opencode` and break path alignment with the test.
53
+ */
54
+ function freshHome() {
55
+ const home = mkdtempSync(join(tmpdir(), 'bizar-devlink-'));
56
+ process.env.HOME = home;
57
+ delete process.env.XDG_CONFIG_HOME;
58
+ return home;
59
+ }
60
+
61
+ /** Path to the deployed plugin dir under the mocked HOME. */
62
+ function pluginDest(home) {
63
+ return join(home, '.config', 'opencode', 'plugins', 'bizar');
64
+ }
65
+
66
+ after(() => {
67
+ // Restore env at the very end (individual tests have already done this).
68
+ if (ORIG_HOME === undefined) delete process.env.HOME;
69
+ else process.env.HOME = ORIG_HOME;
70
+ if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
71
+ else process.env.XDG_CONFIG_HOME = ORIG_XDG;
72
+ });
73
+
74
+ // ── createDevLink ───────────────────────────────────────────────────────────
75
+
76
+ describe('createDevLink()', () => {
77
+ let home;
78
+ let sourceDir;
79
+
80
+ beforeEach(() => {
81
+ home = freshHome();
82
+ // The "source" can be any directory — it doesn't need real plugin
83
+ // contents for the symlink test. Use the actual repo plugins/bizar
84
+ // so the test doubles as a smoke test of the real path.
85
+ sourceDir = join(PROJECT_ROOT, 'plugins', 'bizar');
86
+ });
87
+
88
+ afterEach(() => {
89
+ if (home && existsSync(home)) rmSync(home, { recursive: true, force: true });
90
+ if (ORIG_HOME === undefined) delete process.env.HOME;
91
+ else process.env.HOME = ORIG_HOME;
92
+ if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
93
+ else process.env.XDG_CONFIG_HOME = ORIG_XDG;
94
+ });
95
+
96
+ test('creates a symlink when dest does not exist', () => {
97
+ const dest = pluginDest(home);
98
+ assert.equal(existsSync(dest), false, 'precondition: dest should not exist');
99
+
100
+ const ok = createDevLink(sourceDir);
101
+ assert.equal(ok, true);
102
+ assert.equal(existsSync(dest), true);
103
+ const st = lstatSync(dest);
104
+ assert.equal(st.isSymbolicLink(), true, 'dest should be a symlink');
105
+ });
106
+
107
+ test('refuses to overwrite a real directory without --force', () => {
108
+ const dest = pluginDest(home);
109
+ mkdirSync(dirname(dest), { recursive: true });
110
+ mkdirSync(dest, { recursive: true });
111
+ writeFileSync(join(dest, 'index.ts'), 'not a symlink');
112
+
113
+ const ok = createDevLink(sourceDir);
114
+ assert.equal(ok, false, 'should refuse without force');
115
+ assert.equal(existsSync(dest), true);
116
+ const st = lstatSync(dest);
117
+ assert.equal(st.isSymbolicLink(), false, 'dest should still be a real dir');
118
+ assert.equal(existsSync(join(dest, 'index.ts')), true, 'contents preserved');
119
+ });
120
+
121
+ test('overwrites a real directory with opts.force=true', () => {
122
+ const dest = pluginDest(home);
123
+ mkdirSync(dirname(dest), { recursive: true });
124
+ mkdirSync(dest, { recursive: true });
125
+ writeFileSync(join(dest, 'index.ts'), 'not a symlink');
126
+
127
+ const ok = createDevLink(sourceDir, { force: true });
128
+ assert.equal(ok, true);
129
+ const st = lstatSync(dest);
130
+ assert.equal(st.isSymbolicLink(), true, 'dest should now be a symlink');
131
+ // Confirm the symlink points at the requested source. We can't
132
+ // look up `dest/index.ts` to verify the old contents are gone —
133
+ // that path now resolves through the symlink to the source's own
134
+ // index.ts, which may exist. Instead, verify the link target.
135
+ const linkTarget = readlinkSync(dest);
136
+ assert.equal(linkTarget, sourceDir);
137
+ // And confirm the previously-existing real directory is no longer
138
+ // present as a real directory at `dest`.
139
+ assert.equal(st.isDirectory(), false, 'dest should no longer be a directory');
140
+ });
141
+
142
+ test('replaces an existing symlink', () => {
143
+ const dest = pluginDest(home);
144
+ mkdirSync(dirname(dest), { recursive: true });
145
+
146
+ const otherTarget = join(home, 'other-source');
147
+ mkdirSync(otherTarget, { recursive: true });
148
+ symlinkSync(otherTarget, dest);
149
+
150
+ const ok = createDevLink(sourceDir);
151
+ assert.equal(ok, true);
152
+ const st = lstatSync(dest);
153
+ assert.equal(st.isSymbolicLink(), true);
154
+ // Confirm the symlink now points at the requested source.
155
+ const linkTarget = readlinkSync(dest);
156
+ assert.equal(linkTarget, sourceDir);
157
+ });
158
+
159
+ test('resolves a relative source dir against cwd', () => {
160
+ const dest = pluginDest(home);
161
+ const ok = createDevLink('plugins/bizar');
162
+ assert.equal(ok, true);
163
+ const linkTarget = readlinkSync(dest);
164
+ // The resolved path should be an absolute path under PROJECT_ROOT.
165
+ assert.ok(linkTarget.startsWith('/'), `expected absolute path, got: ${linkTarget}`);
166
+ assert.ok(linkTarget.endsWith('/plugins/bizar'));
167
+ });
168
+
169
+ test('creates the opencode config parent if missing', () => {
170
+ const dest = pluginDest(home);
171
+ assert.equal(existsSync(dirname(dest)), false, 'precondition');
172
+ const ok = createDevLink(sourceDir);
173
+ assert.equal(ok, true);
174
+ assert.equal(existsSync(dirname(dest)), true);
175
+ });
176
+ });
177
+
178
+ // ── removeDevLink ───────────────────────────────────────────────────────────
179
+
180
+ describe('removeDevLink()', () => {
181
+ let home;
182
+
183
+ beforeEach(() => {
184
+ home = freshHome();
185
+ });
186
+
187
+ afterEach(() => {
188
+ if (home && existsSync(home)) rmSync(home, { recursive: true, force: true });
189
+ if (ORIG_HOME === undefined) delete process.env.HOME;
190
+ else process.env.HOME = ORIG_HOME;
191
+ if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
192
+ else process.env.XDG_CONFIG_HOME = ORIG_XDG;
193
+ });
194
+
195
+ test('errors when dest does not exist', async () => {
196
+ const dest = pluginDest(home);
197
+ assert.equal(existsSync(dest), false);
198
+
199
+ const ok = await removeDevLink();
200
+ assert.equal(ok, false);
201
+ });
202
+
203
+ test('errors when dest is a real directory and no --force', async () => {
204
+ const dest = pluginDest(home);
205
+ mkdirSync(dirname(dest), { recursive: true });
206
+ mkdirSync(dest, { recursive: true });
207
+ writeFileSync(join(dest, 'index.ts'), 'precious data');
208
+
209
+ const ok = await removeDevLink();
210
+ assert.equal(ok, false);
211
+ assert.equal(existsSync(join(dest, 'index.ts')), true, 'data preserved');
212
+ });
213
+
214
+ test('removes a symlink and restores from npm', async () => {
215
+ const dest = pluginDest(home);
216
+ mkdirSync(dirname(dest), { recursive: true });
217
+
218
+ const sourceDir = join(PROJECT_ROOT, 'plugins', 'bizar');
219
+ symlinkSync(sourceDir, dest);
220
+ assert.equal(lstatSync(dest).isSymbolicLink(), true);
221
+
222
+ const ok = await removeDevLink();
223
+ assert.equal(ok, true);
224
+ // The symlink should be gone. Whether the npm-restore step
225
+ // recreated a real directory depends on whether @polderlabs/bizar-plugin
226
+ // is installed globally in the test environment. Both outcomes are
227
+ // acceptable — what matters is that the symlink was removed.
228
+ if (existsSync(dest)) {
229
+ const st = lstatSync(dest);
230
+ assert.equal(
231
+ st.isSymbolicLink(),
232
+ false,
233
+ 'symlink should have been replaced with a real directory (npm restore)',
234
+ );
235
+ }
236
+ });
237
+
238
+ test('refuses to unlink a real dir without --force', async () => {
239
+ const dest = pluginDest(home);
240
+ mkdirSync(dirname(dest), { recursive: true });
241
+ mkdirSync(dest, { recursive: true });
242
+
243
+ const ok = await removeDevLink();
244
+ assert.equal(ok, false);
245
+ assert.equal(existsSync(dest), true);
246
+ });
247
+
248
+ test('force-removes a real dir with opts.force=true', async () => {
249
+ const dest = pluginDest(home);
250
+ mkdirSync(dirname(dest), { recursive: true });
251
+ mkdirSync(dest, { recursive: true });
252
+ writeFileSync(join(dest, 'marker'), 'x');
253
+
254
+ const ok = await removeDevLink({ force: true });
255
+ assert.equal(ok, true);
256
+ // The npm-restore may or may not recreate the directory. If it
257
+ // does, the marker should be gone.
258
+ if (existsSync(dest)) {
259
+ assert.equal(existsSync(join(dest, 'marker')), false);
260
+ }
261
+ });
262
+ });
263
+
264
+ // ── Symlink detection ───────────────────────────────────────────────────────
265
+
266
+ describe('symlink detection', () => {
267
+ let home;
268
+
269
+ beforeEach(() => {
270
+ home = freshHome();
271
+ });
272
+
273
+ afterEach(() => {
274
+ if (home && existsSync(home)) rmSync(home, { recursive: true, force: true });
275
+ if (ORIG_HOME === undefined) delete process.env.HOME;
276
+ else process.env.HOME = ORIG_HOME;
277
+ if (ORIG_XDG === undefined) delete process.env.XDG_CONFIG_HOME;
278
+ else process.env.XDG_CONFIG_HOME = ORIG_XDG;
279
+ });
280
+
281
+ test('lstatSync reports isSymbolicLink()=true after createDevLink', () => {
282
+ const dest = pluginDest(home);
283
+ createDevLink(join(PROJECT_ROOT, 'plugins', 'bizar'));
284
+ const st = lstatSync(dest);
285
+ assert.equal(st.isSymbolicLink(), true);
286
+ });
287
+
288
+ test('lstatSync reports isSymbolicLink()=false for a real dir', () => {
289
+ const dest = pluginDest(home);
290
+ mkdirSync(dirname(dest), { recursive: true });
291
+ mkdirSync(dest, { recursive: true });
292
+ const st = lstatSync(dest);
293
+ assert.equal(st.isSymbolicLink(), false);
294
+ });
295
+ });
296
+
297
+ console.log(' dev-link.mjs tests loaded — run with: node --test cli/dev-link.test.mjs');