@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/CHANGELOG.md +41 -0
- package/README.md +61 -0
- package/dist/commands/dev.js +118 -0
- package/dist/commands/lock.js +148 -0
- package/dist/commands/restore.js +232 -0
- package/dist/mcp/server.js +28 -0
- package/dist/mcp/tools.js +61 -0
- package/dist/program.js +6 -0
- package/dist/utils/dev-orchestrator.js +324 -0
- package/dist/utils/workspace-lock.js +256 -0
- package/package.json +2 -2
- package/skills/nemus/SKILL.md +13 -0
- package/skills/nemus/references/dev.md +46 -0
- package/skills/nemus/references/lock.md +38 -0
- package/skills/nemus/references/mcp.md +2 -0
- package/skills/nemus/references/restore.md +39 -0
- package/src/commands/dev.ts +98 -0
- package/src/commands/lock.ts +129 -0
- package/src/commands/restore.ts +219 -0
- package/src/mcp/server.ts +40 -0
- package/src/mcp/tools.ts +69 -0
- package/src/program.ts +6 -0
- package/src/utils/dev-orchestrator.test.ts +207 -0
- package/src/utils/dev-orchestrator.ts +325 -0
- package/src/utils/workspace-lock.test.ts +200 -0
- package/src/utils/workspace-lock.ts +245 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.17.0] - 2026-09-09
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **`nemus dev` — multi-repo dev orchestrator.** Start every repo's dev server in
|
|
15
|
+
a workspace at once and stream their output into one terminal with a
|
|
16
|
+
color-coded, aligned per-repo prefix; a single Ctrl-C tears them all down
|
|
17
|
+
cleanly. Each service runs in its own process group and shutdown SIGTERMs the
|
|
18
|
+
group then SIGKILLs stragglers after `--kill-timeout` (default 5s), so child
|
|
19
|
+
process trees (a dev server's own subprocesses) are never orphaned. For each
|
|
20
|
+
repo it runs `--command "<cmd>"` if given, else the first `package.json` script
|
|
21
|
+
that exists (`--script` → `dev` → `develop` → `start` → `serve`) using the
|
|
22
|
+
repo's own package manager (pnpm/yarn/npm from its lockfile); repos with no
|
|
23
|
+
runnable script are skipped with a notice. Flags: `--only <repos>`,
|
|
24
|
+
`--script`, `--command`, `--exit-on-failure`, `--kill-timeout`.
|
|
25
|
+
|
|
26
|
+
## [0.16.0] - 2026-09-09
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **Portable workspaces: `nemus lock` & `nemus restore`.** `nemus lock` writes a
|
|
31
|
+
small, committable `nemus.lock` capturing every repo (owner, directory, clone
|
|
32
|
+
URL), the branch each repo is currently on, and its HEAD commit; `-o <file>`
|
|
33
|
+
writes elsewhere and `-o -` prints it to stdout. `nemus restore` recreates the
|
|
34
|
+
workspace from a lockfile (defaults to `./nemus.lock`, or `-` for stdin) on any
|
|
35
|
+
machine — it clones every repo and checks out the recorded branch, then writes
|
|
36
|
+
metadata + agent context exactly like `create`. `--pin` checks out the exact
|
|
37
|
+
recorded commit instead of the branch tip, and `-w/--workspace` overrides the
|
|
38
|
+
name baked into the lockfile. `nemus lock --all` drops a lockfile into every
|
|
39
|
+
workspace at once (skipping existing ones unless `--force`) — the explicit way
|
|
40
|
+
to make a whole machine portable, rather than a hidden side effect in
|
|
41
|
+
`migrate`. The MCP server gains matching `lock-workspace` / `restore-workspace`
|
|
42
|
+
tools (restore accepts the manifest inline via `lockContent`) so agents have
|
|
43
|
+
the same portability as `create`/`update`/`delete`. Restore rebuilds https/ssh URLs for the
|
|
44
|
+
restorer's `cloneProtocol` from the locked host+owner+name (falling back to the
|
|
45
|
+
stored URL), so an ssh-locked workspace restores fine for an https user. The
|
|
46
|
+
lockfile holds only what `git remote -v` already exposes — no secrets — so it's
|
|
47
|
+
safe to commit. Unlike a local `snapshot` (time-travel within an existing
|
|
48
|
+
workspace) or a `suite` (reusable repo template), a lockfile pins one
|
|
49
|
+
workspace's exact repos + branch state and recreates it from nothing.
|
|
50
|
+
|
|
10
51
|
## [0.15.2] - 2026-09-04
|
|
11
52
|
|
|
12
53
|
### Fixed
|
package/README.md
CHANGED
|
@@ -240,9 +240,42 @@ nemus status my-workspace # (st) git status for every repo
|
|
|
240
240
|
nemus sync my-workspace # (s) git pull every repo (auto-retries on flaky network)
|
|
241
241
|
nemus diff my-workspace # (d) combined diff summary (--full for raw diffs)
|
|
242
242
|
nemus run my-workspace "npm install" # (r) run a command in every repo
|
|
243
|
+
nemus dev my-workspace # start every repo's dev server together (one Ctrl-C stops all)
|
|
243
244
|
nemus doctor my-workspace # (doc) health checks + score
|
|
244
245
|
```
|
|
245
246
|
|
|
247
|
+
### `nemus dev` — run all your services together
|
|
248
|
+
|
|
249
|
+
A workspace is usually a set of services you run *together*. `nemus dev` starts
|
|
250
|
+
each repo's dev server at once and streams their output into one terminal with a
|
|
251
|
+
color-coded, aligned per-repo prefix; a single Ctrl-C tears them all down cleanly
|
|
252
|
+
(process-group kill, so child trees die too).
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
nemus dev # start every runnable repo in the current workspace
|
|
256
|
+
nemus dev payments --only web,api # just a subset
|
|
257
|
+
nemus dev payments --script start # prefer a specific npm script
|
|
258
|
+
nemus dev payments --command "make run" # run an exact command in every repo
|
|
259
|
+
nemus dev payments --exit-on-failure # stop everything if any service crashes
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
```
|
|
263
|
+
web | VITE ready in 412 ms
|
|
264
|
+
api | listening on :4000
|
|
265
|
+
worker | [queue] connected
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
For each repo it runs `--command` if given, else the first `package.json` script
|
|
269
|
+
that exists (`--script` → `dev` → `develop` → `start` → `serve`), using the
|
|
270
|
+
repo's own package manager (pnpm/yarn/npm from its lockfile). Repos with no
|
|
271
|
+
runnable script are skipped with a notice, so a library in the workspace won't
|
|
272
|
+
block the services.
|
|
273
|
+
|
|
274
|
+
> On **macOS/Linux** each service runs in its own process group, so shutdown
|
|
275
|
+
> reliably takes down the whole child tree. On **Windows** there are no POSIX
|
|
276
|
+
> process groups; teardown falls back to `taskkill /T /F` (force-kill the tree,
|
|
277
|
+
> no graceful SIGTERM phase).
|
|
278
|
+
|
|
246
279
|
```
|
|
247
280
|
Repo Branch Status Ahead/Behind Modified
|
|
248
281
|
─────────────────────────────────────────────────────────────────────
|
|
@@ -354,6 +387,34 @@ nemus snapshot save ws # (ss) capture exact branches/commits/dirty state
|
|
|
354
387
|
nemus snapshot restore <id> # (sr)
|
|
355
388
|
```
|
|
356
389
|
|
|
390
|
+
### Portable workspaces — `lock` & `restore`
|
|
391
|
+
|
|
392
|
+
Share the *exact* workspace you're in. `nemus lock` writes a small, committable
|
|
393
|
+
`nemus.lock` (repos + owner + the branch each repo is on + its HEAD commit);
|
|
394
|
+
`nemus restore` recreates that workspace from scratch on any machine — clones
|
|
395
|
+
every repo and checks out the recorded branch.
|
|
396
|
+
|
|
397
|
+
```bash
|
|
398
|
+
nemus lock # write ./nemus.lock for the current workspace
|
|
399
|
+
nemus lock my-ws -o my-ws.lock # or a named workspace, to a file
|
|
400
|
+
nemus lock my-ws -o - # print the lockfile to stdout (pipe it anywhere)
|
|
401
|
+
nemus lock --all [--force] # drop a nemus.lock into every workspace at once
|
|
402
|
+
|
|
403
|
+
nemus restore # recreate from ./nemus.lock
|
|
404
|
+
nemus restore my-ws.lock # from a specific file (or `-` to read stdin)
|
|
405
|
+
nemus restore my-ws.lock -w exp --pin # new name; pin exact commits, not branch tips
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Commit `nemus.lock` next to a design doc or drop it in a ticket, and a teammate
|
|
409
|
+
runs `nemus restore` to land in the identical multi-repo setup. It contains only
|
|
410
|
+
what `git remote -v` already exposes — no secrets — so it's safe to commit.
|
|
411
|
+
|
|
412
|
+
> **`lock`/`restore` vs. `snapshot`:** a *snapshot* is local time-travel for a
|
|
413
|
+
> workspace that already exists on your machine; `lock`/`restore` is portable —
|
|
414
|
+
> it recreates the workspace (repos and all) from nothing, on any machine.
|
|
415
|
+
> **vs. `suite`:** a *suite* is a reusable repo *template*; a lockfile pins one
|
|
416
|
+
> workspace's *exact* repos and branch state.
|
|
417
|
+
|
|
357
418
|
### Reflect — improve your setup over time
|
|
358
419
|
|
|
359
420
|
```bash
|
|
@@ -0,0 +1,118 @@
|
|
|
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.registerDevCommand = registerDevCommand;
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
const config_1 = require("../utils/config");
|
|
39
|
+
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
40
|
+
const command_helpers_1 = require("../utils/command-helpers");
|
|
41
|
+
const dev_orchestrator_1 = require("../utils/dev-orchestrator");
|
|
42
|
+
const logger_1 = require("../utils/logger");
|
|
43
|
+
const colors_1 = require("../utils/colors");
|
|
44
|
+
function registerDevCommand(parent) {
|
|
45
|
+
parent
|
|
46
|
+
.command('dev [workspace]')
|
|
47
|
+
.description('Start every repo\'s dev server together, with unified color-coded logs (Ctrl-C stops all)')
|
|
48
|
+
.option('--only <repos>', 'Comma-separated subset of repos to start')
|
|
49
|
+
.option('--script <name>', 'npm script to prefer (default: dev → develop → start → serve)')
|
|
50
|
+
.option('--command <cmd>', 'Run this exact command in every selected repo instead of a script')
|
|
51
|
+
.option('--exit-on-failure', 'Tear everything down if any service exits non-zero')
|
|
52
|
+
.option('--kill-timeout <seconds>', 'Grace period before SIGKILL on shutdown', '5')
|
|
53
|
+
.action(async (workspace, opts, cmd) => {
|
|
54
|
+
const globalOpts = (0, command_helpers_1.getGlobalOpts)(cmd);
|
|
55
|
+
await handleDev({ workspace, ...opts, ...globalOpts });
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function handleDev(opts) {
|
|
59
|
+
try {
|
|
60
|
+
const workspaceName = await (0, command_helpers_1.resolveWorkspace)(opts.workspace);
|
|
61
|
+
const workspacePath = path.join(config_1.WORKSPACES_DIR, workspaceName);
|
|
62
|
+
const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
|
|
63
|
+
if (!metadata) {
|
|
64
|
+
(0, logger_1.logError)(`Workspace not found: ${workspaceName}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
let repos = metadata.repositories.filter(r => r.status === 'success');
|
|
68
|
+
if (opts.only) {
|
|
69
|
+
const wanted = new Set((0, command_helpers_1.parseList)(opts.only));
|
|
70
|
+
repos = repos.filter(r => wanted.has(r.directoryName) || wanted.has(r.name));
|
|
71
|
+
if (repos.length === 0) {
|
|
72
|
+
(0, logger_1.logError)(`No repos in "${workspaceName}" matched --only ${opts.only}`);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const services = [];
|
|
77
|
+
const skipped = [];
|
|
78
|
+
for (const repo of repos) {
|
|
79
|
+
const cwd = path.join(workspacePath, repo.directoryName);
|
|
80
|
+
const command = (0, dev_orchestrator_1.resolveDevCommand)(cwd, { commandOverride: opts.command, script: opts.script });
|
|
81
|
+
if (!command) {
|
|
82
|
+
skipped.push(repo.directoryName);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
services.push({ label: repo.directoryName, cwd, command });
|
|
86
|
+
}
|
|
87
|
+
if (services.length === 0) {
|
|
88
|
+
(0, logger_1.logError)(`Nothing to run in "${workspaceName}".`);
|
|
89
|
+
(0, logger_1.logInfo)(opts.script
|
|
90
|
+
? `No repo has a "${opts.script}" script.`
|
|
91
|
+
: 'No repo has a dev/develop/start/serve script. Pass --command "<cmd>" to run something explicitly.');
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
if (skipped.length > 0) {
|
|
95
|
+
(0, logger_1.logWarning)(`Skipped (no runnable script): ${skipped.join(', ')}`);
|
|
96
|
+
}
|
|
97
|
+
console.log('\n' + (0, colors_1.colorize)('Starting dev servers', 'bright') + (0, colors_1.colorize)(` · ${workspaceName}`, 'cyan'));
|
|
98
|
+
for (const s of services) {
|
|
99
|
+
console.log(` ${(0, colors_1.colorize)(s.label, 'cyan')} ${(0, colors_1.colorize)(s.command.command, 'gray')} ${(0, colors_1.colorize)(`(${s.command.source})`, 'gray')}`);
|
|
100
|
+
}
|
|
101
|
+
console.log((0, colors_1.colorize)(' Ctrl-C to stop all.\n', 'gray'));
|
|
102
|
+
// NaN check (not `|| 5`) so an explicit --kill-timeout 0 (immediate SIGKILL)
|
|
103
|
+
// is honored rather than coerced back to the default.
|
|
104
|
+
const parsedTimeout = Number(opts.killTimeout);
|
|
105
|
+
const killTimeoutMs = Math.max(0, Number.isFinite(parsedTimeout) ? parsedTimeout : 5) * 1000;
|
|
106
|
+
const code = await (0, dev_orchestrator_1.runDev)(services, {
|
|
107
|
+
exitOnFailure: opts.exitOnFailure,
|
|
108
|
+
killTimeoutMs,
|
|
109
|
+
});
|
|
110
|
+
process.exit(code);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
(0, logger_1.logError)('Failed to start dev servers');
|
|
114
|
+
if (error instanceof Error)
|
|
115
|
+
(0, logger_1.logError)(error.message);
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
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.registerLockCommand = registerLockCommand;
|
|
37
|
+
const path = __importStar(require("path"));
|
|
38
|
+
const fs = __importStar(require("fs/promises"));
|
|
39
|
+
const config_1 = require("../utils/config");
|
|
40
|
+
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
41
|
+
const command_helpers_1 = require("../utils/command-helpers");
|
|
42
|
+
const workspace_lock_1 = require("../utils/workspace-lock");
|
|
43
|
+
const logger_1 = require("../utils/logger");
|
|
44
|
+
const colors_1 = require("../utils/colors");
|
|
45
|
+
function registerLockCommand(parent) {
|
|
46
|
+
parent
|
|
47
|
+
.command('lock [workspace]')
|
|
48
|
+
.description('Snapshot a workspace into a committable nemus.lock (repos + branches)')
|
|
49
|
+
.option('-o, --output <file>', 'Write the lockfile to <file> ("-" for stdout) instead of the workspace root')
|
|
50
|
+
.option('--all', 'Write a nemus.lock into every workspace (skips ones that already have one)')
|
|
51
|
+
.option('--force', 'With --all, overwrite an existing nemus.lock')
|
|
52
|
+
.action(async (workspace, opts, cmd) => {
|
|
53
|
+
const globalOpts = (0, command_helpers_1.getGlobalOpts)(cmd);
|
|
54
|
+
await handleLock({ workspace, ...opts, ...globalOpts });
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
async function fileExists(p) {
|
|
58
|
+
try {
|
|
59
|
+
await fs.access(p);
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function handleLock(opts) {
|
|
67
|
+
if (opts.all) {
|
|
68
|
+
await handleLockAll(opts);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const workspaceName = await (0, command_helpers_1.resolveWorkspace)(opts.workspace);
|
|
73
|
+
const workspacePath = path.join(config_1.WORKSPACES_DIR, workspaceName);
|
|
74
|
+
const metadata = await (0, workspace_meta_1.loadMetadata)(workspacePath);
|
|
75
|
+
if (!metadata) {
|
|
76
|
+
(0, logger_1.logError)(`Workspace not found: ${workspaceName}`);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
const lock = await (0, workspace_lock_1.buildLock)(workspacePath, metadata);
|
|
80
|
+
// `-o -`: emit only the lockfile JSON so it can be piped/redirected cleanly.
|
|
81
|
+
if (opts.output === '-') {
|
|
82
|
+
process.stdout.write((0, workspace_lock_1.serializeLock)(lock));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const outPath = opts.output
|
|
86
|
+
? path.resolve(opts.output)
|
|
87
|
+
: path.join(workspacePath, workspace_lock_1.LOCK_FILENAME);
|
|
88
|
+
await (0, workspace_lock_1.writeLock)(outPath, lock);
|
|
89
|
+
(0, logger_1.logSuccess)(`Wrote ${(0, colors_1.colorize)(workspace_lock_1.LOCK_FILENAME, 'cyan')} (${lock.repositories.length} repos) → ${outPath}`);
|
|
90
|
+
(0, logger_1.logInfo)('Commit or share it, then recreate the workspace with: nemus restore');
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
(0, logger_1.logError)(error instanceof Error ? error.message : 'Failed to write lockfile');
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Bulk mode: drop a nemus.lock into every workspace so a whole machine's
|
|
99
|
+
* workspaces become portable in one shot. Existing lockfiles are left alone
|
|
100
|
+
* (they may be hand-edited / committed) unless --force is given — this is the
|
|
101
|
+
* explicit, discoverable alternative to burying a snapshot side effect inside
|
|
102
|
+
* `migrate`, which is meant to be re-run freely.
|
|
103
|
+
*/
|
|
104
|
+
async function handleLockAll(opts) {
|
|
105
|
+
if (opts.output) {
|
|
106
|
+
(0, logger_1.logError)('--all writes one nemus.lock per workspace and cannot be combined with --output.');
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
const workspaces = await (0, workspace_meta_1.listWorkspaces)(false);
|
|
110
|
+
if (workspaces.length === 0) {
|
|
111
|
+
(0, logger_1.logInfo)('No workspaces found. Nothing to lock.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
let written = 0;
|
|
115
|
+
let skipped = 0;
|
|
116
|
+
let errors = 0;
|
|
117
|
+
for (const ws of workspaces) {
|
|
118
|
+
const metadata = ws.metadata ?? (await (0, workspace_meta_1.loadMetadata)(ws.path));
|
|
119
|
+
if (!metadata) {
|
|
120
|
+
(0, logger_1.logWarning)(` ${ws.name}: no metadata — skipped (run 'nemus migrate' first)`);
|
|
121
|
+
skipped++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const outPath = path.join(ws.path, workspace_lock_1.LOCK_FILENAME);
|
|
125
|
+
if (!opts.force && (await fileExists(outPath))) {
|
|
126
|
+
(0, logger_1.logInfo)(` ${(0, colors_1.colorize)(ws.name, 'cyan')}: ${workspace_lock_1.LOCK_FILENAME} already exists — skipped (use --force to overwrite)`);
|
|
127
|
+
skipped++;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const lock = await (0, workspace_lock_1.buildLock)(ws.path, metadata);
|
|
132
|
+
await (0, workspace_lock_1.writeLock)(outPath, lock);
|
|
133
|
+
(0, logger_1.logSuccess)(` ${(0, colors_1.colorize)(ws.name, 'cyan')}: wrote ${workspace_lock_1.LOCK_FILENAME} (${lock.repositories.length} repos)`);
|
|
134
|
+
written++;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
(0, logger_1.logError)(` ${ws.name}: ${error instanceof Error ? error.message : 'failed to write lockfile'}`);
|
|
138
|
+
errors++;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
console.log('');
|
|
142
|
+
(0, logger_1.logInfo)('Bulk lock complete:');
|
|
143
|
+
console.log(` ${(0, colors_1.colorize)(String(written), 'green')} written`);
|
|
144
|
+
if (skipped > 0)
|
|
145
|
+
console.log(` ${(0, colors_1.colorize)(String(skipped), 'yellow')} skipped`);
|
|
146
|
+
if (errors > 0)
|
|
147
|
+
console.log(` ${(0, colors_1.colorize)(String(errors), 'red')} errors`);
|
|
148
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
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.registerRestoreCommand = registerRestoreCommand;
|
|
37
|
+
exports.restoreWorkspace = restoreWorkspace;
|
|
38
|
+
const child_process_1 = require("child_process");
|
|
39
|
+
const util_1 = require("util");
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const command_helpers_1 = require("../utils/command-helpers");
|
|
42
|
+
const workspace_lock_1 = require("../utils/workspace-lock");
|
|
43
|
+
const git_operations_1 = require("../utils/git-operations");
|
|
44
|
+
const ghq_integration_1 = require("../utils/ghq-integration");
|
|
45
|
+
const workspace_meta_1 = require("../utils/workspace-meta");
|
|
46
|
+
const claude_integration_1 = require("../utils/claude-integration");
|
|
47
|
+
const github_1 = require("../utils/github");
|
|
48
|
+
const validation_1 = require("../utils/validation");
|
|
49
|
+
const logger_1 = require("../utils/logger");
|
|
50
|
+
const colors_1 = require("../utils/colors");
|
|
51
|
+
const banner_1 = require("../utils/banner");
|
|
52
|
+
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
53
|
+
const GIT_TIMEOUT = 30000;
|
|
54
|
+
function registerRestoreCommand(parent) {
|
|
55
|
+
parent
|
|
56
|
+
.command('restore [lockfile]')
|
|
57
|
+
.description('Recreate a workspace from a nemus.lock (defaults to ./nemus.lock, "-" for stdin)')
|
|
58
|
+
.option('-w, --workspace <name>', 'Override the workspace name baked into the lockfile')
|
|
59
|
+
.option('--pin', 'Check out the exact recorded commit instead of the branch tip')
|
|
60
|
+
.action(async (lockfile, opts, cmd) => {
|
|
61
|
+
const globalOpts = (0, command_helpers_1.getGlobalOpts)(cmd);
|
|
62
|
+
await handleRestore({ lockfile, ...opts, ...globalOpts });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
async function readStdin() {
|
|
66
|
+
const chunks = [];
|
|
67
|
+
for await (const chunk of process.stdin)
|
|
68
|
+
chunks.push(chunk);
|
|
69
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
70
|
+
}
|
|
71
|
+
// `branch`/`commit` come from an untrusted lockfile (already ref-validated in
|
|
72
|
+
// parseLock); the `--end-of-options` guard is defense-in-depth so a ref can
|
|
73
|
+
// never be reparsed as a git option even if validation is bypassed.
|
|
74
|
+
/** Check out `branch` in a freshly-cloned repo, creating a tracking branch if needed. */
|
|
75
|
+
async function checkoutBranch(repoPath, branch) {
|
|
76
|
+
try {
|
|
77
|
+
await execFileAsync('git', ['checkout', '--end-of-options', branch], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
try {
|
|
82
|
+
await execFileAsync('git', ['checkout', '-b', branch, '--end-of-options', `origin/${branch}`], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async function checkoutCommit(repoPath, commit) {
|
|
91
|
+
try {
|
|
92
|
+
await execFileAsync('git', ['checkout', '--end-of-options', commit], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
93
|
+
return true;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Core restore: clone every repo in a (already-validated) lock and check out the
|
|
101
|
+
* recorded branch (or the exact commit with `pin`), then write metadata + agent
|
|
102
|
+
* context — exactly like `create`. Throws on fatal errors (no process.exit) and
|
|
103
|
+
* logs progress via the logger (stderr), so both the CLI and the MCP tool can
|
|
104
|
+
* call it. Callers own presentation (final message, shell-CD hook).
|
|
105
|
+
*/
|
|
106
|
+
async function restoreWorkspace(lock, opts = {}) {
|
|
107
|
+
if (lock.repositories.length === 0) {
|
|
108
|
+
throw new Error('Lockfile has no repositories to restore');
|
|
109
|
+
}
|
|
110
|
+
// Resolve the target workspace name
|
|
111
|
+
let workspaceName = (0, validation_1.sanitizeWorkspaceName)(opts.workspace || lock.workspace);
|
|
112
|
+
const nameError = (0, validation_1.validateWorkspaceName)(workspaceName);
|
|
113
|
+
if (nameError !== true) {
|
|
114
|
+
throw new Error(typeof nameError === 'string' ? nameError : 'Invalid workspace name');
|
|
115
|
+
}
|
|
116
|
+
if (await (0, validation_1.checkWorkspaceExists)(workspaceName)) {
|
|
117
|
+
const resolved = await (0, validation_1.resolveWorkspaceNameConflict)(workspaceName, lock.repositories.map(r => r.directoryName));
|
|
118
|
+
(0, logger_1.logInfo)(`Workspace "${workspaceName}" already exists — using "${(0, colors_1.colorize)(resolved, 'cyan')}" instead.`);
|
|
119
|
+
workspaceName = resolved;
|
|
120
|
+
}
|
|
121
|
+
const workspacePath = (0, validation_1.safeWorkspacePath)(workspaceName);
|
|
122
|
+
(0, logger_1.logInfo)(`Restoring ${(0, colors_1.colorize)(String(lock.repositories.length), 'cyan')} repos into workspace "${(0, colors_1.colorize)(workspaceName, 'cyan')}"`);
|
|
123
|
+
// Clone every repo (reuses the create pipeline: ghq, concurrency, dedup)
|
|
124
|
+
(0, logger_1.logStep)(1, 3, 'Cloning repositories...');
|
|
125
|
+
const { mkdir } = await Promise.resolve().then(() => __importStar(require('fs/promises')));
|
|
126
|
+
await mkdir(workspacePath, { recursive: true });
|
|
127
|
+
await (0, ghq_integration_1.warnIfGhqMissing)();
|
|
128
|
+
const entries = lock.repositories.map(r => ({
|
|
129
|
+
repo: (0, workspace_lock_1.reconstructRepo)(r),
|
|
130
|
+
directoryName: r.directoryName,
|
|
131
|
+
}));
|
|
132
|
+
const results = await (0, git_operations_1.cloneRepositories)(entries, workspacePath);
|
|
133
|
+
(0, git_operations_1.reportCloneResults)(results);
|
|
134
|
+
// Check out the recorded branch (or pinned commit) per repo
|
|
135
|
+
(0, logger_1.logStep)(2, 3, opts.pin ? 'Checking out pinned commits...' : 'Checking out recorded branches...');
|
|
136
|
+
const byDir = new Map(lock.repositories.map(r => [r.directoryName, r]));
|
|
137
|
+
for (const result of results) {
|
|
138
|
+
if (result.status !== 'success')
|
|
139
|
+
continue;
|
|
140
|
+
const entry = byDir.get(result.directoryName);
|
|
141
|
+
if (!entry)
|
|
142
|
+
continue;
|
|
143
|
+
const repoPath = path.join(workspacePath, result.directoryName);
|
|
144
|
+
const display = (0, colors_1.colorize)(result.directoryName, 'cyan');
|
|
145
|
+
if (opts.pin && entry.commit) {
|
|
146
|
+
if (!(await checkoutCommit(repoPath, entry.commit))) {
|
|
147
|
+
(0, logger_1.logWarning)(`${display}: could not check out pinned commit ${entry.commit} — left on the default branch`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
else if (entry.branch) {
|
|
151
|
+
if (await checkoutBranch(repoPath, entry.branch)) {
|
|
152
|
+
(0, logger_1.logInfo)(`${display} → ${entry.branch}`);
|
|
153
|
+
}
|
|
154
|
+
else if (entry.commit && (await checkoutCommit(repoPath, entry.commit))) {
|
|
155
|
+
(0, logger_1.logWarning)(`${display}: branch "${entry.branch}" not found — checked out commit ${entry.commit} instead`);
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
(0, logger_1.logWarning)(`${display}: could not check out "${entry.branch}" — left on the default branch`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
else if (entry.commit) {
|
|
162
|
+
// No branch was recorded (detached HEAD at lock time) — restore the commit
|
|
163
|
+
// so that state isn't silently lost even without --pin.
|
|
164
|
+
if (await checkoutCommit(repoPath, entry.commit)) {
|
|
165
|
+
(0, logger_1.logInfo)(`${display} → ${entry.commit} (detached)`);
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
(0, logger_1.logWarning)(`${display}: could not check out commit ${entry.commit} — left on the default branch`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// Metadata + agent context (same as create)
|
|
173
|
+
(0, logger_1.logStep)(3, 3, 'Saving workspace metadata...');
|
|
174
|
+
const metadata = (0, workspace_meta_1.createMetadata)(workspaceName, results, { prompt: `Restored from ${workspace_lock_1.LOCK_FILENAME}` });
|
|
175
|
+
await (0, workspace_meta_1.saveMetadata)(workspacePath, metadata);
|
|
176
|
+
const successfulRepos = results.filter(r => r.status === 'success').map(r => r.repo);
|
|
177
|
+
if (successfulRepos.length > 0) {
|
|
178
|
+
await (0, claude_integration_1.generateClaudeContext)(workspacePath, workspaceName, successfulRepos, metadata);
|
|
179
|
+
}
|
|
180
|
+
return { workspaceName, workspacePath, results };
|
|
181
|
+
}
|
|
182
|
+
async function handleRestore(opts) {
|
|
183
|
+
(0, banner_1.printBanner)();
|
|
184
|
+
try {
|
|
185
|
+
// Step 1: Load + validate the lockfile
|
|
186
|
+
let lock;
|
|
187
|
+
if (opts.lockfile === '-') {
|
|
188
|
+
lock = (0, workspace_lock_1.parseLock)(await readStdin());
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const lockPath = path.resolve(opts.lockfile || workspace_lock_1.LOCK_FILENAME);
|
|
192
|
+
try {
|
|
193
|
+
lock = await (0, workspace_lock_1.readLockFile)(lockPath);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
(0, logger_1.logError)(`Could not read lockfile at ${lockPath}`);
|
|
197
|
+
if (error instanceof Error)
|
|
198
|
+
(0, logger_1.logError)(error.message);
|
|
199
|
+
(0, logger_1.logInfo)('Pass a path (nemus restore path/to/nemus.lock) or pipe one with: nemus restore -');
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (lock.repositories.length === 0) {
|
|
204
|
+
(0, logger_1.logError)('Lockfile has no repositories to restore');
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
// gh auth (soft — private repos need it, public/other creds may not)
|
|
208
|
+
if (!(await (0, github_1.verifyGhAuth)())) {
|
|
209
|
+
(0, logger_1.logWarning)('GitHub CLI not authenticated — private repositories may fail to clone.');
|
|
210
|
+
}
|
|
211
|
+
const { workspaceName, workspacePath } = await restoreWorkspace(lock, {
|
|
212
|
+
workspace: opts.workspace,
|
|
213
|
+
pin: opts.pin,
|
|
214
|
+
});
|
|
215
|
+
(0, logger_1.logSuccess)(`Workspace "${(0, colors_1.colorize)(workspaceName, 'cyan')}" restored!`);
|
|
216
|
+
// Shell-integration auto-CD (same hook create uses)
|
|
217
|
+
try {
|
|
218
|
+
const { writeFile } = await Promise.resolve().then(() => __importStar(require('fs/promises')));
|
|
219
|
+
const os = await Promise.resolve().then(() => __importStar(require('os')));
|
|
220
|
+
await writeFile(path.join(os.homedir(), '.workspace-last-created'), workspacePath, 'utf-8');
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// non-critical
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
catch (error) {
|
|
227
|
+
(0, logger_1.logError)('Failed to restore workspace');
|
|
228
|
+
if (error instanceof Error)
|
|
229
|
+
(0, logger_1.logError)(error.message);
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
}
|
package/dist/mcp/server.js
CHANGED
|
@@ -387,6 +387,34 @@ server.tool('save-context', 'Save a progress summary to the workspace. Use this
|
|
|
387
387
|
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
388
388
|
}
|
|
389
389
|
});
|
|
390
|
+
server.tool('lock-workspace', 'Snapshot a workspace into a committable nemus.lock manifest (repos + owner + the branch each repo is on + its HEAD commit) so it can be shared and recreated elsewhere with restore-workspace. Writes nemus.lock to the workspace root by default and returns the manifest.', {
|
|
391
|
+
workspace: wsName.describe('Name of the workspace to lock'),
|
|
392
|
+
output: zod_1.z.string().optional().describe('Optional path to write the lockfile to instead of <workspace>/nemus.lock'),
|
|
393
|
+
}, async ({ workspace, output }) => {
|
|
394
|
+
try {
|
|
395
|
+
const result = await (0, tools_1.handleLockWorkspace)(workspace, output);
|
|
396
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
397
|
+
}
|
|
398
|
+
catch (error) {
|
|
399
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
400
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
401
|
+
}
|
|
402
|
+
});
|
|
403
|
+
server.tool('restore-workspace', 'Recreate a workspace from a nemus.lock manifest: clone every repo and check out the recorded branch (or the exact commit with pin). Provide the manifest inline via lockContent, or a path via lockfile (defaults to ./nemus.lock).', {
|
|
404
|
+
lockContent: zod_1.z.string().optional().describe('The nemus.lock manifest JSON, inline (preferred for agents)'),
|
|
405
|
+
lockfile: zod_1.z.string().optional().describe('Path to a nemus.lock file (used when lockContent is not given; defaults to ./nemus.lock)'),
|
|
406
|
+
workspace: wsName.optional().describe('Override the workspace name baked into the lockfile'),
|
|
407
|
+
pin: zod_1.z.boolean().optional().describe('Check out the exact recorded commit instead of the branch tip'),
|
|
408
|
+
}, async ({ lockContent, lockfile, workspace, pin }) => {
|
|
409
|
+
try {
|
|
410
|
+
const result = await (0, tools_1.handleRestoreWorkspace)({ lockContent, lockfile, workspace, pin });
|
|
411
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
412
|
+
}
|
|
413
|
+
catch (error) {
|
|
414
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
415
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
416
|
+
}
|
|
417
|
+
});
|
|
390
418
|
async function main() {
|
|
391
419
|
const transport = new stdio_js_1.StdioServerTransport();
|
|
392
420
|
await server.connect(transport);
|