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 +268 -1
- package/dist/cli.js +3 -2
- package/dist/commands/serve.js +93 -45
- package/dist/lib/bin-targets.js +80 -0
- package/dist/lib/options.js +72 -26
- package/dist/lib/schema.js +38 -17
- package/dist/{services → lib}/session-store.js +5 -15
- package/dist/lib/utils.js +106 -4
- package/dist/lib/{validate.js → validator.js} +13 -5
- package/dist/postinstall.js +142 -0
- package/dist/preinstall.js +10 -0
- package/package.json +8 -4
- package/dist/core/paths.js +0 -32
- package/dist/core/types.js +0 -2
- package/dist/lib/paths.js +0 -26
- package/dist/lib/validators.js +0 -29
- package/dist/services/package-bins.js +0 -62
- /package/dist/{services → lib}/lifecycle.js +0 -0
- /package/dist/{services → lib}/link-manager.js +0 -0
- /package/dist/ui/{collision-prompt.js → prompts.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
|
@@ -50,11 +50,12 @@ async function main() {
|
|
|
50
50
|
.showHelpAfterError();
|
|
51
51
|
program
|
|
52
52
|
.command('serve')
|
|
53
|
-
.description("Link this project's
|
|
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
|
|
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
|
}
|
package/dist/commands/serve.js
CHANGED
|
@@ -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
|
|
38
|
-
const
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
106
|
+
links,
|
|
87
107
|
};
|
|
88
108
|
(0, session_store_1.persistSession)(sessionPath, sessionRecord);
|
|
89
|
-
|
|
109
|
+
return { sessionPath, sessionRecord };
|
|
110
|
+
}
|
|
111
|
+
function registerSessionCleanup(sessionPath, links) {
|
|
90
112
|
(0, lifecycle_1.registerLifecycleCleanup)(() => {
|
|
91
|
-
(0, session_store_1.cleanupSession)(sessionPath,
|
|
113
|
+
(0, session_store_1.cleanupSession)(sessionPath, links);
|
|
92
114
|
});
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
|
|
125
|
+
}
|
|
126
|
+
function printPathHintIfNeeded(binDir) {
|
|
127
|
+
if ((0, utils_1.pathContainsDir)(process.env.PATH, binDir)) {
|
|
128
|
+
return;
|
|
102
129
|
}
|
|
103
|
-
log.info(
|
|
104
|
-
|
|
105
|
-
|
|
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
|
|
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
|
+
}
|
package/dist/lib/options.js
CHANGED
|
@@ -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
|
|
11
|
-
const
|
|
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
|
-
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/schema.js
CHANGED
|
@@ -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.
|
|
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(
|
|
47
|
-
const
|
|
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:
|
|
88
|
+
bin: binRecordSchema.optional(),
|
|
89
|
+
binResolutionStrategy: zod_1.z.enum(['replace', 'merge']).optional(),
|
|
68
90
|
});
|
|
69
91
|
exports.configFileOptionsSchema = configFileOptionsSchema;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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("
|
|
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
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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.
|
|
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
|
|
16
|
-
const result =
|
|
16
|
+
function validatePackageJSONOptions(input) {
|
|
17
|
+
const result = schema_1.packageJSONOptionsSchema.safeParse(input || {});
|
|
17
18
|
if (!result.success) {
|
|
18
|
-
|
|
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
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Temporary local CLI bin linker",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
|
7
|
-
"symlx": "dist/
|
|
8
|
-
"cx": "dist/
|
|
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
|
-
"
|
|
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
|
}
|
package/dist/core/paths.js
DELETED
|
@@ -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
|
-
}
|
package/dist/core/types.js
DELETED
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
|
-
}
|
package/dist/lib/validators.js
DELETED
|
@@ -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
|