@nemus-cli/nemus 0.15.2 → 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 +25 -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/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/program.ts +4 -0
- package/src/utils/workspace-lock.test.ts +200 -0
- package/src/utils/workspace-lock.ts +245 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import * as fs from 'fs/promises';
|
|
4
|
+
import * as path from 'path';
|
|
5
|
+
import { GitHubRepo, WorkspaceMetadata } from '../types';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const GIT_TIMEOUT = 15000;
|
|
9
|
+
|
|
10
|
+
/** Committable manifest that fully describes a workspace's repos + branch state. */
|
|
11
|
+
export const LOCK_FILENAME = 'nemus.lock';
|
|
12
|
+
export const LOCK_VERSION = 1;
|
|
13
|
+
|
|
14
|
+
export interface LockRepo {
|
|
15
|
+
name: string;
|
|
16
|
+
owner: string;
|
|
17
|
+
directoryName: string;
|
|
18
|
+
cloneUrl: string;
|
|
19
|
+
/** Current branch at lock time. Omitted for a detached HEAD. */
|
|
20
|
+
branch?: string;
|
|
21
|
+
/** Short HEAD SHA at lock time (used by `restore --pin`, and as a fallback). */
|
|
22
|
+
commit?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface WorkspaceLock {
|
|
26
|
+
version: number;
|
|
27
|
+
workspace: string;
|
|
28
|
+
generatedAt: string;
|
|
29
|
+
repositories: LockRepo[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Read the current branch of a git repo, or undefined for a detached HEAD / error. */
|
|
33
|
+
export async function readRepoBranch(repoPath: string): Promise<string | undefined> {
|
|
34
|
+
try {
|
|
35
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
36
|
+
cwd: repoPath,
|
|
37
|
+
timeout: GIT_TIMEOUT,
|
|
38
|
+
});
|
|
39
|
+
const branch = stdout.trim();
|
|
40
|
+
return branch && branch !== 'HEAD' ? branch : undefined;
|
|
41
|
+
} catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read the short HEAD SHA of a git repo, or undefined on error. */
|
|
47
|
+
export async function readRepoCommit(repoPath: string): Promise<string | undefined> {
|
|
48
|
+
try {
|
|
49
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD'], {
|
|
50
|
+
cwd: repoPath,
|
|
51
|
+
timeout: GIT_TIMEOUT,
|
|
52
|
+
});
|
|
53
|
+
return stdout.trim() || undefined;
|
|
54
|
+
} catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Build a lock manifest from a workspace's metadata, reading the live branch +
|
|
61
|
+
* commit for each successfully-cloned repo directory.
|
|
62
|
+
*/
|
|
63
|
+
export async function buildLock(
|
|
64
|
+
workspacePath: string,
|
|
65
|
+
metadata: WorkspaceMetadata
|
|
66
|
+
): Promise<WorkspaceLock> {
|
|
67
|
+
const repos = metadata.repositories.filter(r => r.status !== 'failed');
|
|
68
|
+
|
|
69
|
+
const repositories: LockRepo[] = await Promise.all(
|
|
70
|
+
repos.map(async (r): Promise<LockRepo> => {
|
|
71
|
+
const repoPath = path.join(workspacePath, r.directoryName);
|
|
72
|
+
const [branch, commit] = await Promise.all([
|
|
73
|
+
readRepoBranch(repoPath),
|
|
74
|
+
readRepoCommit(repoPath),
|
|
75
|
+
]);
|
|
76
|
+
return {
|
|
77
|
+
name: r.name,
|
|
78
|
+
owner: r.owner,
|
|
79
|
+
directoryName: r.directoryName,
|
|
80
|
+
cloneUrl: r.cloneUrl,
|
|
81
|
+
...(branch ? { branch } : {}),
|
|
82
|
+
...(commit ? { commit } : {}),
|
|
83
|
+
};
|
|
84
|
+
})
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
version: LOCK_VERSION,
|
|
89
|
+
workspace: metadata.workspaceName,
|
|
90
|
+
generatedAt: new Date().toISOString(),
|
|
91
|
+
repositories,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Serialize a lock to canonical JSON (trailing newline). */
|
|
96
|
+
export function serializeLock(lock: WorkspaceLock): string {
|
|
97
|
+
return JSON.stringify(lock, null, 2) + '\n';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function writeLock(filePath: string, lock: WorkspaceLock): Promise<void> {
|
|
101
|
+
await fs.writeFile(filePath, serializeLock(lock), 'utf-8');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Parse + validate a lock manifest. Throws a helpful error on malformed input. */
|
|
105
|
+
export function parseLock(content: string): WorkspaceLock {
|
|
106
|
+
let data: unknown;
|
|
107
|
+
try {
|
|
108
|
+
data = JSON.parse(content);
|
|
109
|
+
} catch {
|
|
110
|
+
throw new Error('Not valid JSON — is this a nemus.lock file?');
|
|
111
|
+
}
|
|
112
|
+
if (!data || typeof data !== 'object') {
|
|
113
|
+
throw new Error('Lockfile is not an object');
|
|
114
|
+
}
|
|
115
|
+
const lock = data as Partial<WorkspaceLock>;
|
|
116
|
+
if (lock.version !== LOCK_VERSION) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Unsupported lockfile version ${String(lock.version)} (this nemus supports version ${LOCK_VERSION})`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
if (typeof lock.workspace !== 'string' || !lock.workspace) {
|
|
122
|
+
throw new Error('Lockfile is missing a "workspace" name');
|
|
123
|
+
}
|
|
124
|
+
if (!Array.isArray(lock.repositories)) {
|
|
125
|
+
throw new Error('Lockfile is missing a "repositories" array');
|
|
126
|
+
}
|
|
127
|
+
// A lockfile is untrusted shared input — people commit it and hand it around,
|
|
128
|
+
// then `restore` feeds these fields to git + path.join. Validate every field
|
|
129
|
+
// that reaches a side effect, not just that it's a non-empty string.
|
|
130
|
+
for (const [i, r] of lock.repositories.entries()) {
|
|
131
|
+
if (!r || typeof r !== 'object') throw new Error(`repositories[${i}] is not an object`);
|
|
132
|
+
const entry = r as Partial<LockRepo>;
|
|
133
|
+
for (const field of ['name', 'owner', 'directoryName', 'cloneUrl'] as const) {
|
|
134
|
+
if (typeof entry[field] !== 'string' || !entry[field]) {
|
|
135
|
+
throw new Error(`repositories[${i}] is missing "${field}"`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (!isSafeSegment(entry.directoryName!)) {
|
|
139
|
+
throw new Error(`repositories[${i}].directoryName "${entry.directoryName}" is not a single path segment`);
|
|
140
|
+
}
|
|
141
|
+
// owner/name are what `reconstructRepo` rebuilds the clone URL from, so they
|
|
142
|
+
// must be safe segments too — not merely non-empty.
|
|
143
|
+
if (!isSafeSegment(entry.owner!)) {
|
|
144
|
+
throw new Error(`repositories[${i}].owner "${entry.owner}" is not a valid path segment`);
|
|
145
|
+
}
|
|
146
|
+
if (!isSafeSegment(entry.name!)) {
|
|
147
|
+
throw new Error(`repositories[${i}].name "${entry.name}" is not a valid path segment`);
|
|
148
|
+
}
|
|
149
|
+
if (!isAllowedCloneUrl(entry.cloneUrl!)) {
|
|
150
|
+
throw new Error(`repositories[${i}].cloneUrl "${entry.cloneUrl}" has no recognized git transport (expected https/ssh/git:// or user@host:path)`);
|
|
151
|
+
}
|
|
152
|
+
if (entry.branch !== undefined && (typeof entry.branch !== 'string' || !isSafeGitRef(entry.branch))) {
|
|
153
|
+
throw new Error(`repositories[${i}].branch "${entry.branch}" is not a valid git ref`);
|
|
154
|
+
}
|
|
155
|
+
if (entry.commit !== undefined && (typeof entry.commit !== 'string' || !isSafeGitRef(entry.commit))) {
|
|
156
|
+
throw new Error(`repositories[${i}].commit "${entry.commit}" is not a valid git ref`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return lock as WorkspaceLock;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A `directoryName` from a lockfile flows into `path.join(workspacePath, …)`, so
|
|
164
|
+
* it must be a single, non-traversing path segment — no separators, and not `.`
|
|
165
|
+
* or `..` — or a crafted lockfile could write repos outside the workspace.
|
|
166
|
+
*/
|
|
167
|
+
export function isSafeSegment(name: string): boolean {
|
|
168
|
+
return (
|
|
169
|
+
name.length > 0 &&
|
|
170
|
+
!name.includes('/') &&
|
|
171
|
+
!name.includes('\\') &&
|
|
172
|
+
!name.includes('\0') &&
|
|
173
|
+
name !== '.' &&
|
|
174
|
+
name !== '..'
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A `cloneUrl` is passed to `git clone`; without a recognized transport scheme a
|
|
180
|
+
* value like `--upload-pack=…` would be parsed as a git option. Allow only
|
|
181
|
+
* https/http/ssh/git URLs and scp-style `user@host:path` remotes.
|
|
182
|
+
*/
|
|
183
|
+
export function isAllowedCloneUrl(url: string): boolean {
|
|
184
|
+
if (/^(https?|ssh|git):\/\//i.test(url)) return true;
|
|
185
|
+
if (/^[^\s@/]+@[^\s@:/]+:/.test(url)) return true;
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A `branch`/`commit` from a lockfile is handed to `git checkout`. Reject refs
|
|
191
|
+
* that could smuggle git options (leading `-`) or aren't valid refs
|
|
192
|
+
* (whitespace/control chars, the metacharacters git itself forbids, or `..`).
|
|
193
|
+
*/
|
|
194
|
+
export function isSafeGitRef(ref: string): boolean {
|
|
195
|
+
return (
|
|
196
|
+
ref.length > 0 &&
|
|
197
|
+
!ref.startsWith('-') &&
|
|
198
|
+
// eslint-disable-next-line no-control-regex
|
|
199
|
+
!/[\s\x00-\x1f\x7f~^:?*[\\]/.test(ref) &&
|
|
200
|
+
!ref.includes('..')
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function readLockFile(filePath: string): Promise<WorkspaceLock> {
|
|
205
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
206
|
+
return parseLock(content);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Extract the git host from a clone URL — supports scp-style
|
|
211
|
+
* (`git@github.com:owner/repo.git`) and URL-style
|
|
212
|
+
* (`https://github.com/owner/repo.git`, `ssh://git@host/owner/repo`).
|
|
213
|
+
* Returns undefined if it can't be determined.
|
|
214
|
+
*/
|
|
215
|
+
export function parseGitHost(cloneUrl: string): string | undefined {
|
|
216
|
+
const scp = cloneUrl.match(/^[^@/]+@([^:/]+):/);
|
|
217
|
+
if (scp) return scp[1];
|
|
218
|
+
try {
|
|
219
|
+
const u = new URL(cloneUrl);
|
|
220
|
+
if (u.hostname) return u.hostname;
|
|
221
|
+
} catch {
|
|
222
|
+
// not a URL
|
|
223
|
+
}
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Rebuild a `GitHubRepo` for cloning from a lock entry. When the host is known
|
|
229
|
+
* we synthesize both https + ssh forms for `<host>/<owner>/<name>` so
|
|
230
|
+
* `getCloneUrl` can honor the restorer's `cloneProtocol`; otherwise we fall back
|
|
231
|
+
* to the stored `cloneUrl` for both fields (clone from exactly what was locked).
|
|
232
|
+
*/
|
|
233
|
+
export function reconstructRepo(entry: LockRepo): GitHubRepo {
|
|
234
|
+
const host = parseGitHost(entry.cloneUrl);
|
|
235
|
+
const url = host ? `https://${host}/${entry.owner}/${entry.name}` : entry.cloneUrl;
|
|
236
|
+
const sshUrl = host ? `git@${host}:${entry.owner}/${entry.name}.git` : entry.cloneUrl;
|
|
237
|
+
return {
|
|
238
|
+
name: entry.name,
|
|
239
|
+
url,
|
|
240
|
+
sshUrl,
|
|
241
|
+
owner: { login: entry.owner },
|
|
242
|
+
description: '',
|
|
243
|
+
isPrivate: false,
|
|
244
|
+
};
|
|
245
|
+
}
|