@nemus-cli/nemus 0.15.1 → 0.16.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 +45 -0
- package/README.md +28 -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 +4 -0
- package/dist/utils/workspace-lock.js +256 -0
- package/package.json +2 -2
- package/scripts/postinstall.js +89 -13
- package/skills/nemus/SKILL.md +12 -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/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/postinstall.test.ts +115 -42
- package/src/program.ts +4 -0
- package/src/utils/workspace-lock.test.ts +200 -0
- package/src/utils/workspace-lock.ts +245 -0
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nemus-cli/nemus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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/scripts/postinstall.js
CHANGED
|
@@ -17,18 +17,88 @@ const fs = require('fs');
|
|
|
17
17
|
const os = require('os');
|
|
18
18
|
const path = require('path');
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Classify the install context, so first-run setup (interactive `configure` +
|
|
22
|
+
* shell integration) runs only when it makes sense — and NEVER on a transient
|
|
23
|
+
* one-off runner (npx / dlx), where an interactive prompt on the controlling
|
|
24
|
+
* terminal would HANG. Pure (all inputs injected) so it's unit-tested.
|
|
25
|
+
*
|
|
26
|
+
* Returns: 'ci' | 'transient' | 'local' | 'global-npm' | 'global-other'.
|
|
27
|
+
*
|
|
28
|
+
* Detection is empirical (env dumped from real runners), because the managers
|
|
29
|
+
* disagree on what they set:
|
|
30
|
+
* • npm — npx sets npm_command="exec"; `-g` sets npm_config_global="true"; a
|
|
31
|
+
* local install sets it "false". Reliable.
|
|
32
|
+
* • pnpm — `pnpm dlx` sets NEITHER npm_command NOR npm_config_global, only
|
|
33
|
+
* npm_config_user_agent="pnpm/…". It DOES stage the package under a
|
|
34
|
+
* per-run cache path ("…/pnpm/dlx/<hash>/…"), which we key on.
|
|
35
|
+
* • yarn — classic `yarn global add` sets only "yarn/…" (no npm_command /
|
|
36
|
+
* npm_config_global); berry `yarn dlx` stages under a temp dir.
|
|
37
|
+
* So a bare yarn/pnpm user-agent is treated as a GLOBAL install UNLESS the
|
|
38
|
+
* install PATH shows a transient runner cache. The path is matched RAW (only the
|
|
39
|
+
* macOS /private symlink prefix is string-normalized, below) — deliberately NOT
|
|
40
|
+
* fs.realpathSync'd: realpath resolves a pnpm/yarn staging dir through its
|
|
41
|
+
* symlinks into a content-addressed store path that no longer contains /dlx/,
|
|
42
|
+
* which would defeat the marker. String-normalizing just the /private prefix
|
|
43
|
+
* fixes the macOS /var↔/private/var temp-dir case without touching the markers.
|
|
44
|
+
*
|
|
45
|
+
* NOTE on failure mode: because the interactive `configure` (the step that can
|
|
46
|
+
* hang on /dev/tty) is separately gated to a confirmed npm `-g` install (see
|
|
47
|
+
* `certainNpmGlobal`), a misclassified `pnpm dlx`/`yarn dlx` lands in
|
|
48
|
+
* 'global-other' and can never hang — it skips `configure`. The path signal's
|
|
49
|
+
* real job is therefore to stop a throwaway dlx run from spuriously appending
|
|
50
|
+
* the source line to the user's shell RC, not to provide hang-safety.
|
|
51
|
+
*/
|
|
52
|
+
function classifyInstall({ env, dirname, tmpDir }) {
|
|
53
|
+
if (env.CI) return 'ci';
|
|
54
|
+
|
|
55
|
+
const cmd = (env.npm_command || '').toLowerCase();
|
|
56
|
+
// macOS surfaces the temp dir both as /var/folders/… (os.tmpdir(), a symlink)
|
|
57
|
+
// and /private/var/folders/… (the realpath a staged package resolves to). Strip
|
|
58
|
+
// the well-known /private symlink prefix from both sides so the temp-dir
|
|
59
|
+
// comparison survives it — done by string, not fs.realpathSync, to keep this
|
|
60
|
+
// function pure (and correct for paths that don't exist yet, e.g. in tests).
|
|
61
|
+
const norm = (p) => String(p || '').replace(/\\/g, '/').replace(/^\/private(?=\/)/, '');
|
|
62
|
+
const dir = norm(dirname);
|
|
63
|
+
const tmp = norm(tmpDir);
|
|
64
|
+
const stagedInRunnerCache =
|
|
65
|
+
/\/_npx\//.test(dir) || // npm npx
|
|
66
|
+
/\/dlx\//.test(dir) || // pnpm dlx (or any /dlx/ cache)
|
|
67
|
+
(tmp !== '' && (dir === tmp || dir.startsWith(tmp + '/'))); // yarn berry dlx et al.
|
|
68
|
+
if (cmd === 'exec' || cmd === 'dlx' || stagedInRunnerCache) return 'transient';
|
|
69
|
+
|
|
70
|
+
const global = String(env.npm_config_global).toLowerCase();
|
|
71
|
+
if (global === 'true') return 'global-npm';
|
|
72
|
+
if (global === 'false') return 'local';
|
|
73
|
+
|
|
74
|
+
// No npm_config_global (yarn/pnpm): a bare manager user-agent means a global
|
|
75
|
+
// install here (their transient runners were caught above).
|
|
76
|
+
const manager = (env.npm_config_user_agent || '').toLowerCase().split('/')[0];
|
|
77
|
+
if (manager === 'yarn' || manager === 'pnpm') return 'global-other';
|
|
78
|
+
return 'local';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = { classifyInstall };
|
|
82
|
+
|
|
83
|
+
// When imported (unit tests) rather than run as the postinstall script, stop
|
|
84
|
+
// here — export the classifier without executing any install side effects.
|
|
85
|
+
// (CommonJS wraps modules in a function, so a top-level return is valid.)
|
|
86
|
+
if (require.main !== module) return;
|
|
87
|
+
|
|
88
|
+
// Pass the RAW __dirname (not realpath'd): the transient-runner markers below
|
|
89
|
+
// (/_npx/, /dlx/) live in the path the runner *constructs*, and fs.realpathSync
|
|
90
|
+
// would resolve a pnpm/yarn staging dir through its symlinks into a
|
|
91
|
+
// content-addressed store path that no longer contains the marker — defeating
|
|
92
|
+
// the very check. The macOS /var↔/private/var temp symlink is instead handled
|
|
93
|
+
// deterministically by string normalization inside classifyInstall.
|
|
94
|
+
const installDecision = classifyInstall({ env: process.env, dirname: __dirname, tmpDir: os.tmpdir() });
|
|
95
|
+
// Only 'global-*' installs get first-run setup; ci/transient/local are no-ops.
|
|
96
|
+
if (installDecision !== 'global-npm' && installDecision !== 'global-other') process.exit(0);
|
|
97
|
+
// Interactive `configure` (reaches /dev/tty) fires ONLY when we're certain it's
|
|
98
|
+
// an npm `-g` install. yarn/pnpm can't be told apart from their dlx runners by
|
|
99
|
+
// env alone, so they get the NON-interactive shell integration below + a hint —
|
|
100
|
+
// which can never hang.
|
|
101
|
+
const certainNpmGlobal = installDecision === 'global-npm';
|
|
32
102
|
|
|
33
103
|
const PKG_ROOT = path.join(__dirname, '..');
|
|
34
104
|
const SHELL_SCRIPT = path.join(PKG_ROOT, 'install-shell-integration.sh');
|
|
@@ -79,7 +149,7 @@ function openControllingTty() {
|
|
|
79
149
|
}
|
|
80
150
|
}
|
|
81
151
|
|
|
82
|
-
if (!optedOut() && fs.existsSync(CLI_BIN) && !alreadyConfigured()) {
|
|
152
|
+
if (certainNpmGlobal && !optedOut() && fs.existsSync(CLI_BIN) && !alreadyConfigured()) {
|
|
83
153
|
const tty = openControllingTty();
|
|
84
154
|
if (tty !== null) {
|
|
85
155
|
try {
|
|
@@ -156,6 +226,12 @@ console.log('');
|
|
|
156
226
|
console.log(' Or open a new terminal tab. Until then, auto-CD into a new');
|
|
157
227
|
console.log(' workspace won\'t work (the CLI itself still runs fine).');
|
|
158
228
|
console.log('');
|
|
229
|
+
if (!certainNpmGlobal) {
|
|
230
|
+
// yarn/pnpm global: we installed shell integration but deliberately did NOT
|
|
231
|
+
// launch the interactive configure — point the user at it explicitly.
|
|
232
|
+
console.log(' \x1b[90m(yarn/pnpm install — run \x1b[36mnemus configure\x1b[90m to finish first-time setup.)\x1b[0m');
|
|
233
|
+
console.log('');
|
|
234
|
+
}
|
|
159
235
|
console.log(' Tip: \x1b[36mnemus\x1b[0m works immediately (\x1b[36mgv\x1b[0m is a short alias):');
|
|
160
236
|
console.log(' \x1b[36mnemus configure\x1b[0m \x1b[90m# first-time setup\x1b[0m');
|
|
161
237
|
console.log(' \x1b[36mnemus list\x1b[0m \x1b[90m# list workspaces\x1b[0m');
|
package/skills/nemus/SKILL.md
CHANGED
|
@@ -72,6 +72,18 @@ Global flags: `-f/--force-refresh` (skip repo cache), `-y/--yes` (skip prompts),
|
|
|
72
72
|
| Import suite(s) from JSON | [suite-import](references/suite-import.md) | `nemus suite import <file>` |
|
|
73
73
|
| Create workspace from suite | [suite-use](references/suite-use.md) | `nemus suite use` |
|
|
74
74
|
|
|
75
|
+
### Portable Workspaces (lock / restore)
|
|
76
|
+
|
|
77
|
+
| Intent | Reference | CLI |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| Snapshot a workspace to a committable `nemus.lock` | [lock](references/lock.md) | `nemus lock [ws] [-o file\|-]` |
|
|
80
|
+
| Recreate a workspace from a `nemus.lock` | [restore](references/restore.md) | `nemus restore [file\|-] [-w name] [--pin]` |
|
|
81
|
+
|
|
82
|
+
`lock`/`restore` is portable and recreates repos from scratch (vs. `snapshot`,
|
|
83
|
+
which is local time-travel within an existing workspace; vs. `suite`, a reusable
|
|
84
|
+
repo *template*). The lockfile records repos + owner + each repo's branch + HEAD
|
|
85
|
+
commit; it holds no secrets (only what `git remote -v` exposes).
|
|
86
|
+
|
|
75
87
|
### Cache & Repo Discovery
|
|
76
88
|
|
|
77
89
|
| Intent | Reference | CLI |
|
|
@@ -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,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
|
+
}
|