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