symlx 0.1.0

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 ADDED
@@ -0,0 +1,63 @@
1
+ # zlx
2
+
3
+ Temporary CLI bin linker for local development.
4
+
5
+ ## What it does
6
+
7
+ `zlx serve` reads the current project's `package.json` `bin` entries and symlinks them into:
8
+
9
+ `~/.zlx/bin`
10
+
11
+ While `zlx serve` is running, those commands can be used from anywhere (if `~/.zlx/bin` is on your `PATH`).
12
+
13
+ When the process exits, it removes only the links it created.
14
+
15
+ ## Command framework and structure
16
+
17
+ `zlx` uses:
18
+
19
+ - `commander` for command orchestration and typed options.
20
+ - `prompts` for interactive/TUI collision handling.
21
+
22
+ Project layout:
23
+
24
+ - `src/commands` command handlers
25
+ - `src/services` bin/session/lifecycle services
26
+ - `src/core` shared types/path helpers
27
+ - `src/ui` terminal UI prompts/logging
28
+
29
+ ## Usage
30
+
31
+ ```bash
32
+ npx zlx serve
33
+ ```
34
+
35
+ or if installed globally:
36
+
37
+ ```bash
38
+ zlx serve
39
+ ```
40
+
41
+ ### Serve options
42
+
43
+ ```bash
44
+ zlx serve --collision prompt
45
+ zlx serve --collision skip
46
+ zlx serve --collision fail
47
+ zlx serve --collision overwrite
48
+ zlx serve --non-interactive
49
+ zlx serve --bin-dir /custom/bin
50
+ ```
51
+
52
+ If your shell does not already include `~/.zlx/bin`, add:
53
+
54
+ ```bash
55
+ export PATH="$HOME/.zlx/bin:$PATH"
56
+ ```
57
+
58
+ ## Notes
59
+
60
+ - Prompt mode lets you choose overwrite/skip/abort when a command name already exists.
61
+ - In non-interactive sessions, prompt mode falls back to skip.
62
+ - Stale links from dead sessions are cleaned on the next startup.
63
+ - `kill -9` cannot cleanup instantly, but stale cleanup runs next time.
package/dist/cli.js ADDED
@@ -0,0 +1,76 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ const commander_1 = require("commander");
38
+ const serve_1 = require("./commands/serve");
39
+ const log = __importStar(require("./ui/logger"));
40
+ // Accepted values for --collision.
41
+ const ALLOWED_COLLISIONS = new Set(["prompt", "skip", "fail", "overwrite"]);
42
+ // Converts raw CLI input into a validated union type used by the serve command.
43
+ function parseCollisionPolicy(value) {
44
+ if (!ALLOWED_COLLISIONS.has(value)) {
45
+ throw new Error(`invalid collision policy "${value}". expected: prompt|skip|fail|overwrite`);
46
+ }
47
+ return value;
48
+ }
49
+ async function main() {
50
+ // Commander orchestrates top-level commands/options and help output.
51
+ const program = new commander_1.Command();
52
+ program
53
+ .name("zlx")
54
+ .description("Temporary CLI bin linker with lifecycle cleanup")
55
+ .showHelpAfterError();
56
+ program
57
+ .command("serve")
58
+ .description("Link this project's package.json bins until zlx exits")
59
+ .option("--bin-dir <dir>", "target bin directory (default: ~/.zlx/bin)")
60
+ .option("--collision <policy>", "collision mode: prompt|skip|fail|overwrite", "prompt")
61
+ .option("--non-interactive", "disable interactive prompts", false)
62
+ .action(async (options) => {
63
+ // Delegate all runtime behavior to the command module.
64
+ await (0, serve_1.runServe)({
65
+ binDir: options.binDir,
66
+ collision: parseCollisionPolicy(options.collision),
67
+ nonInteractive: options.nonInteractive
68
+ });
69
+ });
70
+ await program.parseAsync(process.argv);
71
+ }
72
+ // Centralized fatal error boundary for command execution.
73
+ main().catch((error) => {
74
+ log.error(String(error));
75
+ process.exit(1);
76
+ });
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.runServe = runServe;
37
+ const paths_1 = require("../core/paths");
38
+ const link_manager_1 = require("../services/link-manager");
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");
43
+ const log = __importStar(require("../ui/logger"));
44
+ // Prompts require an interactive terminal; scripts/CI should avoid prompt mode.
45
+ function isInteractiveSession() {
46
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
47
+ }
48
+ // Main zlx behavior:
49
+ // 1) resolve bins from package.json
50
+ // 2) create links
51
+ // 3) persist session
52
+ // 4) keep process alive and cleanup on exit
53
+ async function runServe(options) {
54
+ const cwd = process.cwd();
55
+ const paths = (0, paths_1.getZlxPaths)(options.binDir);
56
+ // Prepare runtime directories and recover stale sessions from previous abnormal exits.
57
+ (0, session_store_1.ensureZlxDirectories)(paths.binDir, paths.sessionDir);
58
+ (0, session_store_1.cleanupStaleSessions)(paths.sessionDir);
59
+ const bins = (0, package_bins_1.readBins)(cwd);
60
+ // Prompt policy only works when we can interact with a TTY.
61
+ const usePrompts = options.collision === "prompt" && !options.nonInteractive && isInteractiveSession();
62
+ if (options.collision === "prompt" && !usePrompts) {
63
+ log.warn("prompt collision mode requested but session is non-interactive; falling back to skip");
64
+ }
65
+ // Link creation returns both successful links and explicit skips.
66
+ const linkResult = await (0, link_manager_1.createLinks)({
67
+ bins,
68
+ binDir: paths.binDir,
69
+ policy: options.collision,
70
+ collisionResolver: usePrompts ? collision_prompt_1.promptCollisionDecision : undefined
71
+ });
72
+ if (linkResult.created.length === 0) {
73
+ throw new Error("no links were created");
74
+ }
75
+ // Session file is the source of truth for cleaning this exact run's links.
76
+ const sessionPath = (0, session_store_1.createSessionFilePath)(paths.sessionDir);
77
+ const sessionRecord = {
78
+ pid: process.pid,
79
+ cwd,
80
+ createdAt: new Date().toISOString(),
81
+ links: linkResult.created
82
+ };
83
+ (0, session_store_1.persistSession)(sessionPath, sessionRecord);
84
+ // Always cleanup linked commands when this process leaves.
85
+ (0, lifecycle_1.registerLifecycleCleanup)(() => {
86
+ (0, session_store_1.cleanupSession)(sessionPath, sessionRecord.links);
87
+ });
88
+ log.info(`linked ${linkResult.created.length} command(s) into ${paths.binDir}`);
89
+ for (const link of linkResult.created) {
90
+ log.info(`${link.name} -> ${link.target}`);
91
+ }
92
+ for (const skip of linkResult.skipped) {
93
+ log.warn(`skip "${skip.name}": ${skip.reason} (${skip.linkPath})`);
94
+ }
95
+ if (!(0, paths_1.pathContainsDir)(process.env.PATH, paths.binDir)) {
96
+ log.info(`add this to your shell config if needed:\nexport PATH="${paths.binDir}:$PATH"`);
97
+ }
98
+ log.info("running. press Ctrl+C to cleanup links.");
99
+ // Keep process alive indefinitely; lifecycle handlers handle termination and cleanup.
100
+ await new Promise(() => {
101
+ setInterval(() => undefined, 60_000);
102
+ });
103
+ }
@@ -0,0 +1,28 @@
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.getZlxPaths = getZlxPaths;
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 getZlxPaths(customBinDir) {
12
+ // zlx keeps mutable runtime state under the user's home directory.
13
+ const rootDir = node_path_1.default.join(node_os_1.default.homedir(), ".zlx");
14
+ // Commands are linked here unless the caller overrides with --bin-dir.
15
+ const binDir = customBinDir ? node_path_1.default.resolve(customBinDir) : node_path_1.default.join(rootDir, "bin");
16
+ // Session files live separately from bins and are used for stale cleanup.
17
+ const sessionDir = node_path_1.default.join(rootDir, "sessions");
18
+ return { rootDir, binDir, sessionDir };
19
+ }
20
+ // Checks if PATH already contains a directory so we can avoid noisy setup hints.
21
+ function pathContainsDir(currentPath, targetDir) {
22
+ if (!currentPath) {
23
+ return false;
24
+ }
25
+ const resolvedTarget = node_path_1.default.resolve(targetDir);
26
+ const parts = currentPath.split(node_path_1.default.delimiter).map((item) => node_path_1.default.resolve(item));
27
+ return parts.includes(resolvedTarget);
28
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,36 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerLifecycleCleanup = registerLifecycleCleanup;
4
+ // Registers robust process-exit handling so linked commands are removed reliably.
5
+ // Cleanup is idempotent and can be triggered by normal exit, signals, or fatal errors.
6
+ function registerLifecycleCleanup(cleanup) {
7
+ let cleaned = false;
8
+ const runCleanup = () => {
9
+ if (cleaned) {
10
+ return;
11
+ }
12
+ cleaned = true;
13
+ cleanup();
14
+ };
15
+ // Normal termination path.
16
+ process.on("exit", runCleanup);
17
+ const onSignal = () => {
18
+ runCleanup();
19
+ process.exit(0);
20
+ };
21
+ // Common interactive stop signals.
22
+ process.on("SIGINT", onSignal);
23
+ process.on("SIGTERM", onSignal);
24
+ process.on("SIGHUP", onSignal);
25
+ // Fatal process events still attempt cleanup before exiting with failure.
26
+ process.on("uncaughtException", (error) => {
27
+ process.stderr.write(`[zlx] uncaught exception: ${String(error)}\n`);
28
+ runCleanup();
29
+ process.exit(1);
30
+ });
31
+ process.on("unhandledRejection", (reason) => {
32
+ process.stderr.write(`[zlx] unhandled rejection: ${String(reason)}\n`);
33
+ runCleanup();
34
+ process.exit(1);
35
+ });
36
+ }
@@ -0,0 +1,112 @@
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.createLinks = createLinks;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ // lstat wrapper that treats missing files as "not found" but rethrows real IO errors.
10
+ function tryLstat(filePath) {
11
+ try {
12
+ return node_fs_1.default.lstatSync(filePath);
13
+ }
14
+ catch (error) {
15
+ const code = error.code;
16
+ if (code === "ENOENT") {
17
+ return undefined;
18
+ }
19
+ throw error;
20
+ }
21
+ }
22
+ // Reads an existing command path and resolves the symlink target when possible.
23
+ function inspectExistingNode(linkPath) {
24
+ const stats = tryLstat(linkPath);
25
+ if (!stats) {
26
+ return undefined;
27
+ }
28
+ if (!stats.isSymbolicLink()) {
29
+ return { stats };
30
+ }
31
+ try {
32
+ const rawTarget = node_fs_1.default.readlinkSync(linkPath);
33
+ const existingTarget = node_path_1.default.resolve(node_path_1.default.dirname(linkPath), rawTarget);
34
+ return { stats, existingTarget };
35
+ }
36
+ catch {
37
+ return { stats };
38
+ }
39
+ }
40
+ // Removes an existing file/symlink to make room for a new command link.
41
+ // We do not delete directories to avoid destructive behavior.
42
+ function removeExistingNode(linkPath, node) {
43
+ if (node.stats.isDirectory() && !node.stats.isSymbolicLink()) {
44
+ throw new Error(`cannot overwrite directory at ${linkPath}`);
45
+ }
46
+ node_fs_1.default.unlinkSync(linkPath);
47
+ }
48
+ // Normalizes filesystem state into a user-facing collision descriptor.
49
+ function toConflict(name, linkPath, target, node) {
50
+ if (node.stats.isSymbolicLink()) {
51
+ return {
52
+ name,
53
+ linkPath,
54
+ target,
55
+ reason: node.existingTarget
56
+ ? `already linked to ${node.existingTarget}`
57
+ : "already exists as symlink",
58
+ existingTarget: node.existingTarget,
59
+ isSymlink: true
60
+ };
61
+ }
62
+ return {
63
+ name,
64
+ linkPath,
65
+ target,
66
+ reason: "already exists as a file",
67
+ isSymlink: false
68
+ };
69
+ }
70
+ // Creates symlinks for all project bins according to the selected collision strategy.
71
+ // This function is pure with regard to policy: caller decides interactive vs non-interactive.
72
+ async function createLinks(params) {
73
+ const { bins, binDir, policy, collisionResolver } = params;
74
+ const created = [];
75
+ const skipped = [];
76
+ for (const [name, target] of bins.entries()) {
77
+ const linkPath = node_path_1.default.join(binDir, name);
78
+ const existingNode = inspectExistingNode(linkPath);
79
+ if (existingNode) {
80
+ const conflict = toConflict(name, linkPath, target, existingNode);
81
+ // Reusing the exact same link is always a no-op.
82
+ if (conflict.existingTarget && node_path_1.default.resolve(conflict.existingTarget) === node_path_1.default.resolve(target)) {
83
+ skipped.push({ name, linkPath, reason: "already linked to requested target" });
84
+ continue;
85
+ }
86
+ let decision;
87
+ if (policy === "skip") {
88
+ decision = "skip";
89
+ }
90
+ else if (policy === "overwrite") {
91
+ decision = "overwrite";
92
+ }
93
+ else if (policy === "fail") {
94
+ throw new Error(`command "${name}" conflicts at ${linkPath}: ${conflict.reason}`);
95
+ }
96
+ else {
97
+ decision = collisionResolver ? await collisionResolver(conflict) : "skip";
98
+ }
99
+ if (decision === "abort") {
100
+ throw new Error(`aborted on collision for command "${name}"`);
101
+ }
102
+ if (decision === "skip") {
103
+ skipped.push({ name, linkPath, reason: conflict.reason });
104
+ continue;
105
+ }
106
+ removeExistingNode(linkPath, existingNode);
107
+ }
108
+ node_fs_1.default.symlinkSync(target, linkPath);
109
+ created.push({ name, linkPath, target });
110
+ }
111
+ return { created, skipped };
112
+ }
@@ -0,0 +1,58 @@
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.readBins = readBins;
7
+ const node_fs_1 = __importDefault(require("node:fs"));
8
+ const node_path_1 = __importDefault(require("node:path"));
9
+ // npm allows `bin` as a string; in that form the command name defaults to package name
10
+ // (without scope for scoped packages).
11
+ function inferBinName(packageName) {
12
+ if (!packageName) {
13
+ throw new Error("package.json is missing `name`, needed when `bin` is a string");
14
+ }
15
+ if (packageName.startsWith("@")) {
16
+ const parts = packageName.split("/");
17
+ if (parts.length !== 2 || !parts[1]) {
18
+ throw new Error(`invalid package name: ${packageName}`);
19
+ }
20
+ return parts[1];
21
+ }
22
+ return packageName;
23
+ }
24
+ function readJsonFile(filePath) {
25
+ const raw = node_fs_1.default.readFileSync(filePath, "utf8");
26
+ return JSON.parse(raw);
27
+ }
28
+ // Loads and validates all bin entries for the current project.
29
+ // Returned map is command name => absolute target file path.
30
+ function readBins(cwd) {
31
+ const packageJsonPath = node_path_1.default.join(cwd, "package.json");
32
+ if (!node_fs_1.default.existsSync(packageJsonPath)) {
33
+ throw new Error(`missing package.json in ${cwd}`);
34
+ }
35
+ const packageJson = readJsonFile(packageJsonPath);
36
+ if (!packageJson.bin) {
37
+ throw new Error("package.json has no `bin` field");
38
+ }
39
+ const bins = new Map();
40
+ if (typeof packageJson.bin === "string") {
41
+ bins.set(inferBinName(packageJson.name), node_path_1.default.resolve(cwd, packageJson.bin));
42
+ }
43
+ else {
44
+ for (const [name, relTarget] of Object.entries(packageJson.bin)) {
45
+ bins.set(name, node_path_1.default.resolve(cwd, relTarget));
46
+ }
47
+ }
48
+ if (bins.size === 0) {
49
+ throw new Error("no bin entries found");
50
+ }
51
+ // Fail fast if package.json points to non-existing executables.
52
+ for (const [name, target] of bins.entries()) {
53
+ if (!node_fs_1.default.existsSync(target)) {
54
+ throw new Error(`bin target for "${name}" does not exist: ${target}`);
55
+ }
56
+ }
57
+ return bins;
58
+ }
@@ -0,0 +1,109 @@
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.ensureZlxDirectories = ensureZlxDirectories;
7
+ exports.cleanupStaleSessions = cleanupStaleSessions;
8
+ exports.persistSession = persistSession;
9
+ exports.createSessionFilePath = createSessionFilePath;
10
+ exports.cleanupSession = cleanupSession;
11
+ const node_fs_1 = __importDefault(require("node:fs"));
12
+ const node_path_1 = __importDefault(require("node:path"));
13
+ // Lightweight JSON reader used for session metadata files.
14
+ function readJsonFile(filePath) {
15
+ const raw = node_fs_1.default.readFileSync(filePath, "utf8");
16
+ return JSON.parse(raw);
17
+ }
18
+ // Checks whether a PID from a previous session is still alive.
19
+ function isProcessAlive(pid) {
20
+ if (!Number.isInteger(pid) || pid <= 0) {
21
+ return false;
22
+ }
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch (error) {
28
+ const code = error.code;
29
+ return code !== "ESRCH";
30
+ }
31
+ }
32
+ // Session files are best-effort state; deletion failure should not fail the command.
33
+ function deleteSessionFile(filePath) {
34
+ try {
35
+ node_fs_1.default.unlinkSync(filePath);
36
+ }
37
+ catch {
38
+ // Best-effort cleanup.
39
+ }
40
+ }
41
+ // Invalid/corrupted session files are ignored and later removed.
42
+ function loadSession(filePath) {
43
+ try {
44
+ return readJsonFile(filePath);
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
50
+ // Removes only symlinks that still point to the exact targets we created.
51
+ // This avoids deleting user-managed commands with the same name.
52
+ function cleanupLinks(links) {
53
+ for (const link of links) {
54
+ try {
55
+ const stats = node_fs_1.default.lstatSync(link.linkPath);
56
+ if (!stats.isSymbolicLink()) {
57
+ continue;
58
+ }
59
+ const linkedTo = node_fs_1.default.readlinkSync(link.linkPath);
60
+ const absoluteLinkedTo = node_path_1.default.resolve(node_path_1.default.dirname(link.linkPath), linkedTo);
61
+ if (absoluteLinkedTo === node_path_1.default.resolve(link.target)) {
62
+ node_fs_1.default.unlinkSync(link.linkPath);
63
+ }
64
+ }
65
+ catch {
66
+ // Best-effort cleanup.
67
+ }
68
+ }
69
+ }
70
+ // Ensures runtime directories exist before linking/saving sessions.
71
+ function ensureZlxDirectories(binDir, sessionDir) {
72
+ node_fs_1.default.mkdirSync(binDir, { recursive: true });
73
+ node_fs_1.default.mkdirSync(sessionDir, { recursive: true });
74
+ }
75
+ // Reaps stale sessions left behind by crashes/kill -9 and removes their symlinks.
76
+ function cleanupStaleSessions(sessionDir) {
77
+ if (!node_fs_1.default.existsSync(sessionDir)) {
78
+ return;
79
+ }
80
+ for (const entry of node_fs_1.default.readdirSync(sessionDir)) {
81
+ if (!entry.endsWith(".json")) {
82
+ continue;
83
+ }
84
+ const filePath = node_path_1.default.join(sessionDir, entry);
85
+ const record = loadSession(filePath);
86
+ if (!record) {
87
+ deleteSessionFile(filePath);
88
+ continue;
89
+ }
90
+ if (!isProcessAlive(record.pid)) {
91
+ cleanupLinks(record.links);
92
+ deleteSessionFile(filePath);
93
+ }
94
+ }
95
+ }
96
+ // Persists currently linked commands so future runs can clean stale state.
97
+ function persistSession(sessionPath, record) {
98
+ node_fs_1.default.writeFileSync(sessionPath, `${JSON.stringify(record, null, 2)}\n`, "utf8");
99
+ }
100
+ // Produces unique session file names to avoid collisions across concurrent runs.
101
+ function createSessionFilePath(sessionDir) {
102
+ const unique = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
103
+ return node_path_1.default.join(sessionDir, `${unique}.json`);
104
+ }
105
+ // Cleanup for the active process/session.
106
+ function cleanupSession(sessionPath, links) {
107
+ cleanupLinks(links);
108
+ deleteSessionFile(sessionPath);
109
+ }
@@ -0,0 +1,40 @@
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.promptCollisionDecision = promptCollisionDecision;
7
+ const prompts_1 = __importDefault(require("prompts"));
8
+ // Interactive collision resolver for --collision prompt.
9
+ // Returning "abort" bubbles up as an error to stop the current serve run.
10
+ async function promptCollisionDecision(conflict) {
11
+ const response = await (0, prompts_1.default)({
12
+ type: "select",
13
+ name: "decision",
14
+ message: `command "${conflict.name}" already exists`,
15
+ choices: [
16
+ {
17
+ title: "Overwrite existing command",
18
+ value: "overwrite",
19
+ description: `Replace ${conflict.linkPath}`
20
+ },
21
+ {
22
+ title: "Skip this command",
23
+ value: "skip",
24
+ description: conflict.reason
25
+ },
26
+ {
27
+ title: "Abort",
28
+ value: "abort",
29
+ description: "Stop serve without linking remaining commands"
30
+ }
31
+ ],
32
+ initial: 1
33
+ }, {
34
+ onCancel: () => false
35
+ });
36
+ if (!response.decision) {
37
+ return "abort";
38
+ }
39
+ return response.decision;
40
+ }
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.info = info;
4
+ exports.warn = warn;
5
+ exports.error = error;
6
+ const PREFIX = "[zlx]";
7
+ // Thin logging wrappers keep formatting centralized and easy to swap later.
8
+ function info(message) {
9
+ process.stdout.write(`${PREFIX} ${message}\n`);
10
+ }
11
+ function warn(message) {
12
+ process.stderr.write(`${PREFIX} ${message}\n`);
13
+ }
14
+ function error(message) {
15
+ process.stderr.write(`${PREFIX} ${message}\n`);
16
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "symlx",
3
+ "version": "0.1.0",
4
+ "description": "Temporary local CLI bin linker",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "symlx": "dist/cli.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md"
12
+ ],
13
+ "keywords": [
14
+ "cli",
15
+ "bin",
16
+ "dev"
17
+ ],
18
+ "engines": {
19
+ "node": ">=18.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@types/node": "^24.0.0",
23
+ "@types/prompts": "^2.4.9",
24
+ "typescript": "^5.9.0"
25
+ },
26
+ "dependencies": {
27
+ "commander": "^12.1.0",
28
+ "prompts": "^2.4.2"
29
+ },
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.json && chmod +x dist/cli.js",
32
+ "check": "tsc -p tsconfig.json --noEmit"
33
+ }
34
+ }