@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nemus-cli/nemus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"workspaces": [
|
|
5
5
|
"packages/*"
|
|
6
6
|
],
|
|
@@ -83,6 +83,6 @@
|
|
|
83
83
|
"@types/react": "^19.2.2",
|
|
84
84
|
"ts-node": "^10.9.1",
|
|
85
85
|
"typescript": "^5.9.3",
|
|
86
|
-
"vitest": "^
|
|
86
|
+
"vitest": "^5.0.0"
|
|
87
87
|
}
|
|
88
88
|
}
|
package/skills/nemus/SKILL.md
CHANGED
|
@@ -46,6 +46,7 @@ Global flags: `-f/--force-refresh` (skip repo cache), `-y/--yes` (skip prompts),
|
|
|
46
46
|
| Pull latest (git sync) | [workspace-sync](references/workspace-sync.md) | `nemus sync [name]` | `s` |
|
|
47
47
|
| Show diff summary | [workspace-diff](references/workspace-diff.md) | `nemus diff [name]` | `di` |
|
|
48
48
|
| Run shell command across repos | [run-command](references/run-command.md) | `nemus run [name] <cmd>` | `r` |
|
|
49
|
+
| Start all repos' dev servers together | [dev](references/dev.md) | `nemus dev [name]` | — |
|
|
49
50
|
| Health check | [workspace-doctor](references/workspace-doctor.md) | `nemus doctor [name]` | `doc` |
|
|
50
51
|
| Clean node_modules / artifacts | [workspace-cleanup](references/workspace-cleanup.md) | `nemus cleanup <name>` | `cl` |
|
|
51
52
|
| Remove repo from workspace | [remove-repo](references/remove-repo.md) | `nemus remove-repo` | `rr` |
|
|
@@ -72,6 +73,18 @@ Global flags: `-f/--force-refresh` (skip repo cache), `-y/--yes` (skip prompts),
|
|
|
72
73
|
| Import suite(s) from JSON | [suite-import](references/suite-import.md) | `nemus suite import <file>` |
|
|
73
74
|
| Create workspace from suite | [suite-use](references/suite-use.md) | `nemus suite use` |
|
|
74
75
|
|
|
76
|
+
### Portable Workspaces (lock / restore)
|
|
77
|
+
|
|
78
|
+
| Intent | Reference | CLI |
|
|
79
|
+
|---|---|---|
|
|
80
|
+
| Snapshot a workspace to a committable `nemus.lock` | [lock](references/lock.md) | `nemus lock [ws] [-o file\|-]` |
|
|
81
|
+
| Recreate a workspace from a `nemus.lock` | [restore](references/restore.md) | `nemus restore [file\|-] [-w name] [--pin]` |
|
|
82
|
+
|
|
83
|
+
`lock`/`restore` is portable and recreates repos from scratch (vs. `snapshot`,
|
|
84
|
+
which is local time-travel within an existing workspace; vs. `suite`, a reusable
|
|
85
|
+
repo *template*). The lockfile records repos + owner + each repo's branch + HEAD
|
|
86
|
+
commit; it holds no secrets (only what `git remote -v` exposes).
|
|
87
|
+
|
|
75
88
|
### Cache & Repo Discovery
|
|
76
89
|
|
|
77
90
|
| Intent | Reference | CLI |
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# dev
|
|
2
|
+
|
|
3
|
+
Start every repo's dev server in a workspace at once, streaming their output into
|
|
4
|
+
one terminal with a color-coded, aligned per-repo prefix. A single Ctrl-C stops
|
|
5
|
+
them all cleanly (process-group kill, so child trees die too).
|
|
6
|
+
|
|
7
|
+
## CLI
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
nemus dev [workspace] [options]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- `[workspace]` — workspace name; omitted, resolves the current/default one.
|
|
14
|
+
- `--only <repos>` — comma-separated subset of repos to start.
|
|
15
|
+
- `--script <name>` — npm script to prefer (default: `dev` → `develop` → `start` → `serve`).
|
|
16
|
+
- `--command "<cmd>"` — run this exact command in every selected repo instead of a script.
|
|
17
|
+
- `--exit-on-failure` — tear everything down if any service exits non-zero.
|
|
18
|
+
- `--kill-timeout <seconds>` — grace period before SIGKILL on shutdown (default 5).
|
|
19
|
+
|
|
20
|
+
## Command selection (per repo)
|
|
21
|
+
|
|
22
|
+
1. `--command` if given.
|
|
23
|
+
2. Else the first `package.json` script that exists (`--script` → `dev` →
|
|
24
|
+
`develop` → `start` → `serve`), run with the repo's own package manager
|
|
25
|
+
(pnpm/yarn/npm, detected from its lockfile).
|
|
26
|
+
3. A repo with no runnable script is **skipped with a notice** — so a library in
|
|
27
|
+
the workspace doesn't block the services. If nothing is runnable, `dev` errors.
|
|
28
|
+
|
|
29
|
+
## Examples
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
nemus dev # every runnable repo in the current workspace
|
|
33
|
+
nemus dev payments --only web,api # just a subset
|
|
34
|
+
nemus dev payments --command "make run"
|
|
35
|
+
nemus dev payments --exit-on-failure
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Notes
|
|
39
|
+
|
|
40
|
+
- Long-running: it stays in the foreground hosting the servers until they exit or
|
|
41
|
+
you Ctrl-C. Not for scripting — use `nemus run` for one-shot commands.
|
|
42
|
+
- Each service runs in its own process group; shutdown SIGTERMs the group, then
|
|
43
|
+
SIGKILLs stragglers after `--kill-timeout`, so nothing is orphaned.
|
|
44
|
+
- **Platform:** clean process-group teardown is POSIX (macOS/Linux). On Windows
|
|
45
|
+
it falls back to `taskkill /T /F` (force-kill the tree, no graceful phase).
|
|
46
|
+
- `--kill-timeout 0` is honored (immediate SIGKILL after the SIGTERM).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# lock
|
|
2
|
+
|
|
3
|
+
Snapshot a workspace into a committable `nemus.lock` — the repos, each repo's
|
|
4
|
+
owner + directory name + clone URL, the branch it's currently on, and its HEAD
|
|
5
|
+
commit. Share the file (commit it, drop it in a ticket) and anyone recreates the
|
|
6
|
+
exact workspace with [`restore`](restore.md).
|
|
7
|
+
|
|
8
|
+
## CLI
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
nemus lock [workspace] [options]
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
- `[workspace]` — workspace name; omitted, resolves the current/default one.
|
|
15
|
+
- `-o, --output <file>` — write to `<file>` instead of `<workspace>/nemus.lock`.
|
|
16
|
+
Use `-o -` to print the lockfile JSON to stdout (pipe/redirect it anywhere).
|
|
17
|
+
- `--all` — write a `nemus.lock` into **every** workspace (makes a whole machine
|
|
18
|
+
portable in one shot). Skips workspaces that already have a lockfile unless
|
|
19
|
+
`--force` is given. Cannot be combined with `--output`. This is the explicit
|
|
20
|
+
alternative to a hidden snapshot side effect in `migrate`.
|
|
21
|
+
|
|
22
|
+
## Examples
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
nemus lock # write ./nemus.lock into the current workspace
|
|
26
|
+
nemus lock checkout-flow # snapshot a named workspace
|
|
27
|
+
nemus lock checkout-flow -o cf.lock
|
|
28
|
+
nemus lock checkout-flow -o - | pbcopy # copy the manifest to the clipboard
|
|
29
|
+
nemus lock --all # lock every workspace (skips existing lockfiles)
|
|
30
|
+
nemus lock --all --force # …and overwrite existing ones
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Notes
|
|
34
|
+
|
|
35
|
+
- Repos whose original clone failed are skipped.
|
|
36
|
+
- A repo on a detached HEAD records only its commit (no branch).
|
|
37
|
+
- The lockfile contains only what `git remote -v` already exposes — safe to commit.
|
|
38
|
+
- Distinct from `snapshot` (local time-travel) and `suite` (reusable template).
|
|
@@ -28,4 +28,6 @@ nemus mcp status
|
|
|
28
28
|
|
|
29
29
|
When installed, Claude Code gains access to Nemus tools:
|
|
30
30
|
- `create-workspace`, `list-workspaces`, `workspace-status`, etc.
|
|
31
|
+
- `lock-workspace` / `restore-workspace` — snapshot a workspace to a portable
|
|
32
|
+
`nemus.lock` and recreate it elsewhere (accepts the manifest inline).
|
|
31
33
|
- Enables natural language workspace management via `nemus -- <prompt>`
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# restore
|
|
2
|
+
|
|
3
|
+
Recreate a workspace from a `nemus.lock` (see [`lock`](lock.md)) on any machine:
|
|
4
|
+
clones every repo and checks out the recorded branch, then writes metadata +
|
|
5
|
+
agent context exactly like `create`.
|
|
6
|
+
|
|
7
|
+
## CLI
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
nemus restore [lockfile] [options]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
- `[lockfile]` — path to a `nemus.lock`; omitted, uses `./nemus.lock`. Use `-`
|
|
14
|
+
to read the lockfile from stdin.
|
|
15
|
+
- `-w, --workspace <name>` — override the workspace name baked into the lockfile
|
|
16
|
+
(also used to resolve a name clash).
|
|
17
|
+
- `--pin` — check out the exact recorded commit for each repo instead of the
|
|
18
|
+
branch tip.
|
|
19
|
+
- `-y, --yes` — non-interactive (skips the post-restore agent launch).
|
|
20
|
+
|
|
21
|
+
## Examples
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
nemus restore # from ./nemus.lock
|
|
25
|
+
nemus restore cf.lock # from a specific file
|
|
26
|
+
nemus restore cf.lock -w experiment # into a differently-named workspace
|
|
27
|
+
nemus restore cf.lock --pin # reproduce exact commits, not branch tips
|
|
28
|
+
cat cf.lock | nemus restore - # from stdin
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Notes
|
|
32
|
+
|
|
33
|
+
- URLs are rebuilt for the restorer's `cloneProtocol` (https/ssh) using the
|
|
34
|
+
locked host + owner + name, so an ssh-locked workspace restores fine for an
|
|
35
|
+
https user.
|
|
36
|
+
- If a recorded branch no longer exists on the remote, restore falls back to the
|
|
37
|
+
recorded commit and warns.
|
|
38
|
+
- Name clashes auto-resolve to a suffixed name (same as `create`).
|
|
39
|
+
- Needs GitHub auth for private repos (warns if `gh` is unauthenticated).
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { WORKSPACES_DIR } from '../utils/config';
|
|
4
|
+
import { loadMetadata } from '../utils/workspace-meta';
|
|
5
|
+
import { resolveWorkspace, getGlobalOpts, parseList } from '../utils/command-helpers';
|
|
6
|
+
import { resolveDevCommand, runDev, type DevService } from '../utils/dev-orchestrator';
|
|
7
|
+
import { logError, logInfo, logWarning } from '../utils/logger';
|
|
8
|
+
import { colorize } from '../utils/colors';
|
|
9
|
+
|
|
10
|
+
export function registerDevCommand(parent: Command) {
|
|
11
|
+
parent
|
|
12
|
+
.command('dev [workspace]')
|
|
13
|
+
.description('Start every repo\'s dev server together, with unified color-coded logs (Ctrl-C stops all)')
|
|
14
|
+
.option('--only <repos>', 'Comma-separated subset of repos to start')
|
|
15
|
+
.option('--script <name>', 'npm script to prefer (default: dev → develop → start → serve)')
|
|
16
|
+
.option('--command <cmd>', 'Run this exact command in every selected repo instead of a script')
|
|
17
|
+
.option('--exit-on-failure', 'Tear everything down if any service exits non-zero')
|
|
18
|
+
.option('--kill-timeout <seconds>', 'Grace period before SIGKILL on shutdown', '5')
|
|
19
|
+
.action(async (workspace, opts, cmd) => {
|
|
20
|
+
const globalOpts = getGlobalOpts(cmd);
|
|
21
|
+
await handleDev({ workspace, ...opts, ...globalOpts });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function handleDev(opts: {
|
|
26
|
+
workspace?: string;
|
|
27
|
+
only?: string;
|
|
28
|
+
script?: string;
|
|
29
|
+
command?: string;
|
|
30
|
+
exitOnFailure?: boolean;
|
|
31
|
+
killTimeout?: string;
|
|
32
|
+
}) {
|
|
33
|
+
try {
|
|
34
|
+
const workspaceName = await resolveWorkspace(opts.workspace);
|
|
35
|
+
const workspacePath = path.join(WORKSPACES_DIR, workspaceName);
|
|
36
|
+
|
|
37
|
+
const metadata = await loadMetadata(workspacePath);
|
|
38
|
+
if (!metadata) {
|
|
39
|
+
logError(`Workspace not found: ${workspaceName}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let repos = metadata.repositories.filter(r => r.status === 'success');
|
|
44
|
+
|
|
45
|
+
if (opts.only) {
|
|
46
|
+
const wanted = new Set(parseList(opts.only));
|
|
47
|
+
repos = repos.filter(r => wanted.has(r.directoryName) || wanted.has(r.name));
|
|
48
|
+
if (repos.length === 0) {
|
|
49
|
+
logError(`No repos in "${workspaceName}" matched --only ${opts.only}`);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const services: DevService[] = [];
|
|
55
|
+
const skipped: string[] = [];
|
|
56
|
+
for (const repo of repos) {
|
|
57
|
+
const cwd = path.join(workspacePath, repo.directoryName);
|
|
58
|
+
const command = resolveDevCommand(cwd, { commandOverride: opts.command, script: opts.script });
|
|
59
|
+
if (!command) {
|
|
60
|
+
skipped.push(repo.directoryName);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
services.push({ label: repo.directoryName, cwd, command });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (services.length === 0) {
|
|
67
|
+
logError(`Nothing to run in "${workspaceName}".`);
|
|
68
|
+
logInfo(opts.script
|
|
69
|
+
? `No repo has a "${opts.script}" script.`
|
|
70
|
+
: 'No repo has a dev/develop/start/serve script. Pass --command "<cmd>" to run something explicitly.');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (skipped.length > 0) {
|
|
75
|
+
logWarning(`Skipped (no runnable script): ${skipped.join(', ')}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
console.log('\n' + colorize('Starting dev servers', 'bright') + colorize(` · ${workspaceName}`, 'cyan'));
|
|
79
|
+
for (const s of services) {
|
|
80
|
+
console.log(` ${colorize(s.label, 'cyan')} ${colorize(s.command.command, 'gray')} ${colorize(`(${s.command.source})`, 'gray')}`);
|
|
81
|
+
}
|
|
82
|
+
console.log(colorize(' Ctrl-C to stop all.\n', 'gray'));
|
|
83
|
+
|
|
84
|
+
// NaN check (not `|| 5`) so an explicit --kill-timeout 0 (immediate SIGKILL)
|
|
85
|
+
// is honored rather than coerced back to the default.
|
|
86
|
+
const parsedTimeout = Number(opts.killTimeout);
|
|
87
|
+
const killTimeoutMs = Math.max(0, Number.isFinite(parsedTimeout) ? parsedTimeout : 5) * 1000;
|
|
88
|
+
const code = await runDev(services, {
|
|
89
|
+
exitOnFailure: opts.exitOnFailure,
|
|
90
|
+
killTimeoutMs,
|
|
91
|
+
});
|
|
92
|
+
process.exit(code);
|
|
93
|
+
} catch (error) {
|
|
94
|
+
logError('Failed to start dev servers');
|
|
95
|
+
if (error instanceof Error) logError(error.message);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import { WORKSPACES_DIR } from '../utils/config';
|
|
5
|
+
import { loadMetadata, listWorkspaces } from '../utils/workspace-meta';
|
|
6
|
+
import { resolveWorkspace, getGlobalOpts } from '../utils/command-helpers';
|
|
7
|
+
import { buildLock, serializeLock, writeLock, LOCK_FILENAME } from '../utils/workspace-lock';
|
|
8
|
+
import { logError, logInfo, logSuccess, logWarning } from '../utils/logger';
|
|
9
|
+
import { colorize } from '../utils/colors';
|
|
10
|
+
|
|
11
|
+
export function registerLockCommand(parent: Command) {
|
|
12
|
+
parent
|
|
13
|
+
.command('lock [workspace]')
|
|
14
|
+
.description('Snapshot a workspace into a committable nemus.lock (repos + branches)')
|
|
15
|
+
.option('-o, --output <file>', 'Write the lockfile to <file> ("-" for stdout) instead of the workspace root')
|
|
16
|
+
.option('--all', 'Write a nemus.lock into every workspace (skips ones that already have one)')
|
|
17
|
+
.option('--force', 'With --all, overwrite an existing nemus.lock')
|
|
18
|
+
.action(async (workspace, opts, cmd) => {
|
|
19
|
+
const globalOpts = getGlobalOpts(cmd);
|
|
20
|
+
await handleLock({ workspace, ...opts, ...globalOpts });
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function fileExists(p: string): Promise<boolean> {
|
|
25
|
+
try {
|
|
26
|
+
await fs.access(p);
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function handleLock(opts: {
|
|
34
|
+
workspace?: string;
|
|
35
|
+
output?: string;
|
|
36
|
+
all?: boolean;
|
|
37
|
+
force?: boolean;
|
|
38
|
+
}) {
|
|
39
|
+
if (opts.all) {
|
|
40
|
+
await handleLockAll(opts);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const workspaceName = await resolveWorkspace(opts.workspace);
|
|
46
|
+
const workspacePath = path.join(WORKSPACES_DIR, workspaceName);
|
|
47
|
+
|
|
48
|
+
const metadata = await loadMetadata(workspacePath);
|
|
49
|
+
if (!metadata) {
|
|
50
|
+
logError(`Workspace not found: ${workspaceName}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const lock = await buildLock(workspacePath, metadata);
|
|
55
|
+
|
|
56
|
+
// `-o -`: emit only the lockfile JSON so it can be piped/redirected cleanly.
|
|
57
|
+
if (opts.output === '-') {
|
|
58
|
+
process.stdout.write(serializeLock(lock));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const outPath = opts.output
|
|
63
|
+
? path.resolve(opts.output)
|
|
64
|
+
: path.join(workspacePath, LOCK_FILENAME);
|
|
65
|
+
await writeLock(outPath, lock);
|
|
66
|
+
|
|
67
|
+
logSuccess(`Wrote ${colorize(LOCK_FILENAME, 'cyan')} (${lock.repositories.length} repos) → ${outPath}`);
|
|
68
|
+
logInfo('Commit or share it, then recreate the workspace with: nemus restore');
|
|
69
|
+
} catch (error) {
|
|
70
|
+
logError(error instanceof Error ? error.message : 'Failed to write lockfile');
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Bulk mode: drop a nemus.lock into every workspace so a whole machine's
|
|
77
|
+
* workspaces become portable in one shot. Existing lockfiles are left alone
|
|
78
|
+
* (they may be hand-edited / committed) unless --force is given — this is the
|
|
79
|
+
* explicit, discoverable alternative to burying a snapshot side effect inside
|
|
80
|
+
* `migrate`, which is meant to be re-run freely.
|
|
81
|
+
*/
|
|
82
|
+
async function handleLockAll(opts: { output?: string; force?: boolean }) {
|
|
83
|
+
if (opts.output) {
|
|
84
|
+
logError('--all writes one nemus.lock per workspace and cannot be combined with --output.');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const workspaces = await listWorkspaces(false);
|
|
89
|
+
if (workspaces.length === 0) {
|
|
90
|
+
logInfo('No workspaces found. Nothing to lock.');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let written = 0;
|
|
95
|
+
let skipped = 0;
|
|
96
|
+
let errors = 0;
|
|
97
|
+
|
|
98
|
+
for (const ws of workspaces) {
|
|
99
|
+
const metadata = ws.metadata ?? (await loadMetadata(ws.path));
|
|
100
|
+
if (!metadata) {
|
|
101
|
+
logWarning(` ${ws.name}: no metadata — skipped (run 'nemus migrate' first)`);
|
|
102
|
+
skipped++;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const outPath = path.join(ws.path, LOCK_FILENAME);
|
|
107
|
+
if (!opts.force && (await fileExists(outPath))) {
|
|
108
|
+
logInfo(` ${colorize(ws.name, 'cyan')}: ${LOCK_FILENAME} already exists — skipped (use --force to overwrite)`);
|
|
109
|
+
skipped++;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
const lock = await buildLock(ws.path, metadata);
|
|
115
|
+
await writeLock(outPath, lock);
|
|
116
|
+
logSuccess(` ${colorize(ws.name, 'cyan')}: wrote ${LOCK_FILENAME} (${lock.repositories.length} repos)`);
|
|
117
|
+
written++;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
logError(` ${ws.name}: ${error instanceof Error ? error.message : 'failed to write lockfile'}`);
|
|
120
|
+
errors++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
console.log('');
|
|
125
|
+
logInfo('Bulk lock complete:');
|
|
126
|
+
console.log(` ${colorize(String(written), 'green')} written`);
|
|
127
|
+
if (skipped > 0) console.log(` ${colorize(String(skipped), 'yellow')} skipped`);
|
|
128
|
+
if (errors > 0) console.log(` ${colorize(String(errors), 'red')} errors`);
|
|
129
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { getGlobalOpts } from '../utils/command-helpers';
|
|
6
|
+
import { readLockFile, parseLock, reconstructRepo, LOCK_FILENAME, type WorkspaceLock, type LockRepo } from '../utils/workspace-lock';
|
|
7
|
+
import { cloneRepositories, reportCloneResults } from '../utils/git-operations';
|
|
8
|
+
import { warnIfGhqMissing } from '../utils/ghq-integration';
|
|
9
|
+
import { createMetadata, saveMetadata } from '../utils/workspace-meta';
|
|
10
|
+
import { generateClaudeContext } from '../utils/claude-integration';
|
|
11
|
+
import { verifyGhAuth } from '../utils/github';
|
|
12
|
+
import { validateWorkspaceName, checkWorkspaceExists, sanitizeWorkspaceName, resolveWorkspaceNameConflict, safeWorkspacePath } from '../utils/validation';
|
|
13
|
+
import { logError, logInfo, logSuccess, logStep, logWarning } from '../utils/logger';
|
|
14
|
+
import { colorize } from '../utils/colors';
|
|
15
|
+
import { printBanner } from '../utils/banner';
|
|
16
|
+
import type { CloneResult } from '../types';
|
|
17
|
+
|
|
18
|
+
export interface RestoreResult {
|
|
19
|
+
workspaceName: string;
|
|
20
|
+
workspacePath: string;
|
|
21
|
+
results: CloneResult[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const execFileAsync = promisify(execFile);
|
|
25
|
+
const GIT_TIMEOUT = 30000;
|
|
26
|
+
|
|
27
|
+
export function registerRestoreCommand(parent: Command) {
|
|
28
|
+
parent
|
|
29
|
+
.command('restore [lockfile]')
|
|
30
|
+
.description('Recreate a workspace from a nemus.lock (defaults to ./nemus.lock, "-" for stdin)')
|
|
31
|
+
.option('-w, --workspace <name>', 'Override the workspace name baked into the lockfile')
|
|
32
|
+
.option('--pin', 'Check out the exact recorded commit instead of the branch tip')
|
|
33
|
+
.action(async (lockfile, opts, cmd) => {
|
|
34
|
+
const globalOpts = getGlobalOpts(cmd);
|
|
35
|
+
await handleRestore({ lockfile, ...opts, ...globalOpts });
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readStdin(): Promise<string> {
|
|
40
|
+
const chunks: Buffer[] = [];
|
|
41
|
+
for await (const chunk of process.stdin) chunks.push(chunk as Buffer);
|
|
42
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// `branch`/`commit` come from an untrusted lockfile (already ref-validated in
|
|
46
|
+
// parseLock); the `--end-of-options` guard is defense-in-depth so a ref can
|
|
47
|
+
// never be reparsed as a git option even if validation is bypassed.
|
|
48
|
+
/** Check out `branch` in a freshly-cloned repo, creating a tracking branch if needed. */
|
|
49
|
+
async function checkoutBranch(repoPath: string, branch: string): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
await execFileAsync('git', ['checkout', '--end-of-options', branch], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
52
|
+
return true;
|
|
53
|
+
} catch {
|
|
54
|
+
try {
|
|
55
|
+
await execFileAsync('git', ['checkout', '-b', branch, '--end-of-options', `origin/${branch}`], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
56
|
+
return true;
|
|
57
|
+
} catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function checkoutCommit(repoPath: string, commit: string): Promise<boolean> {
|
|
64
|
+
try {
|
|
65
|
+
await execFileAsync('git', ['checkout', '--end-of-options', commit], { cwd: repoPath, timeout: GIT_TIMEOUT });
|
|
66
|
+
return true;
|
|
67
|
+
} catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Core restore: clone every repo in a (already-validated) lock and check out the
|
|
74
|
+
* recorded branch (or the exact commit with `pin`), then write metadata + agent
|
|
75
|
+
* context — exactly like `create`. Throws on fatal errors (no process.exit) and
|
|
76
|
+
* logs progress via the logger (stderr), so both the CLI and the MCP tool can
|
|
77
|
+
* call it. Callers own presentation (final message, shell-CD hook).
|
|
78
|
+
*/
|
|
79
|
+
export async function restoreWorkspace(
|
|
80
|
+
lock: WorkspaceLock,
|
|
81
|
+
opts: { workspace?: string; pin?: boolean } = {}
|
|
82
|
+
): Promise<RestoreResult> {
|
|
83
|
+
if (lock.repositories.length === 0) {
|
|
84
|
+
throw new Error('Lockfile has no repositories to restore');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Resolve the target workspace name
|
|
88
|
+
let workspaceName = sanitizeWorkspaceName(opts.workspace || lock.workspace);
|
|
89
|
+
const nameError = validateWorkspaceName(workspaceName);
|
|
90
|
+
if (nameError !== true) {
|
|
91
|
+
throw new Error(typeof nameError === 'string' ? nameError : 'Invalid workspace name');
|
|
92
|
+
}
|
|
93
|
+
if (await checkWorkspaceExists(workspaceName)) {
|
|
94
|
+
const resolved = await resolveWorkspaceNameConflict(
|
|
95
|
+
workspaceName,
|
|
96
|
+
lock.repositories.map(r => r.directoryName)
|
|
97
|
+
);
|
|
98
|
+
logInfo(`Workspace "${workspaceName}" already exists — using "${colorize(resolved, 'cyan')}" instead.`);
|
|
99
|
+
workspaceName = resolved;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const workspacePath = safeWorkspacePath(workspaceName);
|
|
103
|
+
logInfo(`Restoring ${colorize(String(lock.repositories.length), 'cyan')} repos into workspace "${colorize(workspaceName, 'cyan')}"`);
|
|
104
|
+
|
|
105
|
+
// Clone every repo (reuses the create pipeline: ghq, concurrency, dedup)
|
|
106
|
+
logStep(1, 3, 'Cloning repositories...');
|
|
107
|
+
const { mkdir } = await import('fs/promises');
|
|
108
|
+
await mkdir(workspacePath, { recursive: true });
|
|
109
|
+
await warnIfGhqMissing();
|
|
110
|
+
|
|
111
|
+
const entries = lock.repositories.map(r => ({
|
|
112
|
+
repo: reconstructRepo(r),
|
|
113
|
+
directoryName: r.directoryName,
|
|
114
|
+
}));
|
|
115
|
+
const results = await cloneRepositories(entries, workspacePath);
|
|
116
|
+
reportCloneResults(results);
|
|
117
|
+
|
|
118
|
+
// Check out the recorded branch (or pinned commit) per repo
|
|
119
|
+
logStep(2, 3, opts.pin ? 'Checking out pinned commits...' : 'Checking out recorded branches...');
|
|
120
|
+
const byDir = new Map<string, LockRepo>(lock.repositories.map(r => [r.directoryName, r]));
|
|
121
|
+
for (const result of results) {
|
|
122
|
+
if (result.status !== 'success') continue;
|
|
123
|
+
const entry = byDir.get(result.directoryName);
|
|
124
|
+
if (!entry) continue;
|
|
125
|
+
const repoPath = path.join(workspacePath, result.directoryName);
|
|
126
|
+
const display = colorize(result.directoryName, 'cyan');
|
|
127
|
+
|
|
128
|
+
if (opts.pin && entry.commit) {
|
|
129
|
+
if (!(await checkoutCommit(repoPath, entry.commit))) {
|
|
130
|
+
logWarning(`${display}: could not check out pinned commit ${entry.commit} — left on the default branch`);
|
|
131
|
+
}
|
|
132
|
+
} else if (entry.branch) {
|
|
133
|
+
if (await checkoutBranch(repoPath, entry.branch)) {
|
|
134
|
+
logInfo(`${display} → ${entry.branch}`);
|
|
135
|
+
} else if (entry.commit && (await checkoutCommit(repoPath, entry.commit))) {
|
|
136
|
+
logWarning(`${display}: branch "${entry.branch}" not found — checked out commit ${entry.commit} instead`);
|
|
137
|
+
} else {
|
|
138
|
+
logWarning(`${display}: could not check out "${entry.branch}" — left on the default branch`);
|
|
139
|
+
}
|
|
140
|
+
} else if (entry.commit) {
|
|
141
|
+
// No branch was recorded (detached HEAD at lock time) — restore the commit
|
|
142
|
+
// so that state isn't silently lost even without --pin.
|
|
143
|
+
if (await checkoutCommit(repoPath, entry.commit)) {
|
|
144
|
+
logInfo(`${display} → ${entry.commit} (detached)`);
|
|
145
|
+
} else {
|
|
146
|
+
logWarning(`${display}: could not check out commit ${entry.commit} — left on the default branch`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Metadata + agent context (same as create)
|
|
152
|
+
logStep(3, 3, 'Saving workspace metadata...');
|
|
153
|
+
const metadata = createMetadata(workspaceName, results, { prompt: `Restored from ${LOCK_FILENAME}` });
|
|
154
|
+
await saveMetadata(workspacePath, metadata);
|
|
155
|
+
|
|
156
|
+
const successfulRepos = results.filter(r => r.status === 'success').map(r => r.repo);
|
|
157
|
+
if (successfulRepos.length > 0) {
|
|
158
|
+
await generateClaudeContext(workspacePath, workspaceName, successfulRepos, metadata);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return { workspaceName, workspacePath, results };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function handleRestore(opts: {
|
|
165
|
+
lockfile?: string;
|
|
166
|
+
workspace?: string;
|
|
167
|
+
pin?: boolean;
|
|
168
|
+
yes: boolean;
|
|
169
|
+
}) {
|
|
170
|
+
printBanner();
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
// Step 1: Load + validate the lockfile
|
|
174
|
+
let lock: WorkspaceLock;
|
|
175
|
+
if (opts.lockfile === '-') {
|
|
176
|
+
lock = parseLock(await readStdin());
|
|
177
|
+
} else {
|
|
178
|
+
const lockPath = path.resolve(opts.lockfile || LOCK_FILENAME);
|
|
179
|
+
try {
|
|
180
|
+
lock = await readLockFile(lockPath);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
logError(`Could not read lockfile at ${lockPath}`);
|
|
183
|
+
if (error instanceof Error) logError(error.message);
|
|
184
|
+
logInfo('Pass a path (nemus restore path/to/nemus.lock) or pipe one with: nemus restore -');
|
|
185
|
+
process.exit(1);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (lock.repositories.length === 0) {
|
|
190
|
+
logError('Lockfile has no repositories to restore');
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// gh auth (soft — private repos need it, public/other creds may not)
|
|
195
|
+
if (!(await verifyGhAuth())) {
|
|
196
|
+
logWarning('GitHub CLI not authenticated — private repositories may fail to clone.');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const { workspaceName, workspacePath } = await restoreWorkspace(lock, {
|
|
200
|
+
workspace: opts.workspace,
|
|
201
|
+
pin: opts.pin,
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
logSuccess(`Workspace "${colorize(workspaceName, 'cyan')}" restored!`);
|
|
205
|
+
|
|
206
|
+
// Shell-integration auto-CD (same hook create uses)
|
|
207
|
+
try {
|
|
208
|
+
const { writeFile } = await import('fs/promises');
|
|
209
|
+
const os = await import('os');
|
|
210
|
+
await writeFile(path.join(os.homedir(), '.workspace-last-created'), workspacePath, 'utf-8');
|
|
211
|
+
} catch {
|
|
212
|
+
// non-critical
|
|
213
|
+
}
|
|
214
|
+
} catch (error) {
|
|
215
|
+
logError('Failed to restore workspace');
|
|
216
|
+
if (error instanceof Error) logError(error.message);
|
|
217
|
+
process.exit(1);
|
|
218
|
+
}
|
|
219
|
+
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
handleSuiteImport,
|
|
42
42
|
handleSuiteUse,
|
|
43
43
|
handleSaveContext,
|
|
44
|
+
handleLockWorkspace,
|
|
45
|
+
handleRestoreWorkspace,
|
|
44
46
|
} from './tools';
|
|
45
47
|
|
|
46
48
|
import { getPackageVersion } from '../utils/config';
|
|
@@ -567,6 +569,44 @@ server.tool(
|
|
|
567
569
|
}
|
|
568
570
|
);
|
|
569
571
|
|
|
572
|
+
server.tool(
|
|
573
|
+
'lock-workspace',
|
|
574
|
+
'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.',
|
|
575
|
+
{
|
|
576
|
+
workspace: wsName.describe('Name of the workspace to lock'),
|
|
577
|
+
output: z.string().optional().describe('Optional path to write the lockfile to instead of <workspace>/nemus.lock'),
|
|
578
|
+
},
|
|
579
|
+
async ({ workspace, output }) => {
|
|
580
|
+
try {
|
|
581
|
+
const result = await handleLockWorkspace(workspace, output);
|
|
582
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
583
|
+
} catch (error) {
|
|
584
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
585
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
);
|
|
589
|
+
|
|
590
|
+
server.tool(
|
|
591
|
+
'restore-workspace',
|
|
592
|
+
'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).',
|
|
593
|
+
{
|
|
594
|
+
lockContent: z.string().optional().describe('The nemus.lock manifest JSON, inline (preferred for agents)'),
|
|
595
|
+
lockfile: z.string().optional().describe('Path to a nemus.lock file (used when lockContent is not given; defaults to ./nemus.lock)'),
|
|
596
|
+
workspace: wsName.optional().describe('Override the workspace name baked into the lockfile'),
|
|
597
|
+
pin: z.boolean().optional().describe('Check out the exact recorded commit instead of the branch tip'),
|
|
598
|
+
},
|
|
599
|
+
async ({ lockContent, lockfile, workspace, pin }) => {
|
|
600
|
+
try {
|
|
601
|
+
const result = await handleRestoreWorkspace({ lockContent, lockfile, workspace, pin });
|
|
602
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
603
|
+
} catch (error) {
|
|
604
|
+
const msg = error instanceof Error ? error.message : 'Unknown error';
|
|
605
|
+
return { content: [{ type: 'text', text: `Error: ${msg}` }], isError: true };
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
);
|
|
609
|
+
|
|
570
610
|
async function main() {
|
|
571
611
|
const transport = new StdioServerTransport();
|
|
572
612
|
await server.connect(transport);
|