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 +268 -1
- package/dist/cli.js +14 -24
- package/dist/commands/serve.js +99 -44
- package/dist/lib/bin-targets.js +80 -0
- package/dist/{services → lib}/link-manager.js +22 -15
- package/dist/lib/options.js +92 -0
- package/dist/lib/schema.js +105 -0
- package/dist/{services → lib}/session-store.js +46 -25
- package/dist/lib/utils.js +127 -0
- package/dist/lib/validator.js +36 -0
- package/dist/postinstall.js +142 -0
- package/dist/preinstall.js +10 -0
- package/dist/ui/{collision-prompt.js → prompts.js} +15 -15
- package/package.json +8 -3
- package/dist/core/paths.js +0 -32
- package/dist/services/package-bins.js +0 -58
- /package/dist/{services → lib}/lifecycle.js +0 -0
- /package/dist/{core → lib}/types.js +0 -0
package/README.md
CHANGED
|
@@ -1,3 +1,270 @@
|
|
|
1
1
|
# symlx
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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(
|
|
54
|
-
.description(
|
|
48
|
+
.name('symlx')
|
|
49
|
+
.description('Temporary CLI bin linker with lifecycle cleanup')
|
|
55
50
|
.showHelpAfterError();
|
|
56
51
|
program
|
|
57
|
-
.command(
|
|
58
|
-
.description("Link this project's
|
|
59
|
-
.option(
|
|
60
|
-
.option(
|
|
61
|
-
.option(
|
|
62
|
-
.
|
|
63
|
-
|
|
64
|
-
|
|
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.
|
package/dist/commands/serve.js
CHANGED
|
@@ -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.
|
|
37
|
-
const
|
|
38
|
-
const
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
|
106
|
+
links,
|
|
84
107
|
};
|
|
85
108
|
(0, session_store_1.persistSession)(sessionPath, sessionRecord);
|
|
86
|
-
|
|
109
|
+
return { sessionPath, sessionRecord };
|
|
110
|
+
}
|
|
111
|
+
function registerSessionCleanup(sessionPath, links) {
|
|
87
112
|
(0, lifecycle_1.registerLifecycleCleanup)(() => {
|
|
88
|
-
(0, session_store_1.cleanupSession)(sessionPath,
|
|
113
|
+
(0, session_store_1.cleanupSession)(sessionPath, links);
|
|
89
114
|
});
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
98
|
-
|
|
125
|
+
}
|
|
126
|
+
function printPathHintIfNeeded(binDir) {
|
|
127
|
+
if ((0, utils_1.pathContainsDir)(process.env.PATH, binDir)) {
|
|
128
|
+
return;
|
|
99
129
|
}
|
|
100
|
-
log.info(
|
|
101
|
-
|
|
102
|
-
|
|
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 ===
|
|
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
|
-
:
|
|
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:
|
|
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 &&
|
|
83
|
-
|
|
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 ===
|
|
88
|
-
decision =
|
|
92
|
+
if (policy === 'skip') {
|
|
93
|
+
decision = 'skip';
|
|
89
94
|
}
|
|
90
|
-
else if (policy ===
|
|
91
|
-
decision =
|
|
95
|
+
else if (policy === 'overwrite') {
|
|
96
|
+
decision = 'overwrite';
|
|
92
97
|
}
|
|
93
|
-
else if (policy ===
|
|
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
|
|
102
|
+
decision = collisionResolver
|
|
103
|
+
? await collisionResolver(conflict)
|
|
104
|
+
: 'skip';
|
|
98
105
|
}
|
|
99
|
-
if (decision ===
|
|
106
|
+
if (decision === 'abort') {
|
|
100
107
|
throw new Error(`aborted on collision for command "${name}"`);
|
|
101
108
|
}
|
|
102
|
-
if (decision ===
|
|
109
|
+
if (decision === 'skip') {
|
|
103
110
|
skipped.push({ name, linkPath, reason: conflict.reason });
|
|
104
111
|
continue;
|
|
105
112
|
}
|