symlx 0.1.3 → 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
@@ -50,11 +50,12 @@ async function main() {
50
50
  .showHelpAfterError();
51
51
  program
52
52
  .command('serve')
53
- .description("Link this project's package.json bins until symlx exits")
53
+ .description("Link this project's bin commands until symlx exits")
54
54
  .option('--bin-dir <dir>', 'target bin directory (default: ~/.symlx/bin)')
55
55
  .option('--collision <policy>', 'collision mode: prompt|skip|fail|overwrite', 'prompt')
56
+ .option('--bin-resolution-strategy <strategy>', 'bin precedence strategy: replace|merge', 'replace')
56
57
  .option('--non-interactive', 'disable interactive prompts', false)
57
- .option('--bin <name=path>', 'custom bin mapping (repeatable), e.g. --bin my-cli=./cli.js', collectBinEntry, [])
58
+ .option('--bin <name=path>', 'custom bin mapping (repeatable), e.g. --bin my-cli=dist/cli.js', collectBinEntry, [])
58
59
  .action(serve_1.serveCommand);
59
60
  await program.parseAsync(process.argv);
60
61
  }
@@ -32,81 +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
39
  exports.serveCommand = serveCommand;
37
- const paths_1 = require("../lib/paths");
38
- const link_manager_1 = require("../services/link-manager");
39
- const lifecycle_1 = require("../services/lifecycle");
40
- // @ts-ignore
41
- const package_bins_1 = require("../services/package-bins");
42
- const session_store_1 = require("../services/session-store");
43
- const collision_prompt_1 = require("../ui/collision-prompt");
40
+ const path_1 = __importDefault(require("path"));
41
+ const node_os_1 = __importDefault(require("node:os"));
44
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");
45
49
  const options_1 = require("../lib/options");
46
50
  const schema_1 = require("../lib/schema");
47
51
  // Prompts require an interactive terminal; scripts/CI should avoid prompt mode.
48
52
  function isInteractiveSession() {
49
53
  return Boolean(process.stdin.isTTY && process.stdout.isTTY);
50
54
  }
51
- // Main symlx behavior:
52
- // 1) resolve bins from package.json
53
- // 2) create links
54
- // 3) persist session
55
- // 4) keep process alive and cleanup on exit
56
- async function run(options) {
57
- const cwd = process.cwd();
58
- const paths = (0, paths_1.getSymlxPaths)(options.binDir);
59
- // Prepare runtime directories and recover stale sessions from previous abnormal exits.
60
- (0, session_store_1.cleanupStaleSessions)(paths.sessionDir);
61
- (0, session_store_1.ensureSymlxDirectories)(paths.binDir, paths.sessionDir);
62
- const bins = (0, package_bins_1.readBins)(cwd);
63
- // Prompt policy only works when we can interact with a TTY.
64
- const usePrompts = options.collision === 'prompt' &&
65
- !options.nonInteractive &&
66
- isInteractiveSession();
67
- if (options.collision === 'prompt' && !usePrompts) {
68
- 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 };
69
62
  }
70
- // Link creation returns both successful links and explicit skips.
71
- const linkResult = await (0, link_manager_1.createLinks)({
72
- bins,
73
- binDir: paths.binDir,
74
- policy: options.collision,
75
- collisionResolver: usePrompts ? collision_prompt_1.promptCollisionDecision : undefined,
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' };
67
+ }
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,
76
79
  });
80
+ }
81
+ function ensureLinksWereCreated(linkResult) {
77
82
  if (linkResult.created.length === 0) {
78
- 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'));
79
97
  }
80
- // Session file is the source of truth for cleaning this exact run's links.
81
- 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);
82
102
  const sessionRecord = {
83
103
  pid: process.pid,
84
104
  cwd,
85
105
  createdAt: new Date().toISOString(),
86
- links: linkResult.created,
106
+ links,
87
107
  };
88
108
  (0, session_store_1.persistSession)(sessionPath, sessionRecord);
89
- // Always cleanup linked commands when this process leaves.
109
+ return { sessionPath, sessionRecord };
110
+ }
111
+ function registerSessionCleanup(sessionPath, links) {
90
112
  (0, lifecycle_1.registerLifecycleCleanup)(() => {
91
- (0, session_store_1.cleanupSession)(sessionPath, sessionRecord.links);
113
+ (0, session_store_1.cleanupSession)(sessionPath, links);
92
114
  });
93
- log.info(`linked ${linkResult.created.length} command(s) into ${paths.binDir}`);
94
- 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) {
95
120
  log.info(`${link.name} -> ${link.target}`);
96
121
  }
97
122
  for (const skip of linkResult.skipped) {
98
123
  log.warn(`skip "${skip.name}": ${skip.reason} (${skip.linkPath})`);
99
124
  }
100
- if (!(0, paths_1.pathContainsDir)(process.env.PATH, paths.binDir)) {
101
- 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;
102
129
  }
103
- log.info('running. press Ctrl+C to cleanup links.');
104
- // Keep process alive indefinitely; lifecycle handlers handle termination and cleanup.
105
- 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(() => {
106
134
  setInterval(() => undefined, 60_000);
107
135
  });
108
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
+ }
109
156
  function serveCommand(inlineOptions) {
110
- const options = (0, options_1.resolveOptions)(schema_1.serveInlineOptionsSchema, inlineOptions);
157
+ const cwd = process.cwd();
158
+ const options = (0, options_1.resolveOptions)(cwd, schema_1.serveInlineOptionsSchema, inlineOptions);
111
159
  return run(options);
112
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
+ }
@@ -7,40 +7,86 @@ exports.resolveOptions = resolveOptions;
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const node_os_1 = __importDefault(require("node:os"));
9
9
  const utils_1 = require("./utils");
10
- const validate_1 = require("./validate");
11
- const package_bins_1 = require("../services/package-bins");
12
- const defaultOptions = {
10
+ const validator_1 = require("./validator");
11
+ const DEFAULT_OPTIONS = {
13
12
  collision: 'prompt',
14
13
  nonInteractive: false,
15
14
  binDir: path_1.default.join(node_os_1.default.homedir(), '.symlx', 'bin'),
16
15
  bin: {},
16
+ binResolutionStrategy: 'replace',
17
17
  };
18
+ function hasBinEntries(bin) {
19
+ return Boolean(bin && Object.keys(bin).length > 0);
20
+ }
21
+ function computeResolvedBin(inlineBin, configFileBin, packageJSONBin, binResolutionStrategy) {
22
+ // Aggregates bin from all sources:
23
+ // inline + config + package.json + default
24
+ if (binResolutionStrategy === 'merge') {
25
+ return {
26
+ ...(packageJSONBin ?? {}),
27
+ ...(configFileBin ?? {}),
28
+ ...(inlineBin ?? {}),
29
+ };
30
+ }
31
+ // Bin source precedence is value-aware:
32
+ // inline (if non-empty) -> config (if non-empty) -> package.json (if non-empty) -> default.
33
+ return hasBinEntries(inlineBin)
34
+ ? inlineBin
35
+ : hasBinEntries(configFileBin)
36
+ ? configFileBin
37
+ : hasBinEntries(packageJSONBin)
38
+ ? packageJSONBin
39
+ : DEFAULT_OPTIONS.bin;
40
+ }
41
+ function withCwdPrefixedBin(cwd, bin) {
42
+ return Object.fromEntries(Object.entries(bin).map(([name, target]) => [
43
+ name,
44
+ path_1.default.resolve(cwd, target),
45
+ ]));
46
+ }
18
47
  // Function to aggregate all options from different sources in order or priority
19
- function resolveOptions(inlineOptionsSchema, inlineOptions) {
20
- // Load the bin from package.json
21
- const packageJSONOptions = (0, package_bins_1.loadPackageJSONOptions)(process.cwd());
22
- // Load and validate the options from the config file,
23
- // silently overriding invalid non-critical values with defaults or inline based on order of priority
24
- const configFileOptions = (0, utils_1.loadConfigFileOptions)();
25
- const validatedConfigFileOptions = (0, validate_1.validateConfigFileOptions)(configFileOptions);
26
- // Validate the CLI inline options if available
27
- const validatedInlineOptions = (0, validate_1.validateInlineOptions)(inlineOptionsSchema, inlineOptions);
28
- // Default options first
29
- // -> Config file options override defaults
30
- // -> CLI inline options overrides config file options
31
- const finalOptiions = {
32
- ...defaultOptions,
48
+ function resolveOptions(cwd, inlineOptionsSchema, inlineOptions) {
49
+ const packageJSONLoadResult = (0, utils_1.loadPackageJSONOptions)(cwd);
50
+ const validatedPackageJSONOptions = (0, validator_1.validatePackageJSONOptions)(packageJSONLoadResult);
51
+ const packageJSONIssues = [
52
+ ...packageJSONLoadResult.issues,
53
+ ...validatedPackageJSONOptions.issues,
54
+ ];
55
+ const fatalPackageIssue = packageJSONIssues.find((issue) => issue.startsWith('invalid package.json'));
56
+ if (fatalPackageIssue) {
57
+ throw new Error(fatalPackageIssue);
58
+ }
59
+ const configFileLoadResult = (0, utils_1.loadConfigFileOptions)(cwd);
60
+ if (configFileLoadResult.issue) {
61
+ throw new Error(configFileLoadResult.issue);
62
+ }
63
+ const validatedConfigFileOptions = (0, validator_1.validateConfigFileOptions)(configFileLoadResult.options);
64
+ const validatedInlineOptions = (0, validator_1.validateInlineOptions)(inlineOptionsSchema, inlineOptions);
65
+ const inlineBin = validatedInlineOptions
66
+ .bin;
67
+ const mergedOptions = {
68
+ ...DEFAULT_OPTIONS,
69
+ ...(validatedPackageJSONOptions ?? {}),
33
70
  ...(validatedConfigFileOptions ?? {}),
34
71
  ...(validatedInlineOptions ?? {}),
35
72
  };
36
- if (!Object.entries(finalOptiions.bin).length) {
37
- throw new Error([
38
- 'no bin entries found. add at least one bin in any of these places:',
39
- '1) package.json -> "bin": { "my-cli": "./cli.js" }',
40
- '2) symlx.config.json -> "bin": { "my-cli": "./cli.js" }',
41
- '3) inline CLI -> symlx serve --bin my-cli=./cli.js',
42
- '4) if package.json "bin" is a string, set a valid package.json "name" (used to infer the bin name).',
43
- ].join('\n'));
73
+ const resolvedBin = computeResolvedBin(inlineBin, validatedConfigFileOptions.bin, validatedPackageJSONOptions.bin, mergedOptions.binResolutionStrategy);
74
+ const finalOptions = {
75
+ ...mergedOptions,
76
+ bin: withCwdPrefixedBin(cwd, resolvedBin),
77
+ };
78
+ if (Object.keys(finalOptions.bin).length > 0) {
79
+ return finalOptions;
80
+ }
81
+ const primaryIssue = packageJSONIssues[0];
82
+ if (primaryIssue) {
83
+ throw new Error(primaryIssue);
44
84
  }
45
- return finalOptiions;
85
+ throw new Error([
86
+ 'no bin entries found.',
87
+ 'add at least one command in one of these places:',
88
+ '1) package.json -> "bin": { "my-cli": "./cli.js" }',
89
+ '2) symlx.config.json -> "bin": { "my-cli": "./cli.js" }',
90
+ '3) inline CLI -> symlx serve --bin my-cli=./cli.js',
91
+ ].join('\n'));
46
92
  }
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.serveInlineOptionsSchema = exports.configFileOptionsSchema = exports.binEntriesToRecordSchema = void 0;
36
+ exports.serveInlineOptionsSchema = exports.configFileOptionsSchema = exports.packageJSONOptionsSchema = void 0;
37
37
  const zod_1 = require("zod");
38
38
  const log = __importStar(require("../ui/logger"));
39
39
  const binNameSchema = zod_1.z
@@ -43,8 +43,29 @@ const binTargetSchema = zod_1.z
43
43
  .string()
44
44
  .trim()
45
45
  .min(1)
46
- .regex(/^\.{1,2}\//, 'bin target must be a relative path like ./cli.js');
47
- const customBinSchema = zod_1.z.record(binNameSchema, binTargetSchema);
46
+ .regex(/^(?!\/)(?![A-Za-z]:[\\/])(?!(?:\\\\|\/\/)).+/, 'bin target must be a relative path like ./cli.js');
47
+ const binRecordSchema = zod_1.z.record(binNameSchema, binTargetSchema);
48
+ const binEntrySchema = zod_1.z
49
+ .string()
50
+ .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?=(?!\/)(?![A-Za-z]:[\\/])(?!(?:\\\\|\/\/)).+$/, 'expected <name=relative/path>');
51
+ const binEntriesToRecordSchema = zod_1.z
52
+ .array(binEntrySchema)
53
+ .optional()
54
+ .default([])
55
+ .transform((entries) => Object.fromEntries(entries.map((entry) => {
56
+ const [name, target] = entry.split('=', 2);
57
+ return [name, target];
58
+ })));
59
+ // -------------------------------------------
60
+ // package.json Schema: Just bin for now
61
+ // -------------------------------------------
62
+ const packageJSONOptionsSchema = zod_1.z.object({
63
+ bin: binRecordSchema.optional(),
64
+ });
65
+ exports.packageJSONOptionsSchema = packageJSONOptionsSchema;
66
+ // -------------------------------------------
67
+ // symlx.config.json options: should allow configuring all options
68
+ // -------------------------------------------
48
69
  const configFileOptionsSchema = zod_1.z.object({
49
70
  binDir: zod_1.z
50
71
  .string()
@@ -64,21 +85,21 @@ const configFileOptionsSchema = zod_1.z.object({
64
85
  log.warn('invalid "nonInteractive" value in config file; using default.');
65
86
  return undefined;
66
87
  }),
67
- bin: customBinSchema.optional(),
88
+ bin: binRecordSchema.optional(),
89
+ binResolutionStrategy: zod_1.z.enum(['replace', 'merge']).optional(),
68
90
  });
69
91
  exports.configFileOptionsSchema = configFileOptionsSchema;
70
- const binEntrySchema = zod_1.z
71
- .string()
72
- .regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?=\.{1,2}\/.+$/, 'expected <name=./relative/path>');
73
- exports.binEntriesToRecordSchema = zod_1.z
74
- .array(binEntrySchema)
75
- .optional()
76
- .default([])
77
- .transform((entries) => Object.fromEntries(entries.map((entry) => {
78
- const [name, target] = entry.split('=', 2);
79
- return [name, target];
80
- })));
81
- const serveInlineOptionsSchema = configFileOptionsSchema.extend({
82
- bin: exports.binEntriesToRecordSchema,
92
+ // -------------------------------------------
93
+ // varying command inline options: highest priority in field:value resolution
94
+ // -------------------------------------------
95
+ const serveInlineOptionsSchema = configFileOptionsSchema
96
+ .pick({
97
+ binDir: true,
98
+ collision: true,
99
+ nonInteractive: true,
100
+ binResolutionStrategy: true,
101
+ })
102
+ .extend({
103
+ bin: binEntriesToRecordSchema,
83
104
  });
84
105
  exports.serveInlineOptionsSchema = serveInlineOptionsSchema;
@@ -44,7 +44,7 @@ exports.cleanupSession = cleanupSession;
44
44
  const node_fs_1 = __importDefault(require("node:fs"));
45
45
  const node_path_1 = __importDefault(require("node:path"));
46
46
  const log = __importStar(require("../ui/logger"));
47
- const utils_1 = require("../lib/utils");
47
+ const utils_1 = require("./utils");
48
48
  // Checks whether a PID from a previous session is still alive.
49
49
  function isProcessAlive(pid) {
50
50
  // PIDs are always positive integer typically less the 2^15
@@ -63,15 +63,6 @@ function isProcessAlive(pid) {
63
63
  return code !== 'ESRCH';
64
64
  }
65
65
  }
66
- // Session files are best-effort state; deletion failure should not fail the command.
67
- function deleteFile(filePath) {
68
- try {
69
- node_fs_1.default.unlinkSync(filePath);
70
- }
71
- catch {
72
- // Best-effort cleanup.
73
- }
74
- }
75
66
  // Removes only symlinks that still point to the exact targets we created.
76
67
  // This avoids deleting user-managed commands with the same name.
77
68
  function cleanupLinks(links) {
@@ -92,7 +83,6 @@ function cleanupLinks(links) {
92
83
  }
93
84
  }
94
85
  }
95
- // -----------------------------------------------------------
96
86
  // Ensures runtime directories exist before linking/saving sessions.
97
87
  function ensureSymlxDirectories(binDir, sessionDir) {
98
88
  node_fs_1.default.mkdirSync(binDir, { recursive: true });
@@ -110,19 +100,19 @@ function cleanupStaleSessions(sessionDir) {
110
100
  const filePath = node_path_1.default.join(sessionDir, entry);
111
101
  // Delete any files that are not .json, session files can only be JSON
112
102
  if (!entry.endsWith('.json')) {
113
- deleteFile(filePath);
103
+ (0, utils_1.deleteFile)(filePath);
114
104
  continue;
115
105
  }
116
106
  // If the expected file structure has been corrupted, delete the file
117
107
  const record = (0, utils_1.loadJSONFile)(filePath);
118
108
  if (!record) {
119
- deleteFile(filePath);
109
+ (0, utils_1.deleteFile)(filePath);
120
110
  continue;
121
111
  }
122
112
  // If process is dead, unlink the command from the bin and delete the session file
123
113
  if (!isProcessAlive(record.pid)) {
124
114
  cleanupLinks(record.links);
125
- deleteFile(filePath);
115
+ (0, utils_1.deleteFile)(filePath);
126
116
  cleanUpCount++;
127
117
  }
128
118
  }
@@ -142,5 +132,5 @@ function createSessionFilePath(sessionDir) {
142
132
  // Cleanup for the active process/session.
143
133
  function cleanupSession(sessionPath, links) {
144
134
  cleanupLinks(links);
145
- deleteFile(sessionPath);
135
+ (0, utils_1.deleteFile)(sessionPath);
146
136
  }
package/dist/lib/utils.js CHANGED
@@ -5,6 +5,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.loadJSONFile = loadJSONFile;
7
7
  exports.loadConfigFileOptions = loadConfigFileOptions;
8
+ exports.loadPackageJSONOptions = loadPackageJSONOptions;
9
+ exports.deleteFile = deleteFile;
10
+ exports.pathContainsDir = pathContainsDir;
8
11
  const node_fs_1 = __importDefault(require("node:fs"));
9
12
  const node_path_1 = __importDefault(require("node:path"));
10
13
  // Invalid/corrupted JSON files are ignored.
@@ -17,9 +20,108 @@ function loadJSONFile(filePath) {
17
20
  return undefined;
18
21
  }
19
22
  }
20
- function loadConfigFileOptions() {
21
- const cwd = process.cwd();
23
+ function formatReadError(error) {
24
+ if (error instanceof Error && error.message) {
25
+ return error.message;
26
+ }
27
+ return String(error);
28
+ }
29
+ function readJSONFileWithIssue(filePath, label) {
30
+ try {
31
+ const raw = node_fs_1.default.readFileSync(filePath, 'utf8');
32
+ return { data: JSON.parse(raw) };
33
+ }
34
+ catch (error) {
35
+ return {
36
+ issue: `invalid ${label} at ${filePath}: ${formatReadError(error)}`,
37
+ };
38
+ }
39
+ }
40
+ function loadConfigFileOptions(cwd) {
22
41
  const configPath = node_path_1.default.join(cwd, 'symlx.config.json');
23
- const configFileOptions = loadJSONFile(configPath);
24
- return configFileOptions;
42
+ if (!node_fs_1.default.existsSync(configPath)) {
43
+ return {};
44
+ }
45
+ const result = readJSONFileWithIssue(configPath, 'symlx.config.json');
46
+ if (result.issue) {
47
+ return { issue: result.issue };
48
+ }
49
+ return { options: result.data };
50
+ }
51
+ // npm allows `bin` as a string; in that form the command name defaults to package name
52
+ // (without scope for scoped packages).
53
+ function inferBinName(packageName) {
54
+ if (!packageName) {
55
+ return undefined;
56
+ }
57
+ if (packageName.startsWith('@')) {
58
+ const parts = packageName.split('/');
59
+ if (parts.length !== 2 || !parts[1]) {
60
+ return undefined;
61
+ }
62
+ return parts[1];
63
+ }
64
+ return packageName;
65
+ }
66
+ // Loads and validates all bin entries for the current project.
67
+ // Returned map is command name => absolute target file path.
68
+ function loadPackageJSONOptions(cwd) {
69
+ const packageJsonPath = node_path_1.default.join(cwd, 'package.json');
70
+ if (!node_fs_1.default.existsSync(packageJsonPath)) {
71
+ return {
72
+ bin: {},
73
+ issues: [`package.json not found at ${packageJsonPath}`],
74
+ };
75
+ }
76
+ const parsedPackageJSON = readJSONFileWithIssue(packageJsonPath, 'package.json');
77
+ if (parsedPackageJSON.issue) {
78
+ return {
79
+ bin: {},
80
+ issues: [parsedPackageJSON.issue],
81
+ };
82
+ }
83
+ const packageJson = parsedPackageJSON.data;
84
+ if (!packageJson || !packageJson.bin) {
85
+ return {
86
+ bin: {},
87
+ issues: [],
88
+ };
89
+ }
90
+ const bin = {};
91
+ const issues = [];
92
+ if (typeof packageJson.bin === 'string') {
93
+ const inferredBinName = inferBinName(packageJson.name);
94
+ if (inferredBinName) {
95
+ bin[inferredBinName] = packageJson.bin;
96
+ }
97
+ else {
98
+ issues.push('bin field is a string, but could not infer name, set a valid package.json "name"');
99
+ }
100
+ }
101
+ else {
102
+ for (const [name, relTarget] of Object.entries(packageJson.bin)) {
103
+ bin[name] = relTarget;
104
+ }
105
+ }
106
+ return { bin, issues };
107
+ }
108
+ // Session files are best-effort state; deletion failure should not fail the command.
109
+ function deleteFile(filePath) {
110
+ try {
111
+ node_fs_1.default.unlinkSync(filePath);
112
+ }
113
+ catch {
114
+ // Best-effort cleanup.
115
+ }
116
+ }
117
+ // Checks if PATH already contains a directory so we can avoid noisy setup hints.
118
+ function pathContainsDir(currentPath, targetDir) {
119
+ if (!currentPath) {
120
+ return false;
121
+ }
122
+ const resolvedTarget = node_path_1.default.resolve(targetDir);
123
+ const parts = currentPath
124
+ .split(node_path_1.default.delimiter)
125
+ .map((item) => node_path_1.default.resolve(item));
126
+ return parts.includes(resolvedTarget);
25
127
  }
@@ -1,7 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.validateInlineOptions = validateInlineOptions;
3
+ exports.validatePackageJSONOptions = validatePackageJSONOptions;
4
4
  exports.validateConfigFileOptions = validateConfigFileOptions;
5
+ exports.validateInlineOptions = validateInlineOptions;
5
6
  const schema_1 = require("./schema");
6
7
  function formatIssues(error) {
7
8
  const details = error.issues
@@ -12,12 +13,12 @@ function formatIssues(error) {
12
13
  .join('; ');
13
14
  return details || 'invalid input';
14
15
  }
15
- function validateInlineOptions(schema, input, label = 'input') {
16
- const result = schema.safeParse(input || {});
16
+ function validatePackageJSONOptions(input) {
17
+ const result = schema_1.packageJSONOptionsSchema.safeParse(input || {});
17
18
  if (!result.success) {
18
- throw new Error(`invalid ${label}: ${formatIssues(result.error)}`);
19
+ return { bin: {}, issues: result.error.issues.map((i) => i.message) };
19
20
  }
20
- return result.data;
21
+ return { ...result.data, issues: [] };
21
22
  }
22
23
  function validateConfigFileOptions(input, label = 'input') {
23
24
  const result = schema_1.configFileOptionsSchema.safeParse(input || {});
@@ -26,3 +27,10 @@ function validateConfigFileOptions(input, label = 'input') {
26
27
  }
27
28
  return result.data;
28
29
  }
30
+ function validateInlineOptions(schema, input, label = 'input') {
31
+ const result = schema.safeParse(input || {});
32
+ if (!result.success) {
33
+ throw new Error(`invalid ${label}: ${formatIssues(result.error)}`);
34
+ }
35
+ return result.data;
36
+ }
@@ -0,0 +1,142 @@
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
+ const node_fs_1 = __importDefault(require("node:fs"));
7
+ const node_os_1 = __importDefault(require("node:os"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ const PREFIX = '[symlx]';
10
+ const START = '# >>> symlx path >>>';
11
+ const END = '# <<< symlx path <<<';
12
+ const BIN_PATH = '$HOME/.symlx/bin';
13
+ const PROFILE_BASENAMES = ['.zprofile', '.zshrc', '.bashrc'];
14
+ function info(message) {
15
+ process.stdout.write(`${PREFIX} ${message}\n`);
16
+ }
17
+ function warn(message) {
18
+ process.stderr.write(`${PREFIX} ${message}\n`);
19
+ }
20
+ function printManualPathSetupGuidance() {
21
+ if (process.platform === 'win32') {
22
+ info('manual setup (PowerShell):');
23
+ info('[Environment]::SetEnvironmentVariable("Path", "$env:USERPROFILE\\\\.symlx\\\\bin;$env:Path", "User")');
24
+ info('then open a new terminal');
25
+ return;
26
+ }
27
+ info('manual setup: add this to ~/.zshrc, ~/.zprofile, or ~/.bashrc');
28
+ info(START);
29
+ info('if [[ ":$PATH:" != *":$HOME/.symlx/bin:"* ]]; then');
30
+ info(' export PATH="$HOME/.symlx/bin:$PATH"');
31
+ info('fi');
32
+ info(END);
33
+ info('then run: source ~/.zshrc (or your active shell profile)');
34
+ }
35
+ function resolveProfilePaths(homeDir) {
36
+ return PROFILE_BASENAMES.map((basename) => node_path_1.default.join(homeDir, basename));
37
+ }
38
+ function toHomeRelativePath(filePath, homeDir) {
39
+ if (filePath.startsWith(`${homeDir}${node_path_1.default.sep}`)) {
40
+ return `~/${node_path_1.default.relative(homeDir, filePath)}`;
41
+ }
42
+ return filePath;
43
+ }
44
+ function getPreferredSourcePath(updatedPaths) {
45
+ const shell = process.env.SHELL ?? '';
46
+ const preferredBasename = shell.includes('zsh')
47
+ ? '.zshrc'
48
+ : shell.includes('bash')
49
+ ? '.bashrc'
50
+ : undefined;
51
+ if (!preferredBasename) {
52
+ return updatedPaths[0];
53
+ }
54
+ return (updatedPaths.find((filePath) => node_path_1.default.basename(filePath) === preferredBasename) ??
55
+ updatedPaths[0]);
56
+ }
57
+ function escapeRegExp(value) {
58
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
59
+ }
60
+ function buildPathBlock() {
61
+ return [
62
+ START,
63
+ `if [[ ":$PATH:" != *":${BIN_PATH}:"* ]]; then`,
64
+ ` export PATH="${BIN_PATH}:$PATH"`,
65
+ 'fi',
66
+ END,
67
+ ].join('\n');
68
+ }
69
+ function ensureTrailingNewline(value) {
70
+ return value.endsWith('\n') ? value : `${value}\n`;
71
+ }
72
+ function upsertProfileBlock(filePath, block) {
73
+ const exists = node_fs_1.default.existsSync(filePath);
74
+ const current = exists ? node_fs_1.default.readFileSync(filePath, 'utf8') : '';
75
+ const normalizedCurrent = current.replace(/\r\n/g, '\n');
76
+ const markerPattern = new RegExp(`${escapeRegExp(START)}[\\s\\S]*?${escapeRegExp(END)}\\n?`, 'm');
77
+ let next;
78
+ if (markerPattern.test(normalizedCurrent)) {
79
+ next = normalizedCurrent.replace(markerPattern, `${block}\n`);
80
+ }
81
+ else if (normalizedCurrent.trim().length === 0) {
82
+ next = `${block}\n`;
83
+ }
84
+ else {
85
+ next = `${ensureTrailingNewline(normalizedCurrent)}\n${block}\n`;
86
+ }
87
+ if (next === normalizedCurrent) {
88
+ return false;
89
+ }
90
+ node_fs_1.default.writeFileSync(filePath, next, 'utf8');
91
+ return true;
92
+ }
93
+ function run() {
94
+ if (process.env.SYMLX_SKIP_PATH_SETUP === '1') {
95
+ info('skipping PATH setup because SYMLX_SKIP_PATH_SETUP=1');
96
+ printManualPathSetupGuidance();
97
+ return;
98
+ }
99
+ if (process.platform === 'win32') {
100
+ info('skipping shell profile PATH setup on Windows');
101
+ printManualPathSetupGuidance();
102
+ return;
103
+ }
104
+ const homeDir = node_os_1.default.homedir();
105
+ if (!homeDir) {
106
+ warn('could not resolve home directory; skipping PATH setup');
107
+ printManualPathSetupGuidance();
108
+ return;
109
+ }
110
+ const block = buildPathBlock();
111
+ const profilePaths = resolveProfilePaths(homeDir);
112
+ const existingPaths = profilePaths.filter((filePath) => node_fs_1.default.existsSync(filePath));
113
+ const targets = existingPaths.length > 0 ? existingPaths : [profilePaths[0]];
114
+ const updated = [];
115
+ for (const target of targets) {
116
+ try {
117
+ const changed = upsertProfileBlock(target, block);
118
+ if (changed) {
119
+ updated.push(target);
120
+ }
121
+ }
122
+ catch (error) {
123
+ warn(`could not update ${target}: ${String(error)}`);
124
+ }
125
+ }
126
+ if (updated.length > 0) {
127
+ info(`added ${BIN_PATH} to PATH in:`);
128
+ for (const target of updated) {
129
+ info(`- ${target}`);
130
+ }
131
+ const preferredSourcePath = getPreferredSourcePath(updated);
132
+ if (preferredSourcePath) {
133
+ const sourceTarget = toHomeRelativePath(preferredSourcePath, homeDir);
134
+ info(`run now: source ${sourceTarget}`);
135
+ }
136
+ info('or open a new shell to apply immediately');
137
+ }
138
+ else {
139
+ info(`PATH setup already present (${BIN_PATH})`);
140
+ }
141
+ }
142
+ run();
@@ -0,0 +1,10 @@
1
+ "use strict";
2
+ const PREFIX = '[symlx]';
3
+ function info(message) {
4
+ process.stdout.write(`${PREFIX} ${message}\n`);
5
+ }
6
+ function run() {
7
+ info('notice: install will update your shell profile PATH with $HOME/.symlx/bin');
8
+ info('set SYMLX_SKIP_PATH_SETUP=1 to skip automatic PATH setup');
9
+ }
10
+ run();
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "symlx",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Temporary local CLI bin linker",
5
5
  "license": "MIT",
6
6
  "bin": {
7
- "symlx": "dist/clii.js",
8
- "cx": "dist/clii.js"
7
+ "symlx": "dist/cli.js",
8
+ "cx": "dist/cli.js"
9
9
  },
10
10
  "files": [
11
11
  "dist",
@@ -31,6 +31,10 @@
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
34
- "check": "tsc -p tsconfig.json --noEmit"
34
+ "watch": "tsc -p tsconfig.json --watch --preserveWatchOutput",
35
+ "check": "tsc -p tsconfig.json --noEmit",
36
+ "test": "pnpm run build && tsc -p tsconfig.test.json && node --test .tmp-tests/test/**/*.test.js",
37
+ "preinstall": "node dist/preinstall.js",
38
+ "postinstall": "node dist/postinstall.js"
35
39
  }
36
40
  }
@@ -1,32 +0,0 @@
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.getSymlxPaths = getSymlxPaths;
7
- exports.pathContainsDir = pathContainsDir;
8
- const node_os_1 = __importDefault(require("node:os"));
9
- const node_path_1 = __importDefault(require("node:path"));
10
- // Central place for runtime paths so every command/service resolves locations consistently.
11
- function getSymlxPaths(customBinDir) {
12
- // symlx keeps mutable runtime state under the user's home directory.
13
- const rootSymlxDir = node_path_1.default.join(node_os_1.default.homedir(), '.symlx');
14
- // Commands are linked here unless the caller overrides with --bin-dir.
15
- const binDir = customBinDir
16
- ? node_path_1.default.resolve(customBinDir)
17
- : node_path_1.default.join(rootSymlxDir, 'bin');
18
- // Session files live separately from bins and are used for stale cleanup.
19
- const sessionDir = node_path_1.default.join(rootSymlxDir, 'sessions');
20
- return { binDir, sessionDir };
21
- }
22
- // Checks if PATH already contains a directory so we can avoid noisy setup hints.
23
- function pathContainsDir(currentPath, targetDir) {
24
- if (!currentPath) {
25
- return false;
26
- }
27
- const resolvedTarget = node_path_1.default.resolve(targetDir);
28
- const parts = currentPath
29
- .split(node_path_1.default.delimiter)
30
- .map((item) => node_path_1.default.resolve(item));
31
- return parts.includes(resolvedTarget);
32
- }
@@ -1,2 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
package/dist/lib/paths.js DELETED
@@ -1,26 +0,0 @@
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.getSymlxPaths = getSymlxPaths;
7
- exports.pathContainsDir = pathContainsDir;
8
- const node_os_1 = __importDefault(require("node:os"));
9
- const node_path_1 = __importDefault(require("node:path"));
10
- function getSymlxPaths(binDir) {
11
- // Keep mutable runtime state under the user's home directory.
12
- // Session files live separately from bins and are used for stale cleanup.
13
- const sessionDir = node_path_1.default.join(node_os_1.default.homedir(), '.symlx', 'sessions');
14
- return { binDir, sessionDir };
15
- }
16
- // Checks if PATH already contains a directory so we can avoid noisy setup hints.
17
- function pathContainsDir(currentPath, targetDir) {
18
- if (!currentPath) {
19
- return false;
20
- }
21
- const resolvedTarget = node_path_1.default.resolve(targetDir);
22
- const parts = currentPath
23
- .split(node_path_1.default.delimiter)
24
- .map((item) => node_path_1.default.resolve(item));
25
- return parts.includes(resolvedTarget);
26
- }
@@ -1,29 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.serveOptionsSchema = exports.collisionPolicySchema = void 0;
4
- exports.validate = validate;
5
- const zod_1 = require("zod");
6
- const collisionPolicySchema = zod_1.z.enum(['prompt', 'skip', 'fail', 'overwrite']);
7
- exports.collisionPolicySchema = collisionPolicySchema;
8
- const serveOptionsSchema = zod_1.z.object({
9
- binDir: zod_1.z.string().trim().min(1).optional(),
10
- collision: collisionPolicySchema,
11
- nonInteractive: zod_1.z.boolean(),
12
- });
13
- exports.serveOptionsSchema = serveOptionsSchema;
14
- function formatIssues(error) {
15
- const details = error.issues
16
- .map((issue) => {
17
- const pathLabel = issue.path.length > 0 ? issue.path.join('.') : 'value';
18
- return `${pathLabel}: ${issue.message}`;
19
- })
20
- .join('; ');
21
- return details || 'invalid input';
22
- }
23
- function validate(schema, input, label = 'input') {
24
- const result = schema.safeParse(input);
25
- if (!result.success) {
26
- throw new Error(`invalid ${label}: ${formatIssues(result.error)}`);
27
- }
28
- return result.data;
29
- }
@@ -1,62 +0,0 @@
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.loadPackageJSONOptions = loadPackageJSONOptions;
7
- const node_fs_1 = __importDefault(require("node:fs"));
8
- const node_path_1 = __importDefault(require("node:path"));
9
- const utils_1 = require("../lib/utils");
10
- // npm allows `bin` as a string; in that form the command name defaults to package name
11
- // (without scope for scoped packages).
12
- function inferBinName(packageName) {
13
- if (!packageName) {
14
- return undefined;
15
- }
16
- if (packageName.startsWith('@')) {
17
- const parts = packageName.split('/');
18
- if (parts.length !== 2 || !parts[1]) {
19
- return undefined;
20
- }
21
- return parts[1];
22
- }
23
- return packageName;
24
- }
25
- // Loads and validates all bin entries for the current project.
26
- // Returned map is command name => absolute target file path.
27
- function loadPackageJSONOptions(cwd) {
28
- const packageJsonPath = node_path_1.default.join(cwd, 'package.json');
29
- if (!node_fs_1.default.existsSync(packageJsonPath)) {
30
- return {
31
- bin: {},
32
- };
33
- }
34
- const packageJson = (0, utils_1.loadJSONFile)(packageJsonPath);
35
- if (!packageJson || !packageJson.bin) {
36
- return {
37
- bin: {},
38
- };
39
- }
40
- const bins = {};
41
- if (typeof packageJson.bin === 'string') {
42
- const inferredBinName = inferBinName(packageJson.name);
43
- if (inferredBinName) {
44
- bins[inferredBinName] = node_path_1.default.resolve(cwd, packageJson.bin);
45
- }
46
- }
47
- else {
48
- for (const [name, relTarget] of Object.entries(packageJson.bin)) {
49
- bins[name] = node_path_1.default.resolve(cwd, relTarget);
50
- }
51
- }
52
- // if (bins.size === 0) {
53
- // throw new Error('no bin entries found');
54
- // }
55
- // // Fail fast if package.json points to non-existing executables.
56
- // for (const [name, target] of bins.entries()) {
57
- // if (!fs.existsSync(target)) {
58
- // throw new Error(`bin target for "${name}" does not exist: ${target}`);
59
- // }
60
- // }
61
- return { bin: {} };
62
- }
File without changes
File without changes
File without changes