@nemus-cli/nemus 0.15.2 → 0.17.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/dist/mcp/tools.js CHANGED
@@ -65,6 +65,8 @@ exports.handleSuiteExport = handleSuiteExport;
65
65
  exports.handleSuiteImport = handleSuiteImport;
66
66
  exports.handleSuiteUse = handleSuiteUse;
67
67
  exports.handleSaveContext = handleSaveContext;
68
+ exports.handleLockWorkspace = handleLockWorkspace;
69
+ exports.handleRestoreWorkspace = handleRestoreWorkspace;
68
70
  const fs = __importStar(require("fs/promises"));
69
71
  const path = __importStar(require("path"));
70
72
  const os = __importStar(require("os"));
@@ -87,6 +89,8 @@ const branch_operations_1 = require("../utils/branch-operations");
87
89
  const cleanup_operations_1 = require("../utils/cleanup-operations");
88
90
  const hooks_1 = require("../utils/hooks");
89
91
  const validation_1 = require("../utils/validation");
92
+ const workspace_lock_1 = require("../utils/workspace-lock");
93
+ const restore_1 = require("../commands/restore");
90
94
  /**
91
95
  * Redirects stdout to stderr for the duration of a function call.
92
96
  * MCP uses stdout exclusively for JSON-RPC, so any console.log from
@@ -920,3 +924,60 @@ async function handleSaveContext(workspace, content, append) {
920
924
  };
921
925
  });
922
926
  }
927
+ // ── Portable workspaces: lock / restore ─────────────────────────────────────
928
+ /**
929
+ * Snapshot a workspace into a nemus.lock manifest (repos + branch + commit).
930
+ * Writes it to the workspace root by default and also returns the manifest so
931
+ * an agent can share/commit it.
932
+ */
933
+ async function handleLockWorkspace(workspace, output) {
934
+ return withStdoutProtection(async () => {
935
+ if (!workspace || workspace.trim().length === 0) {
936
+ throw new Error('Workspace name is required');
937
+ }
938
+ const workspacePath = (0, validation_1.safeWorkspacePath)((0, validation_1.sanitizeWorkspaceName)(workspace));
939
+ const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
940
+ if (!metadata) {
941
+ throw new Error(`Workspace not found: ${workspace}`);
942
+ }
943
+ const lock = await (0, workspace_lock_1.buildLock)(workspacePath, metadata);
944
+ const outPath = output ? path.resolve(output) : path.join(workspacePath, workspace_lock_1.LOCK_FILENAME);
945
+ await (0, workspace_lock_1.writeLock)(outPath, lock);
946
+ return {
947
+ workspace: metadata.workspaceName,
948
+ lockfilePath: outPath,
949
+ repoCount: lock.repositories.length,
950
+ lock,
951
+ };
952
+ });
953
+ }
954
+ /**
955
+ * Recreate a workspace from a nemus.lock. Accepts either inline `lockContent`
956
+ * (the manifest JSON) or a `lockfile` path (defaults to ./nemus.lock). Clones
957
+ * every repo and checks out the recorded branch (or exact commit with `pin`).
958
+ */
959
+ async function handleRestoreWorkspace(opts) {
960
+ return withStdoutProtection(async () => {
961
+ const lock = opts.lockContent
962
+ ? (0, workspace_lock_1.parseLock)(opts.lockContent)
963
+ : await (0, workspace_lock_1.readLockFile)(path.resolve(opts.lockfile || workspace_lock_1.LOCK_FILENAME));
964
+ const { workspaceName, workspacePath, results } = await (0, restore_1.restoreWorkspace)(lock, {
965
+ workspace: opts.workspace,
966
+ pin: opts.pin,
967
+ });
968
+ const cloned = results.filter(r => r.status === 'success');
969
+ const failed = results.filter(r => r.status === 'failed');
970
+ return {
971
+ workspace: workspaceName,
972
+ path: workspacePath,
973
+ cloned: cloned.length,
974
+ failed: failed.length,
975
+ repositories: results.map(r => ({
976
+ name: r.repo.name,
977
+ directoryName: r.directoryName,
978
+ status: r.status,
979
+ ...(r.error ? { error: r.error } : {}),
980
+ })),
981
+ };
982
+ });
983
+ }
package/dist/program.js CHANGED
@@ -95,6 +95,9 @@ const migrate_1 = require("./commands/migrate");
95
95
  const report_bug_1 = require("./commands/report-bug");
96
96
  const completion_1 = require("./commands/completion");
97
97
  const reflect_1 = require("./commands/reflect");
98
+ const lock_1 = require("./commands/lock");
99
+ const restore_1 = require("./commands/restore");
100
+ const dev_1 = require("./commands/dev");
98
101
  (0, create_1.registerCreateCommand)(exports.program);
99
102
  (0, list_1.registerListCommand)(exports.program);
100
103
  (0, update_1.registerUpdateCommand)(exports.program);
@@ -122,6 +125,9 @@ const reflect_1 = require("./commands/reflect");
122
125
  (0, report_bug_1.registerReportBugCommand)(exports.program);
123
126
  (0, completion_1.registerCompletionCommand)(exports.program);
124
127
  (0, reflect_1.registerReflectCommand)(exports.program);
128
+ (0, lock_1.registerLockCommand)(exports.program);
129
+ (0, restore_1.registerRestoreCommand)(exports.program);
130
+ (0, dev_1.registerDevCommand)(exports.program);
125
131
  // Register TUI (delegates to existing Ink/React implementation)
126
132
  exports.program
127
133
  .command('tui')
@@ -0,0 +1,324 @@
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.PREFIX_COLORS = exports.DEFAULT_SCRIPT_ORDER = void 0;
37
+ exports.detectPackageManager = detectPackageManager;
38
+ exports.pickDevScript = pickDevScript;
39
+ exports.scriptRunCommand = scriptRunCommand;
40
+ exports.resolveDevCommand = resolveDevCommand;
41
+ exports.assignColors = assignColors;
42
+ exports.formatPrefix = formatPrefix;
43
+ exports.createLineSplitter = createLineSplitter;
44
+ exports.runDev = runDev;
45
+ const child_process_1 = require("child_process");
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ const colors_1 = require("./colors");
49
+ /** Detect a repo's package manager from its lockfile (defaults to npm). */
50
+ function detectPackageManager(repoPath) {
51
+ if (fs.existsSync(path.join(repoPath, 'pnpm-lock.yaml')))
52
+ return 'pnpm';
53
+ if (fs.existsSync(path.join(repoPath, 'yarn.lock')))
54
+ return 'yarn';
55
+ return 'npm';
56
+ }
57
+ /** Default script preference order when the user doesn't pass --script. */
58
+ exports.DEFAULT_SCRIPT_ORDER = ['dev', 'develop', 'start', 'serve'];
59
+ /**
60
+ * Pick the dev script to run from a package.json `scripts` map. If `preferred`
61
+ * is given and present, it wins; otherwise the first of DEFAULT_SCRIPT_ORDER
62
+ * that exists. Returns null when nothing matches.
63
+ */
64
+ function pickDevScript(scripts, preferred) {
65
+ if (!scripts)
66
+ return null;
67
+ if (preferred)
68
+ return scripts[preferred] ? preferred : null;
69
+ for (const name of exports.DEFAULT_SCRIPT_ORDER) {
70
+ if (scripts[name])
71
+ return name;
72
+ }
73
+ return null;
74
+ }
75
+ /** The command string a package manager uses to run a script. */
76
+ function scriptRunCommand(pm, script) {
77
+ // npm needs `run`; yarn/pnpm accept the bare script name.
78
+ return pm === 'npm' ? `npm run ${script}` : `${pm} ${script}`;
79
+ }
80
+ /**
81
+ * Resolve the command to run for a repo. An explicit `commandOverride` always
82
+ * wins; otherwise we read package.json and pick a script. Returns null when the
83
+ * repo has nothing runnable (e.g. a library with no dev script).
84
+ */
85
+ function resolveDevCommand(repoPath, opts = {}) {
86
+ if (opts.commandOverride) {
87
+ return { command: opts.commandOverride, source: 'command' };
88
+ }
89
+ const pkgPath = path.join(repoPath, 'package.json');
90
+ if (!fs.existsSync(pkgPath))
91
+ return null;
92
+ let scripts;
93
+ try {
94
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
95
+ scripts = pkg?.scripts;
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ const script = pickDevScript(scripts, opts.script);
101
+ if (!script)
102
+ return null;
103
+ const pm = detectPackageManager(repoPath);
104
+ return { command: scriptRunCommand(pm, script), source: `${pm} · ${script}` };
105
+ }
106
+ /** Palette used to color per-repo prefixes (cycled if there are more repos). */
107
+ exports.PREFIX_COLORS = [
108
+ 'cyan', 'green', 'yellow', 'magenta', 'blue', 'red', 'white',
109
+ ];
110
+ function assignColors(names) {
111
+ const map = new Map();
112
+ names.forEach((name, i) => map.set(name, exports.PREFIX_COLORS[i % exports.PREFIX_COLORS.length]));
113
+ return map;
114
+ }
115
+ /** Build the aligned, colored `"label | "` prefix for a service's output. */
116
+ function formatPrefix(label, width, color) {
117
+ return (0, colors_1.colorize)(`${label.padEnd(width)} ${(0, colors_1.colorize)('|', 'gray')}`, color);
118
+ }
119
+ /**
120
+ * Stateful line splitter: feed it chunks, it returns complete lines and holds
121
+ * the trailing partial until the next chunk. `flush()` yields any remainder.
122
+ */
123
+ function createLineSplitter() {
124
+ let buffer = '';
125
+ return {
126
+ push(chunk) {
127
+ buffer += chunk;
128
+ const lines = buffer.split('\n');
129
+ buffer = lines.pop() ?? '';
130
+ return lines;
131
+ },
132
+ flush() {
133
+ if (buffer.length === 0)
134
+ return null;
135
+ const rest = buffer;
136
+ buffer = '';
137
+ return rest;
138
+ },
139
+ };
140
+ }
141
+ /**
142
+ * Start every service, multiplex their output with colored prefixes, and drive a
143
+ * clean shutdown on Ctrl-C. Resolves with the exit code to use (first non-zero
144
+ * child code, or 0) once all services have exited.
145
+ */
146
+ function runDev(services, opts = {}) {
147
+ const out = opts.stdout ?? process.stdout;
148
+ const err = opts.stderr ?? process.stderr;
149
+ const spawnFn = opts.spawnFn ?? child_process_1.spawn;
150
+ const killTimeoutMs = opts.killTimeoutMs ?? 5000;
151
+ const kill = opts.killFn ?? killGroup;
152
+ const width = Math.max(...services.map(s => s.label.length), 1);
153
+ const colors = assignColors(services.map(s => s.label));
154
+ return new Promise((resolve) => {
155
+ const running = [];
156
+ let shuttingDown = false;
157
+ let finished = false;
158
+ let firstFailureCode = 0;
159
+ let killTimer = null;
160
+ let disposeSignals = () => { };
161
+ const writePrefixed = (sink, label, text) => {
162
+ const prefix = formatPrefix(label, width, colors.get(label) ?? 'white');
163
+ sink.write(`${prefix} ${text}\n`);
164
+ };
165
+ // SIGKILL every started service's process GROUP. Unconditional by design: a
166
+ // detached leader (the shell) can exit on SIGTERM while its group still has
167
+ // living members (e.g. a grandchild that ignores SIGTERM) — gating on the
168
+ // leader having exited is exactly what leaks orphans. kill(-pid) on an empty
169
+ // group is a harmless ESRCH.
170
+ //
171
+ // Deliberate scope: the sweep only runs as part of shutdown (Ctrl-C, or
172
+ // --exit-on-failure). A single service that exits on its own while others
173
+ // keep running is NOT swept — its stray detached grandchildren (if any) are
174
+ // reaped at the eventual overall shutdown, not immediately. Sweeping a live
175
+ // run per-exit would risk killing an unrelated process that reused the pgid.
176
+ const sigkillSweep = () => {
177
+ for (const r of running)
178
+ kill(r.child, 'SIGKILL');
179
+ };
180
+ const finalize = () => {
181
+ if (finished)
182
+ return;
183
+ finished = true;
184
+ if (killTimer)
185
+ clearTimeout(killTimer);
186
+ disposeSignals();
187
+ resolve(firstFailureCode);
188
+ };
189
+ const maybeFinish = () => {
190
+ if (!running.every(r => r.exited))
191
+ return;
192
+ // All direct children are gone. If we were shutting down, force-reap any
193
+ // orphaned group members (detached grandchildren) before finishing —
194
+ // otherwise we'd exit and leave them running.
195
+ if (shuttingDown)
196
+ sigkillSweep();
197
+ finalize();
198
+ };
199
+ const shutdown = (reason) => {
200
+ if (shuttingDown) {
201
+ // Second Ctrl-C: escalate immediately.
202
+ sigkillSweep();
203
+ return;
204
+ }
205
+ shuttingDown = true;
206
+ err.write(`\n${(0, colors_1.colorize)(`▸ ${reason} — stopping ${running.filter(r => !r.exited).length} service(s)…`, 'yellow')}\n`);
207
+ for (const r of running)
208
+ kill(r.child, 'SIGTERM');
209
+ // Backstop: SIGKILL anything still alive after the grace period, then finish.
210
+ killTimer = setTimeout(() => {
211
+ sigkillSweep();
212
+ finalize();
213
+ }, killTimeoutMs);
214
+ if (typeof killTimer.unref === 'function')
215
+ killTimer.unref();
216
+ };
217
+ disposeSignals = (opts.onSignal ?? defaultOnSignal)((sig) => shutdown(`received ${sig}`));
218
+ for (const service of services) {
219
+ const child = spawnFn(service.command.command, {
220
+ cwd: service.cwd,
221
+ shell: true,
222
+ detached: true,
223
+ stdio: ['ignore', 'pipe', 'pipe'],
224
+ env: process.env,
225
+ });
226
+ const rec = { service, child, exited: false, exitCode: null };
227
+ running.push(rec);
228
+ const outSplitter = createLineSplitter();
229
+ const errSplitter = createLineSplitter();
230
+ child.stdout?.setEncoding('utf-8');
231
+ child.stderr?.setEncoding('utf-8');
232
+ child.stdout?.on('data', (chunk) => {
233
+ for (const line of outSplitter.push(chunk))
234
+ writePrefixed(out, service.label, line);
235
+ });
236
+ child.stderr?.on('data', (chunk) => {
237
+ for (const line of errSplitter.push(chunk))
238
+ writePrefixed(err, service.label, line);
239
+ });
240
+ child.on('error', (e) => {
241
+ if (rec.exited)
242
+ return; // 'exit' already handled this service
243
+ writePrefixed(err, service.label, (0, colors_1.colorize)(`failed to start: ${e.message}`, 'red'));
244
+ rec.exited = true;
245
+ rec.exitCode = 1;
246
+ if (firstFailureCode === 0)
247
+ firstFailureCode = 1;
248
+ maybeFinish();
249
+ });
250
+ child.on('exit', (code, signal) => {
251
+ if (rec.exited)
252
+ return; // 'error' already handled this service
253
+ for (const line of [outSplitter.flush(), errSplitter.flush()]) {
254
+ if (line)
255
+ writePrefixed(out, service.label, line);
256
+ }
257
+ rec.exited = true;
258
+ rec.exitCode = code ?? (signal ? 0 : 1);
259
+ const desc = signal ? `signal ${signal}` : `code ${code}`;
260
+ const color = code && code !== 0 ? 'red' : 'gray';
261
+ err.write(`${(0, colors_1.colorize)(`▸ ${service.label} exited (${desc})`, color)}\n`);
262
+ if (code && code !== 0 && firstFailureCode === 0)
263
+ firstFailureCode = code;
264
+ if (!shuttingDown && opts.exitOnFailure && code && code !== 0) {
265
+ shutdown(`${service.label} failed`);
266
+ }
267
+ maybeFinish();
268
+ });
269
+ }
270
+ if (running.length === 0)
271
+ finalize();
272
+ });
273
+ }
274
+ /**
275
+ * Kill a detached child's whole process tree.
276
+ *
277
+ * POSIX: the child is its own process-group leader (spawned detached), so
278
+ * `kill(-pid)` signals the entire group — the reliable way to take down
279
+ * shell→pm→server→… trees. Windows has no process groups or POSIX signals, so
280
+ * `kill(-pid)` throws; we fall back to `taskkill /T /F` to force-kill the tree
281
+ * (graceful SIGTERM isn't meaningful for a Windows console child tree). If even
282
+ * that isn't available we degrade to a single `child.kill()` (leaf only).
283
+ */
284
+ function killGroup(child, signal) {
285
+ if (child.pid == null)
286
+ return;
287
+ if (process.platform === 'win32') {
288
+ // spawn() reports failures (e.g. taskkill missing → ENOENT) via an async
289
+ // 'error' event, not a synchronous throw, so a try/catch can't catch it — and
290
+ // an unhandled 'error' event would crash the process. Attach a handler that
291
+ // degrades to a leaf-only kill.
292
+ const tk = (0, child_process_1.spawn)('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
293
+ tk.on('error', () => {
294
+ try {
295
+ child.kill();
296
+ }
297
+ catch {
298
+ // already gone
299
+ }
300
+ });
301
+ return;
302
+ }
303
+ try {
304
+ process.kill(-child.pid, signal);
305
+ }
306
+ catch {
307
+ try {
308
+ child.kill(signal);
309
+ }
310
+ catch {
311
+ // already gone
312
+ }
313
+ }
314
+ }
315
+ function defaultOnSignal(handler) {
316
+ const onSigint = () => handler('SIGINT');
317
+ const onSigterm = () => handler('SIGTERM');
318
+ process.on('SIGINT', onSigint);
319
+ process.on('SIGTERM', onSigterm);
320
+ return () => {
321
+ process.off('SIGINT', onSigint);
322
+ process.off('SIGTERM', onSigterm);
323
+ };
324
+ }
@@ -0,0 +1,256 @@
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.LOCK_VERSION = exports.LOCK_FILENAME = void 0;
37
+ exports.readRepoBranch = readRepoBranch;
38
+ exports.readRepoCommit = readRepoCommit;
39
+ exports.buildLock = buildLock;
40
+ exports.serializeLock = serializeLock;
41
+ exports.writeLock = writeLock;
42
+ exports.parseLock = parseLock;
43
+ exports.isSafeSegment = isSafeSegment;
44
+ exports.isAllowedCloneUrl = isAllowedCloneUrl;
45
+ exports.isSafeGitRef = isSafeGitRef;
46
+ exports.readLockFile = readLockFile;
47
+ exports.parseGitHost = parseGitHost;
48
+ exports.reconstructRepo = reconstructRepo;
49
+ const child_process_1 = require("child_process");
50
+ const util_1 = require("util");
51
+ const fs = __importStar(require("fs/promises"));
52
+ const path = __importStar(require("path"));
53
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
54
+ const GIT_TIMEOUT = 15000;
55
+ /** Committable manifest that fully describes a workspace's repos + branch state. */
56
+ exports.LOCK_FILENAME = 'nemus.lock';
57
+ exports.LOCK_VERSION = 1;
58
+ /** Read the current branch of a git repo, or undefined for a detached HEAD / error. */
59
+ async function readRepoBranch(repoPath) {
60
+ try {
61
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
62
+ cwd: repoPath,
63
+ timeout: GIT_TIMEOUT,
64
+ });
65
+ const branch = stdout.trim();
66
+ return branch && branch !== 'HEAD' ? branch : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ /** Read the short HEAD SHA of a git repo, or undefined on error. */
73
+ async function readRepoCommit(repoPath) {
74
+ try {
75
+ const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], {
76
+ cwd: repoPath,
77
+ timeout: GIT_TIMEOUT,
78
+ });
79
+ return stdout.trim() || undefined;
80
+ }
81
+ catch {
82
+ return undefined;
83
+ }
84
+ }
85
+ /**
86
+ * Build a lock manifest from a workspace's metadata, reading the live branch +
87
+ * commit for each successfully-cloned repo directory.
88
+ */
89
+ async function buildLock(workspacePath, metadata) {
90
+ const repos = metadata.repositories.filter(r => r.status !== 'failed');
91
+ const repositories = await Promise.all(repos.map(async (r) => {
92
+ const repoPath = path.join(workspacePath, r.directoryName);
93
+ const [branch, commit] = await Promise.all([
94
+ readRepoBranch(repoPath),
95
+ readRepoCommit(repoPath),
96
+ ]);
97
+ return {
98
+ name: r.name,
99
+ owner: r.owner,
100
+ directoryName: r.directoryName,
101
+ cloneUrl: r.cloneUrl,
102
+ ...(branch ? { branch } : {}),
103
+ ...(commit ? { commit } : {}),
104
+ };
105
+ }));
106
+ return {
107
+ version: exports.LOCK_VERSION,
108
+ workspace: metadata.workspaceName,
109
+ generatedAt: new Date().toISOString(),
110
+ repositories,
111
+ };
112
+ }
113
+ /** Serialize a lock to canonical JSON (trailing newline). */
114
+ function serializeLock(lock) {
115
+ return JSON.stringify(lock, null, 2) + '\n';
116
+ }
117
+ async function writeLock(filePath, lock) {
118
+ await fs.writeFile(filePath, serializeLock(lock), 'utf-8');
119
+ }
120
+ /** Parse + validate a lock manifest. Throws a helpful error on malformed input. */
121
+ function parseLock(content) {
122
+ let data;
123
+ try {
124
+ data = JSON.parse(content);
125
+ }
126
+ catch {
127
+ throw new Error('Not valid JSON — is this a nemus.lock file?');
128
+ }
129
+ if (!data || typeof data !== 'object') {
130
+ throw new Error('Lockfile is not an object');
131
+ }
132
+ const lock = data;
133
+ if (lock.version !== exports.LOCK_VERSION) {
134
+ throw new Error(`Unsupported lockfile version ${String(lock.version)} (this nemus supports version ${exports.LOCK_VERSION})`);
135
+ }
136
+ if (typeof lock.workspace !== 'string' || !lock.workspace) {
137
+ throw new Error('Lockfile is missing a "workspace" name');
138
+ }
139
+ if (!Array.isArray(lock.repositories)) {
140
+ throw new Error('Lockfile is missing a "repositories" array');
141
+ }
142
+ // A lockfile is untrusted shared input — people commit it and hand it around,
143
+ // then `restore` feeds these fields to git + path.join. Validate every field
144
+ // that reaches a side effect, not just that it's a non-empty string.
145
+ for (const [i, r] of lock.repositories.entries()) {
146
+ if (!r || typeof r !== 'object')
147
+ throw new Error(`repositories[${i}] is not an object`);
148
+ const entry = r;
149
+ for (const field of ['name', 'owner', 'directoryName', 'cloneUrl']) {
150
+ if (typeof entry[field] !== 'string' || !entry[field]) {
151
+ throw new Error(`repositories[${i}] is missing "${field}"`);
152
+ }
153
+ }
154
+ if (!isSafeSegment(entry.directoryName)) {
155
+ throw new Error(`repositories[${i}].directoryName "${entry.directoryName}" is not a single path segment`);
156
+ }
157
+ // owner/name are what `reconstructRepo` rebuilds the clone URL from, so they
158
+ // must be safe segments too — not merely non-empty.
159
+ if (!isSafeSegment(entry.owner)) {
160
+ throw new Error(`repositories[${i}].owner "${entry.owner}" is not a valid path segment`);
161
+ }
162
+ if (!isSafeSegment(entry.name)) {
163
+ throw new Error(`repositories[${i}].name "${entry.name}" is not a valid path segment`);
164
+ }
165
+ if (!isAllowedCloneUrl(entry.cloneUrl)) {
166
+ throw new Error(`repositories[${i}].cloneUrl "${entry.cloneUrl}" has no recognized git transport (expected https/ssh/git:// or user@host:path)`);
167
+ }
168
+ if (entry.branch !== undefined && (typeof entry.branch !== 'string' || !isSafeGitRef(entry.branch))) {
169
+ throw new Error(`repositories[${i}].branch "${entry.branch}" is not a valid git ref`);
170
+ }
171
+ if (entry.commit !== undefined && (typeof entry.commit !== 'string' || !isSafeGitRef(entry.commit))) {
172
+ throw new Error(`repositories[${i}].commit "${entry.commit}" is not a valid git ref`);
173
+ }
174
+ }
175
+ return lock;
176
+ }
177
+ /**
178
+ * A `directoryName` from a lockfile flows into `path.join(workspacePath, …)`, so
179
+ * it must be a single, non-traversing path segment — no separators, and not `.`
180
+ * or `..` — or a crafted lockfile could write repos outside the workspace.
181
+ */
182
+ function isSafeSegment(name) {
183
+ return (name.length > 0 &&
184
+ !name.includes('/') &&
185
+ !name.includes('\\') &&
186
+ !name.includes('\0') &&
187
+ name !== '.' &&
188
+ name !== '..');
189
+ }
190
+ /**
191
+ * A `cloneUrl` is passed to `git clone`; without a recognized transport scheme a
192
+ * value like `--upload-pack=…` would be parsed as a git option. Allow only
193
+ * https/http/ssh/git URLs and scp-style `user@host:path` remotes.
194
+ */
195
+ function isAllowedCloneUrl(url) {
196
+ if (/^(https?|ssh|git):\/\//i.test(url))
197
+ return true;
198
+ if (/^[^\s@/]+@[^\s@:/]+:/.test(url))
199
+ return true;
200
+ return false;
201
+ }
202
+ /**
203
+ * A `branch`/`commit` from a lockfile is handed to `git checkout`. Reject refs
204
+ * that could smuggle git options (leading `-`) or aren't valid refs
205
+ * (whitespace/control chars, the metacharacters git itself forbids, or `..`).
206
+ */
207
+ function isSafeGitRef(ref) {
208
+ return (ref.length > 0 &&
209
+ !ref.startsWith('-') &&
210
+ // eslint-disable-next-line no-control-regex
211
+ !/[\s\x00-\x1f\x7f~^:?*[\\]/.test(ref) &&
212
+ !ref.includes('..'));
213
+ }
214
+ async function readLockFile(filePath) {
215
+ const content = await fs.readFile(filePath, 'utf-8');
216
+ return parseLock(content);
217
+ }
218
+ /**
219
+ * Extract the git host from a clone URL — supports scp-style
220
+ * (`git@github.com:owner/repo.git`) and URL-style
221
+ * (`https://github.com/owner/repo.git`, `ssh://git@host/owner/repo`).
222
+ * Returns undefined if it can't be determined.
223
+ */
224
+ function parseGitHost(cloneUrl) {
225
+ const scp = cloneUrl.match(/^[^@/]+@([^:/]+):/);
226
+ if (scp)
227
+ return scp[1];
228
+ try {
229
+ const u = new URL(cloneUrl);
230
+ if (u.hostname)
231
+ return u.hostname;
232
+ }
233
+ catch {
234
+ // not a URL
235
+ }
236
+ return undefined;
237
+ }
238
+ /**
239
+ * Rebuild a `GitHubRepo` for cloning from a lock entry. When the host is known
240
+ * we synthesize both https + ssh forms for `<host>/<owner>/<name>` so
241
+ * `getCloneUrl` can honor the restorer's `cloneProtocol`; otherwise we fall back
242
+ * to the stored `cloneUrl` for both fields (clone from exactly what was locked).
243
+ */
244
+ function reconstructRepo(entry) {
245
+ const host = parseGitHost(entry.cloneUrl);
246
+ const url = host ? `https://${host}/${entry.owner}/${entry.name}` : entry.cloneUrl;
247
+ const sshUrl = host ? `git@${host}:${entry.owner}/${entry.name}.git` : entry.cloneUrl;
248
+ return {
249
+ name: entry.name,
250
+ url,
251
+ sshUrl,
252
+ owner: { login: entry.owner },
253
+ description: '',
254
+ isPrivate: false,
255
+ };
256
+ }