symlx 0.1.2 → 0.1.4

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
@@ -1,3 +1,270 @@
1
1
  # symlx
2
2
 
3
- tomorrow...
3
+ Temporary command linker for local CLI development.
4
+
5
+ `symlx serve` links command names from your project into a runnable bin directory for the lifetime of the process.
6
+ When `symlx` stops, those links are cleaned up.
7
+
8
+ ## Why symlx
9
+
10
+ During CLI development, running `node dist/cli.js` repeatedly is noisy.
11
+ `npm link` has generally been buggy and slow to pick recent code changes.
12
+ `symlx` gives you the real command experience (`my-cli --help`) without a global publish/install cycle.
13
+
14
+ Core guarantees:
15
+
16
+ - Links are session-scoped and cleaned on exit.
17
+ - Collision behavior is explicit (`prompt`, `skip`, `fail`, `overwrite`).
18
+ - Option resolution is deterministic.
19
+ - PATH setup for `~/.symlx/bin` is automated on install (with opt-out).
20
+
21
+ ## Install
22
+
23
+ ```bash
24
+ npx symlx serve
25
+ # or
26
+ npm i -g symlx
27
+ ```
28
+
29
+ ## Quick Start
30
+
31
+ In a CLI project with:
32
+
33
+ ```json
34
+ {
35
+ "name": "my-cli",
36
+ "bin": {
37
+ "my-cli": "./dist/cli.js"
38
+ }
39
+ }
40
+ ```
41
+
42
+ run:
43
+
44
+ ```bash
45
+ symlx serve
46
+ ```
47
+
48
+ Then use your CLI normally:
49
+
50
+ ```bash
51
+ my-cli --help
52
+ ```
53
+
54
+ Stop `symlx` with `Ctrl+C` to clean links.
55
+
56
+ ## Alias
57
+
58
+ `symlx` can be clackful for power users, hence its alias: `cx`.
59
+
60
+ Equivalent commands:
61
+
62
+ ```bash
63
+ symlx serve
64
+ cx serve
65
+ ```
66
+
67
+ ## Command Reference
68
+
69
+ ## `symlx serve`
70
+
71
+ Links commands from resolved bin mappings and keeps the process alive until interrupted.
72
+
73
+ ### Options
74
+
75
+ | Option | Type | Default | Description |
76
+ | -------------------------------------- | ------------------------------------- | -------------- | --------------------------------------------------------------------- |
77
+ | `--bin-dir <dir>` | string | `~/.symlx/bin` | Target directory where command links are created. |
78
+ | `--collision <policy>` | `prompt \| skip \| fail \| overwrite` | `prompt` | What to do when a command name already exists in bin dir. |
79
+ | `--bin-resolution-strategy <strategy>` | `replace \| merge` | `replace` | How to resolve `bin` across `package.json`, config, and inline flags. |
80
+ | `--non-interactive` | boolean | `false` | Disable prompts and force non-interactive behavior. |
81
+ | `--bin <name=path>` (repeatable) | string[] | `[]` | Inline bin mapping (for quick overrides/ad-hoc runs). |
82
+
83
+ Examples:
84
+
85
+ ```bash
86
+ symlx serve --collision overwrite
87
+ symlx serve --bin admin=dist/admin.js --bin worker=dist/worker.js
88
+ symlx serve --bin-resolution-strategy merge
89
+ ```
90
+
91
+ ## Bin Resolution Model
92
+
93
+ `symlx` resolves options from three user sources plus defaults:
94
+
95
+ 1. `package.json`
96
+ 2. `symlx.config.json`
97
+ 3. inline CLI flags
98
+
99
+ Scalar fields (`collision`, `binDir`, `nonInteractive`, `binResolutionStrategy`) follow normal override order:
100
+
101
+ `defaults -> package.json-derived -> config -> inline`
102
+
103
+ `bin` uses strategy mode:
104
+
105
+ - `replace` (default): first non-empty wins by priority`inline > config > package.json > default`
106
+ - `merge`: combines all
107
+ `package.json + config + inline` (right-most source overrides key collisions)
108
+
109
+ ## Supported Bin Sources
110
+
111
+ ## `package.json`
112
+
113
+ `bin` supports both npm-compatible linking:
114
+
115
+ ```json
116
+ {
117
+ "name": "my-cli",
118
+ "bin": "./dist/cli.js"
119
+ }
120
+ ```
121
+
122
+ ```json
123
+ {
124
+ "bin": {
125
+ "my-cli": "./dist/cli.js",
126
+ "my-admin": "./dist/admin.js"
127
+ }
128
+ }
129
+ ```
130
+
131
+ If `bin` is a string, `name` is required so command name can be inferred.
132
+
133
+ ## `symlx.config.json`
134
+
135
+ ```json
136
+ {
137
+ "binDir": "~/.symlx/bin",
138
+ "collision": "prompt",
139
+ "nonInteractive": false,
140
+ "binResolutionStrategy": "replace",
141
+ "bin": {
142
+ "my-cli": "./dist/cli.js"
143
+ }
144
+ }
145
+ ```
146
+
147
+ Notes:
148
+
149
+ - In case of invalid config values, `symlx` fallback to defaults (with warnings).
150
+ - `binDir` is treated as critical and must pass validation.
151
+
152
+ ## Inline Flags
153
+
154
+ ```bash
155
+ symlx serve --bin my-cli=dist/cli.js
156
+
157
+ # multiple inline bins
158
+ symlx serve \
159
+ --bin xin-ping=./cli.js \
160
+ --bin admin=./scripts/admin.js
161
+ ```
162
+
163
+ `name` rules:
164
+
165
+ - lowercase letters, digits, `-`
166
+ - no spaces
167
+
168
+ `path` rules:
169
+
170
+ - must be relative (for example `dist/cli.js` or `./dist/cli.js`)
171
+ - absolute paths are rejected
172
+
173
+ ## Collision Policies
174
+
175
+ - `prompt`: ask per conflict (interactive TTY only)
176
+ - `skip`: keep existing command, skip link
177
+ - `fail`: stop on first conflict
178
+ - `overwrite`: replace existing entry
179
+
180
+ If `prompt` is requested in non-interactive mode, symlx falls back to `skip` and warns.
181
+
182
+ ## Install-Time PATH Setup
183
+
184
+ On install, `symlx` updates shell profile PATH block
185
+
186
+ Managed path:
187
+
188
+ ```bash
189
+ $HOME/.symlx/bin
190
+ ```
191
+
192
+ Opt out:
193
+
194
+ ```bash
195
+ SYMLX_SKIP_PATH_SETUP=1 npm i -g symlx
196
+ ```
197
+
198
+ To set a custome bin PATH:
199
+
200
+ ```bash
201
+ symlx serve --bin-dir ~/.symlx/bin
202
+ ```
203
+
204
+ ## Runtime Safety Checks
205
+
206
+ Before linking, symlx validates each resolved bin target:
207
+
208
+ - file exists
209
+ - target is not a directory
210
+ - target is executable on unix-like systems
211
+
212
+ Invalid targets fail early with actionable messages.
213
+
214
+ ## Exit Behavior
215
+
216
+ - `Ctrl+C` (SIGINT), SIGTERM, SIGHUP, uncaught exception, and unhandled rejection trigger cleanup.
217
+ - Session metadata is stored under `~/.symlx/sessions`.
218
+ - Stale sessions leftover due to hard crashes are cleaned on startup.
219
+
220
+ ## Troubleshooting
221
+
222
+ ## "no bin entries found"
223
+
224
+ Add a bin mapping in at least one place:
225
+
226
+ - `package.json -> bin`
227
+ - `symlx.config.json -> bin`
228
+ - `--bin name=path`
229
+
230
+ ## "target is not executable"
231
+
232
+ ```bash
233
+ chmod +x dist/cli.js # or your target executable
234
+ ```
235
+
236
+ ## "command conflicts at ..."
237
+
238
+ Use a collision mode:
239
+
240
+ ```bash
241
+ symlx serve --collision overwrite
242
+ # or
243
+ symlx serve --collision fail
244
+ ```
245
+
246
+ ## "package.json not found"
247
+
248
+ Run in your project root, or pass bins inline/config.
249
+
250
+ ## Development
251
+
252
+ ```bash
253
+ pnpm install
254
+ pnpm run check
255
+ pnpm run build
256
+ pnpm run test
257
+ ```
258
+
259
+ ## Extending Commands (Contributor Contract)
260
+
261
+ To add a new command while preserving set conventions:
262
+
263
+ 1. Define command surface in `src/cli.ts`.
264
+ 2. Keep orchestration in `src/commands/*`.
265
+ 3. Reuse `resolveOptions()` for deterministic source handling.
266
+ 4. Validate user-facing options with zod schemas in `src/lib/schema.ts`.
267
+ 5. Add behavior coverage in `test/*.test.ts`.
268
+ 6. Update this README command reference and examples.
269
+
270
+ The goal is consistent behavior across all current and future commands.
package/dist/cli.js CHANGED
@@ -35,38 +35,28 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  })();
36
36
  Object.defineProperty(exports, "__esModule", { value: true });
37
37
  const commander_1 = require("commander");
38
- const serve_1 = require("./commands/serve");
39
38
  const log = __importStar(require("./ui/logger"));
40
- // Accepted values for --collision.
41
- const ALLOWED_COLLISIONS = new Set(["prompt", "skip", "fail", "overwrite"]);
42
- // Converts raw CLI input into a validated union type used by the serve command.
43
- function parseCollisionPolicy(value) {
44
- if (!ALLOWED_COLLISIONS.has(value)) {
45
- throw new Error(`invalid collision policy "${value}". expected: prompt|skip|fail|overwrite`);
46
- }
47
- return value;
39
+ const serve_1 = require("./commands/serve");
40
+ function collectBinEntry(value, previous = []) {
41
+ previous.push(value);
42
+ return previous;
48
43
  }
49
44
  async function main() {
50
45
  // Commander orchestrates top-level commands/options and help output.
51
46
  const program = new commander_1.Command();
52
47
  program
53
- .name("symlx")
54
- .description("Temporary CLI bin linker with lifecycle cleanup")
48
+ .name('symlx')
49
+ .description('Temporary CLI bin linker with lifecycle cleanup')
55
50
  .showHelpAfterError();
56
51
  program
57
- .command("serve")
58
- .description("Link this project's package.json bins until symlx exits")
59
- .option("--bin-dir <dir>", "target bin directory (default: ~/.symlx/bin)")
60
- .option("--collision <policy>", "collision mode: prompt|skip|fail|overwrite", "prompt")
61
- .option("--non-interactive", "disable interactive prompts", false)
62
- .action(async (options) => {
63
- // Delegate all runtime behavior to the command module.
64
- await (0, serve_1.runServe)({
65
- binDir: options.binDir,
66
- collision: parseCollisionPolicy(options.collision),
67
- nonInteractive: options.nonInteractive
68
- });
69
- });
52
+ .command('serve')
53
+ .description("Link this project's bin commands until symlx exits")
54
+ .option('--bin-dir <dir>', 'target bin directory (default: ~/.symlx/bin)')
55
+ .option('--collision <policy>', 'collision mode: prompt|skip|fail|overwrite', 'prompt')
56
+ .option('--bin-resolution-strategy <strategy>', 'bin precedence strategy: replace|merge', 'replace')
57
+ .option('--non-interactive', 'disable interactive prompts', false)
58
+ .option('--bin <name=path>', 'custom bin mapping (repeatable), e.g. --bin my-cli=dist/cli.js', collectBinEntry, [])
59
+ .action(serve_1.serveCommand);
70
60
  await program.parseAsync(process.argv);
71
61
  }
72
62
  // Centralized fatal error boundary for command execution.
@@ -32,74 +32,129 @@ var __importStar = (this && this.__importStar) || (function () {
32
32
  return result;
33
33
  };
34
34
  })();
35
+ var __importDefault = (this && this.__importDefault) || function (mod) {
36
+ return (mod && mod.__esModule) ? mod : { "default": mod };
37
+ };
35
38
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.runServe = runServe;
37
- const paths_1 = require("../core/paths");
38
- const link_manager_1 = require("../services/link-manager");
39
- const lifecycle_1 = require("../services/lifecycle");
40
- const package_bins_1 = require("../services/package-bins");
41
- const session_store_1 = require("../services/session-store");
42
- const collision_prompt_1 = require("../ui/collision-prompt");
39
+ exports.serveCommand = serveCommand;
40
+ const path_1 = __importDefault(require("path"));
41
+ const node_os_1 = __importDefault(require("node:os"));
43
42
  const log = __importStar(require("../ui/logger"));
43
+ const utils_1 = require("../lib/utils");
44
+ const link_manager_1 = require("../lib/link-manager");
45
+ const bin_targets_1 = require("../lib/bin-targets");
46
+ const lifecycle_1 = require("../lib/lifecycle");
47
+ const session_store_1 = require("../lib/session-store");
48
+ const prompts_1 = require("../ui/prompts");
49
+ const options_1 = require("../lib/options");
50
+ const schema_1 = require("../lib/schema");
44
51
  // Prompts require an interactive terminal; scripts/CI should avoid prompt mode.
45
52
  function isInteractiveSession() {
46
53
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
47
54
  }
48
- // Main symlx behavior:
49
- // 1) resolve bins from package.json
50
- // 2) create links
51
- // 3) persist session
52
- // 4) keep process alive and cleanup on exit
53
- async function runServe(options) {
54
- const cwd = process.cwd();
55
- const paths = (0, paths_1.getSymlxPaths)(options.binDir);
56
- // Prepare runtime directories and recover stale sessions from previous abnormal exits.
57
- (0, session_store_1.cleanupStaleSessions)(paths.sessionDir);
58
- (0, session_store_1.ensureSymlxDirectories)(paths.binDir, paths.sessionDir);
59
- const bins = (0, package_bins_1.readBins)(cwd);
60
- // Prompt policy only works when we can interact with a TTY.
61
- const usePrompts = options.collision === 'prompt' &&
62
- !options.nonInteractive &&
63
- isInteractiveSession();
64
- if (options.collision === 'prompt' && !usePrompts) {
65
- log.warn('prompt collision mode requested but session is non-interactive; falling back to skip');
55
+ function prepareRuntimeDirectories(binDir, sessionDir) {
56
+ (0, session_store_1.cleanupStaleSessions)(sessionDir);
57
+ (0, session_store_1.ensureSymlxDirectories)(binDir, sessionDir);
58
+ }
59
+ function resolveCollisionHandling(options) {
60
+ if (options.collision !== 'prompt') {
61
+ return { policy: options.collision };
62
+ }
63
+ const canPrompt = !options.nonInteractive && isInteractiveSession();
64
+ if (!canPrompt) {
65
+ log.warn('prompt collision mode requested but session is non-interactive; falling back to skip (use --collision overwrite|fail to avoid skips)');
66
+ return { policy: 'skip' };
66
67
  }
67
- // Link creation returns both successful links and explicit skips.
68
- const linkResult = await (0, link_manager_1.createLinks)({
69
- bins,
70
- binDir: paths.binDir,
71
- policy: options.collision,
72
- collisionResolver: usePrompts ? collision_prompt_1.promptCollisionDecision : undefined,
68
+ return {
69
+ policy: 'prompt',
70
+ collisionResolver: prompts_1.promptCollisionDecision,
71
+ };
72
+ }
73
+ async function linkCommands(options, collisionHandling) {
74
+ return (0, link_manager_1.createLinks)({
75
+ bins: new Map(Object.entries(options.bin)),
76
+ binDir: options.binDir,
77
+ policy: collisionHandling.policy,
78
+ collisionResolver: collisionHandling.collisionResolver,
73
79
  });
80
+ }
81
+ function ensureLinksWereCreated(linkResult) {
74
82
  if (linkResult.created.length === 0) {
75
- throw new Error('no links were created');
83
+ if (linkResult.skipped.length === 0) {
84
+ throw new Error('no links were created');
85
+ }
86
+ const details = linkResult.skipped
87
+ .slice(0, 5)
88
+ .map((skip) => `- ${skip.name}: ${skip.reason}`)
89
+ .join('\n');
90
+ const remainingCount = linkResult.skipped.length - 5;
91
+ const remaining = remainingCount > 0 ? `\n- ...and ${remainingCount} more` : '';
92
+ throw new Error([
93
+ 'no links were created because all candidate commands were skipped.',
94
+ details,
95
+ `${remaining}\nuse --collision overwrite or --collision fail for stricter behavior.`,
96
+ ].join('\n'));
76
97
  }
77
- // Session file is the source of truth for cleaning this exact run's links.
78
- const sessionPath = (0, session_store_1.createSessionFilePath)(paths.sessionDir);
98
+ }
99
+ function persistActiveSession(params) {
100
+ const { sessionDir, cwd, links } = params;
101
+ const sessionPath = (0, session_store_1.createSessionFilePath)(sessionDir);
79
102
  const sessionRecord = {
80
103
  pid: process.pid,
81
104
  cwd,
82
105
  createdAt: new Date().toISOString(),
83
- links: linkResult.created,
106
+ links,
84
107
  };
85
108
  (0, session_store_1.persistSession)(sessionPath, sessionRecord);
86
- // Always cleanup linked commands when this process leaves.
109
+ return { sessionPath, sessionRecord };
110
+ }
111
+ function registerSessionCleanup(sessionPath, links) {
87
112
  (0, lifecycle_1.registerLifecycleCleanup)(() => {
88
- (0, session_store_1.cleanupSession)(sessionPath, sessionRecord.links);
113
+ (0, session_store_1.cleanupSession)(sessionPath, links);
89
114
  });
90
- log.info(`linked ${linkResult.created.length} command(s) into ${paths.binDir}`);
91
- for (const link of linkResult.created) {
115
+ }
116
+ function printLinkOutcome(binDir, linkResult) {
117
+ const createdLinks = linkResult.created;
118
+ log.info(`linked ${createdLinks.length} command${createdLinks.length > 1 ? 's' : ''} into ${binDir}`);
119
+ for (const link of createdLinks) {
92
120
  log.info(`${link.name} -> ${link.target}`);
93
121
  }
94
122
  for (const skip of linkResult.skipped) {
95
123
  log.warn(`skip "${skip.name}": ${skip.reason} (${skip.linkPath})`);
96
124
  }
97
- if (!(0, paths_1.pathContainsDir)(process.env.PATH, paths.binDir)) {
98
- log.info(`add this to your shell config if needed:\nexport PATH="${paths.binDir}:$PATH"`);
125
+ }
126
+ function printPathHintIfNeeded(binDir) {
127
+ if ((0, utils_1.pathContainsDir)(process.env.PATH, binDir)) {
128
+ return;
99
129
  }
100
- log.info('running. press Ctrl+C to cleanup links.');
101
- // Keep process alive indefinitely; lifecycle handlers handle termination and cleanup.
102
- await new Promise(() => {
130
+ log.info(`add this to your shell config if needed:\nexport PATH="${binDir}:$PATH"`);
131
+ }
132
+ function waitIndefinitely() {
133
+ return new Promise(() => {
103
134
  setInterval(() => undefined, 60_000);
104
135
  });
105
136
  }
137
+ async function run(options) {
138
+ const cwd = process.cwd();
139
+ const sessionDir = path_1.default.join(node_os_1.default.homedir(), '.symlx', 'sessions');
140
+ prepareRuntimeDirectories(options.binDir, sessionDir);
141
+ (0, bin_targets_1.assertValidBinTargets)(options.bin);
142
+ const collisionHandling = resolveCollisionHandling(options);
143
+ const linkResult = await linkCommands(options, collisionHandling);
144
+ ensureLinksWereCreated(linkResult);
145
+ const { sessionPath, sessionRecord } = persistActiveSession({
146
+ sessionDir,
147
+ cwd,
148
+ links: linkResult.created,
149
+ });
150
+ registerSessionCleanup(sessionPath, sessionRecord.links);
151
+ printLinkOutcome(options.binDir, linkResult);
152
+ printPathHintIfNeeded(options.binDir);
153
+ log.info('running. press Ctrl+C to cleanup links.');
154
+ await waitIndefinitely();
155
+ }
156
+ function serveCommand(inlineOptions) {
157
+ const cwd = process.cwd();
158
+ const options = (0, options_1.resolveOptions)(cwd, schema_1.serveInlineOptionsSchema, inlineOptions);
159
+ return run(options);
160
+ }
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.assertValidBinTargets = assertValidBinTargets;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ function isExecutable(filePath) {
9
+ if (process.platform === 'win32') {
10
+ return true;
11
+ }
12
+ try {
13
+ node_fs_1.default.accessSync(filePath, node_fs_1.default.constants.X_OK);
14
+ return true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ function inspectBinTarget(name, target) {
21
+ if (!node_fs_1.default.existsSync(target)) {
22
+ return {
23
+ name,
24
+ target,
25
+ reason: 'target file does not exist',
26
+ };
27
+ }
28
+ let stats;
29
+ try {
30
+ stats = node_fs_1.default.statSync(target);
31
+ }
32
+ catch (error) {
33
+ return {
34
+ name,
35
+ target,
36
+ reason: `target cannot be accessed (${String(error)})`,
37
+ };
38
+ }
39
+ if (stats.isDirectory()) {
40
+ return {
41
+ name,
42
+ target,
43
+ reason: 'target is a directory',
44
+ };
45
+ }
46
+ if (!isExecutable(target)) {
47
+ return {
48
+ name,
49
+ target,
50
+ reason: 'target is not executable',
51
+ hint: `run: chmod +x ${target}`,
52
+ };
53
+ }
54
+ return undefined;
55
+ }
56
+ function formatIssues(issues) {
57
+ return issues
58
+ .map((issue) => {
59
+ const hint = issue.hint ? ` (${issue.hint})` : '';
60
+ return `- ${issue.name} -> ${issue.target}: ${issue.reason}${hint}`;
61
+ })
62
+ .join('\n');
63
+ }
64
+ function assertValidBinTargets(bin) {
65
+ const issues = [];
66
+ for (const [name, target] of Object.entries(bin)) {
67
+ const issue = inspectBinTarget(name, target);
68
+ if (issue) {
69
+ issues.push(issue);
70
+ }
71
+ }
72
+ if (issues.length === 0) {
73
+ return;
74
+ }
75
+ throw new Error([
76
+ 'invalid bin targets:',
77
+ formatIssues(issues),
78
+ 'fix bin paths/permissions in package.json, symlx.config.json, or inline --bin and run again.',
79
+ ].join('\n'));
80
+ }
@@ -13,7 +13,7 @@ function tryLstat(filePath) {
13
13
  }
14
14
  catch (error) {
15
15
  const code = error.code;
16
- if (code === "ENOENT") {
16
+ if (code === 'ENOENT') {
17
17
  return undefined;
18
18
  }
19
19
  throw error;
@@ -54,17 +54,17 @@ function toConflict(name, linkPath, target, node) {
54
54
  target,
55
55
  reason: node.existingTarget
56
56
  ? `already linked to ${node.existingTarget}`
57
- : "already exists as symlink",
57
+ : 'already exists as symlink',
58
58
  existingTarget: node.existingTarget,
59
- isSymlink: true
59
+ isSymlink: true,
60
60
  };
61
61
  }
62
62
  return {
63
63
  name,
64
64
  linkPath,
65
65
  target,
66
- reason: "already exists as a file",
67
- isSymlink: false
66
+ reason: 'already exists as a file',
67
+ isSymlink: false,
68
68
  };
69
69
  }
70
70
  // Creates symlinks for all project bins according to the selected collision strategy.
@@ -79,27 +79,34 @@ async function createLinks(params) {
79
79
  if (existingNode) {
80
80
  const conflict = toConflict(name, linkPath, target, existingNode);
81
81
  // Reusing the exact same link is always a no-op.
82
- if (conflict.existingTarget && node_path_1.default.resolve(conflict.existingTarget) === node_path_1.default.resolve(target)) {
83
- skipped.push({ name, linkPath, reason: "already linked to requested target" });
82
+ if (conflict.existingTarget &&
83
+ node_path_1.default.resolve(conflict.existingTarget) === node_path_1.default.resolve(target)) {
84
+ skipped.push({
85
+ name,
86
+ linkPath,
87
+ reason: 'already linked to requested target',
88
+ });
84
89
  continue;
85
90
  }
86
91
  let decision;
87
- if (policy === "skip") {
88
- decision = "skip";
92
+ if (policy === 'skip') {
93
+ decision = 'skip';
89
94
  }
90
- else if (policy === "overwrite") {
91
- decision = "overwrite";
95
+ else if (policy === 'overwrite') {
96
+ decision = 'overwrite';
92
97
  }
93
- else if (policy === "fail") {
98
+ else if (policy === 'fail') {
94
99
  throw new Error(`command "${name}" conflicts at ${linkPath}: ${conflict.reason}`);
95
100
  }
96
101
  else {
97
- decision = collisionResolver ? await collisionResolver(conflict) : "skip";
102
+ decision = collisionResolver
103
+ ? await collisionResolver(conflict)
104
+ : 'skip';
98
105
  }
99
- if (decision === "abort") {
106
+ if (decision === 'abort') {
100
107
  throw new Error(`aborted on collision for command "${name}"`);
101
108
  }
102
- if (decision === "skip") {
109
+ if (decision === 'skip') {
103
110
  skipped.push({ name, linkPath, reason: conflict.reason });
104
111
  continue;
105
112
  }