symlx 0.1.8 → 0.1.11

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,4 +1,4 @@
1
- # Quick Start
1
+ # tl:dr
2
2
 
3
3
  In a CLI project with:
4
4
 
@@ -46,6 +46,9 @@ Core guarantees:
46
46
  - Links are session-scoped and cleaned on exit.
47
47
  - Collision behavior is explicit (`prompt`, `skip`, `fail`, `overwrite`).
48
48
  - Option resolution is deterministic.
49
+ - Target execution is hybrid by default:
50
+ - shebang present -> direct link
51
+ - no shebang -> launcher inference by target type
49
52
  - PATH setup for `~/.symlx/bin` is automated on install (with opt-out).
50
53
 
51
54
  ## Install
@@ -97,7 +100,7 @@ symlx serve --bin-resolution-strategy merge
97
100
 
98
101
  Links commands from resolved bin mappings and exits immediately.
99
102
 
100
- It uses the exact same options and resolution behavior as `symlx serve`, but it does not keep a live session.
103
+ It uses the exact same options and resolution behavior as `symlx serve`, _but it does not keep a live session_.
101
104
 
102
105
  Examples:
103
106
 
@@ -121,7 +124,7 @@ Scalar fields (`collision`, `binDir`, `nonInteractive`, `binResolutionStrategy`)
121
124
 
122
125
  `bin` uses strategy mode:
123
126
 
124
- - `replace` (default): first non-empty wins by priority`inline > config > package.json > default`
127
+ - `replace` (default): first non-empty wins by priority `inline > config > package.json > default`
125
128
  - `merge`: combines all
126
129
  `package.json + config + inline` (right-most source overrides key collisions)
127
130
 
@@ -165,7 +168,7 @@ If `bin` is a string, `name` is required so command name can be inferred.
165
168
 
166
169
  Notes:
167
170
 
168
- - In case of invalid config values, `symlx` fallback to defaults (with warnings).
171
+ - In case of invalid non-critical config values, `symlx` falls back to defaults (with warnings).
169
172
  - `binDir` is treated as critical and must pass validation.
170
173
 
171
174
  ## Inline Flags
@@ -189,6 +192,25 @@ symlx serve \
189
192
  - must be relative (for example `dist/cli.js` or `./dist/cli.js`)
190
193
  - absolute paths are rejected
191
194
 
195
+ ## Target Execution Model (Hybrid by Default)
196
+
197
+ For each resolved target file:
198
+
199
+ - if target has a shebang, symlx links it directly
200
+ - if target has no shebang, symlx infers launcher by file type
201
+
202
+ Current launcher inference:
203
+
204
+ - `.js`, `.mjs`, `.cjs` -> Node launcher
205
+ - `.ts`, `.tsx`, `.mts`, `.cts` -> `tsx` launcher
206
+
207
+ TypeScript runtime resolution order is:
208
+
209
+ 1. project-local `node_modules/.bin/tsx`
210
+ 2. `tsx` on `PATH`
211
+
212
+ If target has no shebang and launcher support is unavailable, symlx fails with a clear message that this is not supported yet without shebang and asks you to manually add shebang.
213
+
192
214
  ## Collision Policies
193
215
 
194
216
  - `prompt`: ask per conflict (interactive TTY only)
@@ -200,7 +222,7 @@ If `prompt` is requested in non-interactive mode, symlx falls back to `skip` and
200
222
 
201
223
  ## Install-Time PATH Setup
202
224
 
203
- On install, `symlx` updates shell profile PATH block
225
+ On install, `symlx` updates shell profile PATH block.
204
226
 
205
227
  Managed path:
206
228
 
@@ -214,7 +236,7 @@ Opt out:
214
236
  SYMLX_SKIP_PATH_SETUP=1 npm i -g symlx
215
237
  ```
216
238
 
217
- To set a custome bin PATH:
239
+ To set a custom bin directory:
218
240
 
219
241
  ```bash
220
242
  symlx serve --bin-dir ~/.symlx/bin
@@ -226,9 +248,10 @@ Before linking, symlx prepares each resolved bin target:
226
248
 
227
249
  - file exists
228
250
  - target is not a directory
229
- - target is made executable automatically on unix-like systems when possible
251
+ - shebang path: direct link + executable permission repair when possible
252
+ - no-shebang path: launcher inference + runtime availability checks
230
253
 
231
- Missing targets, directories, and permission-update failures still fail early with actionable messages.
254
+ Missing targets, directories, unsupported no-shebang target types, missing launcher runtimes, and permission-update failures fail early with actionable messages.
232
255
 
233
256
  ## Exit Behavior
234
257
 
@@ -238,6 +261,10 @@ Missing targets, directories, and permission-update failures still fail early wi
238
261
 
239
262
  ## Troubleshooting
240
263
 
264
+ ## "not supported yet without shebang"
265
+
266
+ - add a shebang to the target file to declare its runner explicitly
267
+
241
268
  ## "no bin entries found"
242
269
 
243
270
  Add a bin mapping in at least one place:
@@ -256,6 +283,10 @@ symlx serve --collision overwrite
256
283
  symlx serve --collision fail
257
284
  ```
258
285
 
286
+ ## "tsx runtime could not be resolved for target"
287
+
288
+ Install `tsx` in the project or make `tsx` available on `PATH`.
289
+
259
290
  ## "package.json not found"
260
291
 
261
292
  Run in your project root, or pass bins inline/config.
@@ -40,25 +40,25 @@ exports.linkCommand = linkCommand;
40
40
  const node_os_1 = __importDefault(require("node:os"));
41
41
  const node_path_1 = __importDefault(require("node:path"));
42
42
  const log = __importStar(require("../ui/logger"));
43
- const serve_output_1 = require("../ui/serve-output");
44
- const options_1 = require("../lib/options");
45
- const schema_1 = require("../lib/schema");
46
43
  const bin_targets_1 = require("../lib/bin-targets");
44
+ const constants_1 = require("../lib/constants");
47
45
  const link_manager_1 = require("../lib/link-manager");
46
+ const options_1 = require("../lib/options");
47
+ const schema_1 = require("../lib/schema");
48
48
  const session_store_1 = require("../lib/session-store");
49
- const constants_1 = require("../lib/constants");
49
+ const serve_output_1 = require("../ui/serve-output");
50
50
  async function linkCommand(inlineOptions) {
51
51
  const cwd = process.cwd();
52
52
  const homeDirectory = node_os_1.default.homedir();
53
53
  const sessionDir = node_path_1.default.join(homeDirectory, '.symlx', 'sessions');
54
54
  const options = (0, options_1.resolveOptions)(cwd, schema_1.serveInlineOptionsSchema, inlineOptions);
55
- const internalCollisionOption = (0, options_1.resolveInternalCollisionOption)(options.collision, options.nonInteractive);
55
+ const collisionOption = (0, options_1.resolveInternalCollisionOption)(options.collision, options.nonInteractive);
56
56
  (0, session_store_1.cleanupStaleSessions)(sessionDir);
57
57
  (0, session_store_1.ensureSymlxDirectories)(options.binDir, sessionDir);
58
- (0, bin_targets_1.prepareBinTargets)(options.bin);
59
- const linkResult = await (0, link_manager_1.createLinks)(options.bin, options.binDir, internalCollisionOption);
58
+ const preparedTargets = (0, bin_targets_1.prepareBinTargets)(cwd, options.bin);
59
+ const linkResult = await (0, link_manager_1.createLinks)(preparedTargets, options.binDir, collisionOption);
60
60
  (0, link_manager_1.assertLinksCreated)(linkResult);
61
- if (options.collision === 'prompt' && internalCollisionOption !== 'prompt') {
61
+ if (options.collision === 'prompt' && collisionOption !== 'prompt') {
62
62
  log.warn(constants_1.PROMPT_FALLBACK_WARNING);
63
63
  }
64
64
  (0, serve_output_1.printLinkOutcome)(options.binDir, linkResult);
@@ -40,13 +40,13 @@ exports.serveCommand = serveCommand;
40
40
  const node_os_1 = __importDefault(require("node:os"));
41
41
  const node_path_1 = __importDefault(require("node:path"));
42
42
  const log = __importStar(require("../ui/logger"));
43
- const serve_output_1 = require("../ui/serve-output");
44
- const options_1 = require("../lib/options");
45
- const schema_1 = require("../lib/schema");
46
43
  const bin_targets_1 = require("../lib/bin-targets");
44
+ const constants_1 = require("../lib/constants");
47
45
  const link_manager_1 = require("../lib/link-manager");
46
+ const options_1 = require("../lib/options");
47
+ const schema_1 = require("../lib/schema");
48
48
  const session_store_1 = require("../lib/session-store");
49
- const constants_1 = require("../lib/constants");
49
+ const serve_output_1 = require("../ui/serve-output");
50
50
  function waitUntilStopped() {
51
51
  return new Promise(() => {
52
52
  setInterval(() => undefined, 60_000);
@@ -56,23 +56,18 @@ async function serveCommand(inlineOptions) {
56
56
  const cwd = process.cwd();
57
57
  const homeDirectory = node_os_1.default.homedir();
58
58
  const sessionDir = node_path_1.default.join(homeDirectory, '.symlx', 'sessions');
59
- // resolve options by merge or otherwise and resolve collision based on interactiveness
60
59
  const options = (0, options_1.resolveOptions)(cwd, schema_1.serveInlineOptionsSchema, inlineOptions);
61
- const internalCollisionOption = (0, options_1.resolveInternalCollisionOption)(options.collision, options.nonInteractive);
62
- // prepare
60
+ const collisionOption = (0, options_1.resolveInternalCollisionOption)(options.collision, options.nonInteractive);
63
61
  (0, session_store_1.cleanupStaleSessions)(sessionDir);
64
62
  (0, session_store_1.ensureSymlxDirectories)(options.binDir, sessionDir);
65
- (0, bin_targets_1.prepareBinTargets)(options.bin);
66
- // link creation
67
- const linkResult = await (0, link_manager_1.createLinks)(options.bin, options.binDir, internalCollisionOption);
63
+ const preparedTargets = (0, bin_targets_1.prepareBinTargets)(cwd, options.bin);
64
+ const linkResult = await (0, link_manager_1.createLinks)(preparedTargets, options.binDir, collisionOption);
68
65
  (0, link_manager_1.assertLinksCreated)(linkResult);
69
- // session management
70
66
  const sessionPath = (0, session_store_1.generateSessionFilePath)(sessionDir);
71
67
  const sessionRecord = (0, session_store_1.generateSessionRecord)(cwd, linkResult.created);
72
68
  (0, session_store_1.persistSession)(sessionPath, sessionRecord);
73
69
  (0, session_store_1.registerLifecycleSessionCleanup)(sessionPath, sessionRecord.links);
74
- // logs
75
- if (options.collision === 'prompt' && internalCollisionOption !== 'prompt') {
70
+ if (options.collision === 'prompt' && collisionOption !== 'prompt') {
76
71
  log.warn(constants_1.PROMPT_FALLBACK_WARNING);
77
72
  }
78
73
  (0, serve_output_1.printLinkOutcome)(options.binDir, linkResult);
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.prepareBinTargets = prepareBinTargets;
7
7
  const node_fs_1 = __importDefault(require("node:fs"));
8
+ const launchers_1 = require("./launchers");
9
+ const shebang_1 = require("./shebang");
8
10
  function isExecutable(filePath) {
9
11
  if (process.platform === 'win32') {
10
12
  return true;
@@ -34,43 +36,6 @@ function ensureExecutable(filePath, currentMode) {
34
36
  }
35
37
  return 'target permissions could not be updated';
36
38
  }
37
- function inspectBinTarget(name, target) {
38
- if (!node_fs_1.default.existsSync(target)) {
39
- return {
40
- name,
41
- target,
42
- reason: 'target file does not exist',
43
- };
44
- }
45
- let stats;
46
- try {
47
- stats = node_fs_1.default.statSync(target);
48
- }
49
- catch (error) {
50
- return {
51
- name,
52
- target,
53
- reason: `target cannot be accessed (${String(error)})`,
54
- };
55
- }
56
- if (stats.isDirectory()) {
57
- return {
58
- name,
59
- target,
60
- reason: 'target is a directory',
61
- };
62
- }
63
- const executableIssue = ensureExecutable(target, stats.mode);
64
- if (executableIssue) {
65
- return {
66
- name,
67
- target,
68
- reason: executableIssue,
69
- hint: `run: chmod +x ${target}`,
70
- };
71
- }
72
- return undefined;
73
- }
74
39
  function formatIssues(issues) {
75
40
  return issues
76
41
  .map((issue) => {
@@ -79,20 +44,89 @@ function formatIssues(issues) {
79
44
  })
80
45
  .join('\n');
81
46
  }
82
- function prepareBinTargets(bin) {
47
+ function addUnsupportedWithoutShebangIssue(issues, name, target, reason) {
48
+ const detail = reason ? ` (${reason})` : '';
49
+ issues.push({
50
+ name,
51
+ target,
52
+ reason: `not supported yet without shebang${detail}`,
53
+ hint: 'explicitly specify a shebang at the top of the target file to declare its runner',
54
+ });
55
+ }
56
+ function prepareBinTargets(cwd, bin, options = {}) {
57
+ const currentPath = options.currentPath ?? process.env.PATH;
58
+ const preparedTargets = [];
83
59
  const issues = [];
84
60
  for (const [name, target] of Object.entries(bin)) {
85
- const issue = inspectBinTarget(name, target);
86
- if (issue) {
87
- issues.push(issue);
61
+ if (!node_fs_1.default.existsSync(target)) {
62
+ issues.push({
63
+ name,
64
+ target,
65
+ reason: 'target file does not exist',
66
+ });
67
+ continue;
68
+ }
69
+ let stats;
70
+ try {
71
+ stats = node_fs_1.default.statSync(target);
72
+ }
73
+ catch (error) {
74
+ issues.push({
75
+ name,
76
+ target,
77
+ reason: `target cannot be accessed (${String(error)})`,
78
+ });
79
+ continue;
88
80
  }
81
+ if (stats.isDirectory()) {
82
+ issues.push({
83
+ name,
84
+ target,
85
+ reason: 'target is a directory',
86
+ });
87
+ continue;
88
+ }
89
+ if ((0, shebang_1.hasShebang)(target)) {
90
+ const executableIssue = ensureExecutable(target, stats.mode);
91
+ if (executableIssue) {
92
+ issues.push({
93
+ name,
94
+ target,
95
+ reason: executableIssue,
96
+ hint: `run: chmod +x ${target}`,
97
+ });
98
+ continue;
99
+ }
100
+ preparedTargets.push({
101
+ name,
102
+ target,
103
+ kind: 'direct-link',
104
+ });
105
+ continue;
106
+ }
107
+ const launcher = (0, launchers_1.resolveInferredLauncher)(cwd, target, currentPath);
108
+ if (!launcher) {
109
+ addUnsupportedWithoutShebangIssue(issues, name, target);
110
+ continue;
111
+ }
112
+ if ('reason' in launcher) {
113
+ addUnsupportedWithoutShebangIssue(issues, name, target, launcher.reason);
114
+ continue;
115
+ }
116
+ preparedTargets.push({
117
+ name,
118
+ target,
119
+ kind: 'launcher',
120
+ launcherKind: launcher.launcherKind,
121
+ runtimeCommand: launcher.runtimeCommand,
122
+ });
89
123
  }
90
124
  if (issues.length === 0) {
91
- return;
125
+ return preparedTargets;
92
126
  }
93
127
  throw new Error([
94
128
  'invalid bin targets:',
95
129
  formatIssues(issues),
96
- 'fix bin paths or file permissions in package.json, symlx.config.json, or inline --bin and run again.',
130
+ 'fix bin paths, launcher support, shebang declarations, or file permissions in package.json, symlx.config.json, or inline --bin and run again.',
97
131
  ].join('\n'));
98
132
  }
@@ -0,0 +1,137 @@
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.inferLauncherKind = inferLauncherKind;
7
+ exports.resolveInferredLauncher = resolveInferredLauncher;
8
+ exports.writeLauncher = writeLauncher;
9
+ exports.matchesLauncher = matchesLauncher;
10
+ const node_fs_1 = __importDefault(require("node:fs"));
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const NODE_TARGET_EXTENSIONS = new Set(['.js', '.mjs', '.cjs']);
13
+ const TYPESCRIPT_TARGET_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
14
+ const LAUNCHER_MARKER = '// symlx:launcher';
15
+ function isExecutable(filePath) {
16
+ if (!node_fs_1.default.existsSync(filePath)) {
17
+ return false;
18
+ }
19
+ if (process.platform === 'win32') {
20
+ return true;
21
+ }
22
+ try {
23
+ node_fs_1.default.accessSync(filePath, node_fs_1.default.constants.X_OK);
24
+ return true;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }
30
+ function resolveExecutableOnPath(commandName, currentPath) {
31
+ if (!currentPath) {
32
+ return undefined;
33
+ }
34
+ for (const directory of currentPath.split(node_path_1.default.delimiter)) {
35
+ if (!directory) {
36
+ continue;
37
+ }
38
+ const candidate = node_path_1.default.join(directory, commandName);
39
+ if (isExecutable(candidate)) {
40
+ return candidate;
41
+ }
42
+ }
43
+ return undefined;
44
+ }
45
+ function createLauncherContent(launcherKind, runtimeCommand, target) {
46
+ return `#!/usr/bin/env node
47
+ ${LAUNCHER_MARKER} kind=${launcherKind}
48
+ const { spawnSync } = require('node:child_process');
49
+
50
+ const launcherKind = ${JSON.stringify(launcherKind)};
51
+ const runtimeCommand = ${JSON.stringify(runtimeCommand)};
52
+ const targetPath = ${JSON.stringify(target)};
53
+
54
+ const result = spawnSync(
55
+ runtimeCommand,
56
+ [targetPath, ...process.argv.slice(2)],
57
+ { stdio: 'inherit' },
58
+ );
59
+
60
+ if (result.error) {
61
+ process.stderr.write(
62
+ '[symlx] failed to launch target via ' +
63
+ launcherKind +
64
+ ': ' +
65
+ String(result.error) +
66
+ '\\n',
67
+ );
68
+ process.exit(1);
69
+ }
70
+
71
+ if (typeof result.status === 'number') {
72
+ process.exit(result.status);
73
+ }
74
+
75
+ if (result.signal) {
76
+ process.kill(process.pid, result.signal);
77
+ }
78
+
79
+ process.exit(1);
80
+ `;
81
+ }
82
+ const LAUNCHERS = [
83
+ {
84
+ kind: 'node',
85
+ supportsTarget: (target) => NODE_TARGET_EXTENSIONS.has(node_path_1.default.extname(target).toLowerCase()),
86
+ resolveRuntime: () => process.execPath,
87
+ missingRuntimeHint: 'node runtime could not be resolved',
88
+ },
89
+ {
90
+ kind: 'tsx',
91
+ supportsTarget: (target) => TYPESCRIPT_TARGET_EXTENSIONS.has(node_path_1.default.extname(target).toLowerCase()),
92
+ resolveRuntime: (cwd, currentPath) => {
93
+ const localCandidate = node_path_1.default.join(cwd, 'node_modules', '.bin', 'tsx');
94
+ if (isExecutable(localCandidate)) {
95
+ return localCandidate;
96
+ }
97
+ return resolveExecutableOnPath('tsx', currentPath);
98
+ },
99
+ missingRuntimeHint: 'install tsx in the project or make tsx available on PATH',
100
+ },
101
+ ];
102
+ function findLauncherDefinition(target) {
103
+ return LAUNCHERS.find((launcher) => launcher.supportsTarget(target));
104
+ }
105
+ function inferLauncherKind(target) {
106
+ return findLauncherDefinition(target)?.kind;
107
+ }
108
+ function resolveInferredLauncher(cwd, target, currentPath = process.env.PATH) {
109
+ const launcher = findLauncherDefinition(target);
110
+ if (!launcher) {
111
+ return undefined;
112
+ }
113
+ const runtimeCommand = launcher.resolveRuntime(cwd, currentPath);
114
+ if (runtimeCommand) {
115
+ return {
116
+ launcherKind: launcher.kind,
117
+ runtimeCommand,
118
+ };
119
+ }
120
+ return {
121
+ reason: `${launcher.kind} runtime could not be resolved for target`,
122
+ hint: launcher.missingRuntimeHint,
123
+ };
124
+ }
125
+ function writeLauncher(linkPath, launcherKind, runtimeCommand, target) {
126
+ node_fs_1.default.writeFileSync(linkPath, createLauncherContent(launcherKind, runtimeCommand, target), 'utf8');
127
+ node_fs_1.default.chmodSync(linkPath, 0o755);
128
+ }
129
+ function matchesLauncher(linkPath, launcherKind, runtimeCommand, target) {
130
+ try {
131
+ const content = node_fs_1.default.readFileSync(linkPath, 'utf8');
132
+ return (content === createLauncherContent(launcherKind, runtimeCommand, target));
133
+ }
134
+ catch {
135
+ return false;
136
+ }
137
+ }
@@ -7,6 +7,7 @@ exports.createLinks = createLinks;
7
7
  exports.assertLinksCreated = assertLinksCreated;
8
8
  const node_fs_1 = __importDefault(require("node:fs"));
9
9
  const node_path_1 = __importDefault(require("node:path"));
10
+ const launchers_1 = require("./launchers");
10
11
  const prompts_1 = require("../ui/prompts");
11
12
  // lstat wrapper that treats missing files as "not found" but rethrows real IO errors.
12
13
  function tryLstat(filePath) {
@@ -47,6 +48,36 @@ function removeExistingNode(linkPath, node) {
47
48
  }
48
49
  node_fs_1.default.unlinkSync(linkPath);
49
50
  }
51
+ function matchesPreparedTarget(linkPath, entry, existingNode) {
52
+ if (entry.kind === 'direct-link') {
53
+ return Boolean(existingNode.existingTarget &&
54
+ node_path_1.default.resolve(existingNode.existingTarget) === node_path_1.default.resolve(entry.target));
55
+ }
56
+ if (existingNode.stats.isSymbolicLink() || !existingNode.stats.isFile()) {
57
+ return false;
58
+ }
59
+ return (0, launchers_1.matchesLauncher)(linkPath, entry.launcherKind, entry.runtimeCommand, entry.target);
60
+ }
61
+ function createCommandEntry(linkPath, entry) {
62
+ if (entry.kind === 'direct-link') {
63
+ node_fs_1.default.symlinkSync(entry.target, linkPath);
64
+ return {
65
+ name: entry.name,
66
+ linkPath,
67
+ target: entry.target,
68
+ kind: 'direct-link',
69
+ };
70
+ }
71
+ (0, launchers_1.writeLauncher)(linkPath, entry.launcherKind, entry.runtimeCommand, entry.target);
72
+ return {
73
+ name: entry.name,
74
+ linkPath,
75
+ target: entry.target,
76
+ kind: 'launcher',
77
+ launcherKind: entry.launcherKind,
78
+ runtimeCommand: entry.runtimeCommand,
79
+ };
80
+ }
50
81
  // Normalizes filesystem state into a user-facing collision descriptor.
51
82
  function toConflict(name, linkPath, target, node) {
52
83
  if (node.stats.isSymbolicLink()) {
@@ -69,51 +100,44 @@ function toConflict(name, linkPath, target, node) {
69
100
  isSymlink: false,
70
101
  };
71
102
  }
72
- // Creates symlinks for all project bins according to the selected collision strategy.
103
+ // Creates command entries for all prepared bins according to the selected collision strategy.
73
104
  // This function is pure with regard to policy: caller decides interactive vs non-interactive.
74
- async function createLinks(bins, binDir, collisionOption) {
105
+ async function createLinks(preparedTargets, binDir, collisionOption) {
75
106
  const created = [];
76
107
  const skipped = [];
77
- // Link every executable in the bin options
78
- for (const [name, target] of Object.entries(bins)) {
79
- const linkPath = node_path_1.default.join(binDir, name);
80
- // check is there's an existing binary on the device
108
+ for (const entry of preparedTargets) {
109
+ const linkPath = node_path_1.default.join(binDir, entry.name);
81
110
  const existingNode = inspectExistingNode(linkPath);
82
- // If there's a conflicting binary, handle the conflict
83
111
  if (existingNode) {
84
- const conflict = toConflict(name, linkPath, target, existingNode);
85
- // Reusing the exact same link is always a no-op.
86
- if (conflict.existingTarget &&
87
- node_path_1.default.resolve(conflict.existingTarget) === node_path_1.default.resolve(target)) {
112
+ const conflict = toConflict(entry.name, linkPath, entry.target, existingNode);
113
+ if (matchesPreparedTarget(linkPath, entry, existingNode)) {
88
114
  skipped.push({
89
- name,
115
+ name: entry.name,
90
116
  linkPath,
91
117
  reason: 'already linked to requested target',
92
118
  });
93
119
  continue;
94
120
  }
95
121
  if (collisionOption === 'fail') {
96
- throw new Error(`command "${name}" conflicts at ${linkPath}: ${conflict.reason}`);
122
+ throw new Error(`command "${entry.name}" conflicts at ${linkPath}: ${conflict.reason}`);
97
123
  }
98
124
  let collisionDecision;
99
125
  if (collisionOption === 'prompt') {
100
126
  collisionDecision = await (0, prompts_1.promptCollisionResolver)(conflict);
101
127
  if (collisionDecision === 'abort') {
102
- throw new Error(`aborted on collision for command "${name}"`);
128
+ throw new Error(`aborted on collision for command "${entry.name}"`);
103
129
  }
104
130
  }
105
131
  else {
106
- // After here, resulting decision can only either be 'skip' or 'overwrite'
107
132
  collisionDecision = collisionOption;
108
133
  }
109
134
  if (collisionDecision === 'skip') {
110
- skipped.push({ name, linkPath, reason: conflict.reason });
135
+ skipped.push({ name: entry.name, linkPath, reason: conflict.reason });
111
136
  continue;
112
137
  }
113
138
  removeExistingNode(linkPath, existingNode);
114
139
  }
115
- node_fs_1.default.symlinkSync(target, linkPath);
116
- created.push({ name, linkPath, target });
140
+ created.push(createCommandEntry(linkPath, entry));
117
141
  }
118
142
  return { created, skipped };
119
143
  }
@@ -45,6 +45,7 @@ exports.persistSession = persistSession;
45
45
  exports.registerLifecycleSessionCleanup = registerLifecycleSessionCleanup;
46
46
  const node_fs_1 = __importDefault(require("node:fs"));
47
47
  const node_path_1 = __importDefault(require("node:path"));
48
+ const launchers_1 = require("./launchers");
48
49
  const log = __importStar(require("../ui/logger"));
49
50
  const utils_1 = require("./utils");
50
51
  // Checks whether a PID from a previous session is still alive.
@@ -65,20 +66,36 @@ function isProcessAlive(pid) {
65
66
  return code !== 'ESRCH';
66
67
  }
67
68
  }
68
- // Removes only symlinks that still point to the exact targets we created.
69
+ function cleanupDirectLink(link) {
70
+ const stats = node_fs_1.default.lstatSync(link.linkPath);
71
+ if (!stats.isSymbolicLink()) {
72
+ return;
73
+ }
74
+ const linkedTo = node_fs_1.default.readlinkSync(link.linkPath);
75
+ const absoluteLinkedTo = node_path_1.default.resolve(node_path_1.default.dirname(link.linkPath), linkedTo);
76
+ if (absoluteLinkedTo === node_path_1.default.resolve(link.target)) {
77
+ node_fs_1.default.unlinkSync(link.linkPath);
78
+ }
79
+ }
80
+ function cleanupLauncher(link) {
81
+ const stats = node_fs_1.default.lstatSync(link.linkPath);
82
+ if (stats.isSymbolicLink() || !stats.isFile()) {
83
+ return;
84
+ }
85
+ if ((0, launchers_1.matchesLauncher)(link.linkPath, link.launcherKind, link.runtimeCommand, link.target)) {
86
+ node_fs_1.default.unlinkSync(link.linkPath);
87
+ }
88
+ }
89
+ // Removes only command entries that still match the exact records we created.
69
90
  // This avoids deleting user-managed commands with the same name.
70
91
  function cleanupLinks(links) {
71
92
  for (const link of links) {
72
93
  try {
73
- const stats = node_fs_1.default.lstatSync(link.linkPath);
74
- if (!stats.isSymbolicLink()) {
94
+ if (link.kind === 'direct-link') {
95
+ cleanupDirectLink(link);
75
96
  continue;
76
97
  }
77
- const linkedTo = node_fs_1.default.readlinkSync(link.linkPath);
78
- const absoluteLinkedTo = node_path_1.default.resolve(node_path_1.default.dirname(link.linkPath), linkedTo);
79
- if (absoluteLinkedTo === node_path_1.default.resolve(link.target)) {
80
- node_fs_1.default.unlinkSync(link.linkPath);
81
- }
98
+ cleanupLauncher(link);
82
99
  }
83
100
  catch {
84
101
  // Best-effort cleanup.
@@ -90,7 +107,7 @@ function ensureSymlxDirectories(binDir, sessionDir) {
90
107
  node_fs_1.default.mkdirSync(binDir, { recursive: true });
91
108
  node_fs_1.default.mkdirSync(sessionDir, { recursive: true });
92
109
  }
93
- // Reaps stale sessions left behind by crashes/kill -9 and removes their symlinks.
110
+ // Reaps stale sessions left behind by crashes/kill -9 and removes their command entries.
94
111
  function cleanupStaleSessions(sessionDir) {
95
112
  // If the directory does not exist, return early
96
113
  if (!node_fs_1.default.existsSync(sessionDir)) {
@@ -0,0 +1,19 @@
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.readShebang = readShebang;
7
+ exports.hasShebang = hasShebang;
8
+ const node_fs_1 = __importDefault(require("node:fs"));
9
+ function readShebang(filePath) {
10
+ const raw = node_fs_1.default.readFileSync(filePath, 'utf8');
11
+ const firstLine = raw.split(/\r?\n/, 1)[0]?.replace(/^\uFEFF/, '');
12
+ if (!firstLine?.startsWith('#!')) {
13
+ return undefined;
14
+ }
15
+ return firstLine;
16
+ }
17
+ function hasShebang(filePath) {
18
+ return Boolean(readShebang(filePath));
19
+ }
@@ -0,0 +1,103 @@
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.TSX_LAUNCHER_MARKER = void 0;
7
+ exports.isTypeScriptTarget = isTypeScriptTarget;
8
+ exports.resolveTsxRuntime = resolveTsxRuntime;
9
+ exports.createTsxLauncherContent = createTsxLauncherContent;
10
+ exports.writeTsxLauncher = writeTsxLauncher;
11
+ exports.matchesTsxLauncher = matchesTsxLauncher;
12
+ const node_fs_1 = __importDefault(require("node:fs"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ const TYPESCRIPT_TARGET_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts']);
15
+ const TSX_LAUNCHER_MARKER = '// symlx:tsx-launcher';
16
+ exports.TSX_LAUNCHER_MARKER = TSX_LAUNCHER_MARKER;
17
+ function isExecutable(filePath) {
18
+ if (!node_fs_1.default.existsSync(filePath)) {
19
+ return false;
20
+ }
21
+ if (process.platform === 'win32') {
22
+ return true;
23
+ }
24
+ try {
25
+ node_fs_1.default.accessSync(filePath, node_fs_1.default.constants.X_OK);
26
+ return true;
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
32
+ function resolveExecutableOnPath(commandName, currentPath) {
33
+ if (!currentPath) {
34
+ return undefined;
35
+ }
36
+ for (const directory of currentPath.split(node_path_1.default.delimiter)) {
37
+ if (!directory) {
38
+ continue;
39
+ }
40
+ const candidate = node_path_1.default.join(directory, commandName);
41
+ if (isExecutable(candidate)) {
42
+ return candidate;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+ function isTypeScriptTarget(target) {
48
+ return TYPESCRIPT_TARGET_EXTENSIONS.has(node_path_1.default.extname(target).toLowerCase());
49
+ }
50
+ function resolveTsxRuntime(cwd, currentPath = process.env.PATH) {
51
+ const localCandidate = node_path_1.default.join(cwd, 'node_modules', '.bin', 'tsx');
52
+ if (isExecutable(localCandidate)) {
53
+ return localCandidate;
54
+ }
55
+ return resolveExecutableOnPath('tsx', currentPath);
56
+ }
57
+ function createTsxLauncherContent(runtimeCommand, target) {
58
+ return `#!/usr/bin/env node
59
+ ${TSX_LAUNCHER_MARKER}
60
+ const { spawnSync } = require('node:child_process');
61
+
62
+ const runtimeCommand = ${JSON.stringify(runtimeCommand)};
63
+ const targetPath = ${JSON.stringify(target)};
64
+
65
+ const result = spawnSync(
66
+ runtimeCommand,
67
+ [targetPath, ...process.argv.slice(2)],
68
+ { stdio: 'inherit' },
69
+ );
70
+
71
+ if (result.error) {
72
+ process.stderr.write(
73
+ '[symlx] failed to launch TypeScript target via tsx: ' +
74
+ String(result.error) +
75
+ '\\n',
76
+ );
77
+ process.exit(1);
78
+ }
79
+
80
+ if (typeof result.status === 'number') {
81
+ process.exit(result.status);
82
+ }
83
+
84
+ if (result.signal) {
85
+ process.kill(process.pid, result.signal);
86
+ }
87
+
88
+ process.exit(1);
89
+ `;
90
+ }
91
+ function writeTsxLauncher(linkPath, runtimeCommand, target) {
92
+ node_fs_1.default.writeFileSync(linkPath, createTsxLauncherContent(runtimeCommand, target), 'utf8');
93
+ node_fs_1.default.chmodSync(linkPath, 0o755);
94
+ }
95
+ function matchesTsxLauncher(linkPath, runtimeCommand, target) {
96
+ try {
97
+ const content = node_fs_1.default.readFileSync(linkPath, 'utf8');
98
+ return content === createTsxLauncherContent(runtimeCommand, target);
99
+ }
100
+ catch {
101
+ return false;
102
+ }
103
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symlx",
3
- "version": "0.1.8",
3
+ "version": "0.1.11",
4
4
  "description": "Temporary local CLI bin linker",
5
5
  "license": "MIT",
6
6
  "bin": {