@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,200 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
2
|
+
import { execFile } from 'child_process';
|
|
3
|
+
import { promisify } from 'util';
|
|
4
|
+
import * as fs from 'fs/promises';
|
|
5
|
+
import * as os from 'os';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import {
|
|
8
|
+
parseLock,
|
|
9
|
+
serializeLock,
|
|
10
|
+
parseGitHost,
|
|
11
|
+
reconstructRepo,
|
|
12
|
+
buildLock,
|
|
13
|
+
isSafeSegment,
|
|
14
|
+
isAllowedCloneUrl,
|
|
15
|
+
isSafeGitRef,
|
|
16
|
+
LOCK_VERSION,
|
|
17
|
+
type WorkspaceLock,
|
|
18
|
+
type LockRepo,
|
|
19
|
+
} from './workspace-lock';
|
|
20
|
+
import type { WorkspaceMetadata } from '../types';
|
|
21
|
+
|
|
22
|
+
const execFileAsync = promisify(execFile);
|
|
23
|
+
|
|
24
|
+
const validLock: WorkspaceLock = {
|
|
25
|
+
version: LOCK_VERSION,
|
|
26
|
+
workspace: 'checkout-flow',
|
|
27
|
+
generatedAt: '2026-09-09T10:00:00.000Z',
|
|
28
|
+
repositories: [
|
|
29
|
+
{ name: 'web', owner: 'acme', directoryName: 'web', cloneUrl: 'git@github.com:acme/web.git', branch: 'feat/x', commit: 'abc1234' },
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
describe('parseLock', () => {
|
|
34
|
+
it('round-trips a valid lock through serialize + parse', () => {
|
|
35
|
+
const parsed = parseLock(serializeLock(validLock));
|
|
36
|
+
expect(parsed).toEqual(validLock);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('rejects non-JSON', () => {
|
|
40
|
+
expect(() => parseLock('not json')).toThrow(/valid JSON/i);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects an unsupported version', () => {
|
|
44
|
+
const bad = JSON.stringify({ ...validLock, version: 99 });
|
|
45
|
+
expect(() => parseLock(bad)).toThrow(/version 99/);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects a missing workspace name', () => {
|
|
49
|
+
const bad = JSON.stringify({ ...validLock, workspace: '' });
|
|
50
|
+
expect(() => parseLock(bad)).toThrow(/workspace/i);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('rejects a non-array repositories field', () => {
|
|
54
|
+
const bad = JSON.stringify({ ...validLock, repositories: {} });
|
|
55
|
+
expect(() => parseLock(bad)).toThrow(/repositories/i);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('rejects a repo entry missing a required field', () => {
|
|
59
|
+
const bad = JSON.stringify({ ...validLock, repositories: [{ name: 'web', owner: 'acme', directoryName: 'web' }] });
|
|
60
|
+
expect(() => parseLock(bad)).toThrow(/cloneUrl/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('accepts entries without optional branch/commit', () => {
|
|
64
|
+
const minimal = { ...validLock, repositories: [{ name: 'web', owner: 'acme', directoryName: 'web', cloneUrl: 'https://github.com/acme/web.git' }] };
|
|
65
|
+
expect(() => parseLock(JSON.stringify(minimal))).not.toThrow();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// Untrusted-input hardening: fields that reach git / path.join are validated.
|
|
69
|
+
it('rejects a directoryName that escapes the workspace', () => {
|
|
70
|
+
for (const directoryName of ['../evil', 'a/b', '..', 'a\\b']) {
|
|
71
|
+
const bad = { ...validLock, repositories: [{ name: 'web', owner: 'acme', directoryName, cloneUrl: 'https://h/o/r.git' }] };
|
|
72
|
+
expect(() => parseLock(JSON.stringify(bad)), directoryName).toThrow(/path segment/);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('rejects an owner/name that is not a safe path segment (reconstructRepo builds URLs from them)', () => {
|
|
77
|
+
const badOwner = { ...validLock, repositories: [{ name: 'web', owner: '../x', directoryName: 'web', cloneUrl: 'https://h/o/r.git' }] };
|
|
78
|
+
expect(() => parseLock(JSON.stringify(badOwner))).toThrow(/owner/);
|
|
79
|
+
const badName = { ...validLock, repositories: [{ name: 'a/b', owner: 'acme', directoryName: 'web', cloneUrl: 'https://h/o/r.git' }] };
|
|
80
|
+
expect(() => parseLock(JSON.stringify(badName))).toThrow(/name/);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('rejects a cloneUrl with no recognized transport (option-injection)', () => {
|
|
84
|
+
for (const cloneUrl of ['--upload-pack=/x', '-oProxyCommand=x', '/local/path.git', 'file:///x']) {
|
|
85
|
+
const bad = { ...validLock, repositories: [{ name: 'web', owner: 'acme', directoryName: 'web', cloneUrl }] };
|
|
86
|
+
expect(() => parseLock(JSON.stringify(bad)), cloneUrl).toThrow(/transport/);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('rejects a branch/commit that could smuggle git flags', () => {
|
|
91
|
+
const badBranch = { ...validLock, repositories: [{ name: 'web', owner: 'acme', directoryName: 'web', cloneUrl: 'https://h/o/r.git', branch: '--upload-pack=x' }] };
|
|
92
|
+
expect(() => parseLock(JSON.stringify(badBranch))).toThrow(/branch/);
|
|
93
|
+
const badCommit = { ...validLock, repositories: [{ name: 'web', owner: 'acme', directoryName: 'web', cloneUrl: 'https://h/o/r.git', commit: '-x' }] };
|
|
94
|
+
expect(() => parseLock(JSON.stringify(badCommit))).toThrow(/commit/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('field validators', () => {
|
|
99
|
+
it('isSafeSegment accepts plain names, rejects traversal/separators', () => {
|
|
100
|
+
for (const ok of ['web', 'my-repo', 'repo.git', 'a..b']) expect(isSafeSegment(ok), ok).toBe(true);
|
|
101
|
+
for (const no of ['', '.', '..', 'a/b', 'a\\b', '../x']) expect(isSafeSegment(no), no).toBe(false);
|
|
102
|
+
});
|
|
103
|
+
it('isAllowedCloneUrl accepts real remotes, rejects options/paths', () => {
|
|
104
|
+
for (const ok of ['https://github.com/a/b.git', 'ssh://git@h/a/b', 'git@github.com:a/b.git', 'git://h/a/b']) expect(isAllowedCloneUrl(ok), ok).toBe(true);
|
|
105
|
+
for (const no of ['--upload-pack=x', '/local/path', 'file:///x', 'ext::sh -c x']) expect(isAllowedCloneUrl(no), no).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
it('isSafeGitRef accepts real refs, rejects flags/metachars', () => {
|
|
108
|
+
for (const ok of ['main', 'feat/x', 'release-1.2', 'abc1234']) expect(isSafeGitRef(ok), ok).toBe(true);
|
|
109
|
+
for (const no of ['-x', '--flag', 'a b', 'a..b', 'a~1', 'a^', 'a:b', '']) expect(isSafeGitRef(no), no).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('parseGitHost', () => {
|
|
114
|
+
it('parses scp-style URLs', () => {
|
|
115
|
+
expect(parseGitHost('git@github.com:acme/web.git')).toBe('github.com');
|
|
116
|
+
expect(parseGitHost('git@gitlab.example.com:team/app.git')).toBe('gitlab.example.com');
|
|
117
|
+
});
|
|
118
|
+
it('parses URL-style remotes', () => {
|
|
119
|
+
expect(parseGitHost('https://github.com/acme/web.git')).toBe('github.com');
|
|
120
|
+
expect(parseGitHost('ssh://git@code.corp/team/app')).toBe('code.corp');
|
|
121
|
+
});
|
|
122
|
+
it('returns undefined for garbage', () => {
|
|
123
|
+
expect(parseGitHost('not-a-url')).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe('reconstructRepo', () => {
|
|
128
|
+
it('synthesizes https + ssh forms for a known host so getCloneUrl can pick', () => {
|
|
129
|
+
const repo = reconstructRepo({ name: 'web', owner: 'acme', directoryName: 'web', cloneUrl: 'git@github.com:acme/web.git' });
|
|
130
|
+
expect(repo.url).toBe('https://github.com/acme/web');
|
|
131
|
+
expect(repo.sshUrl).toBe('git@github.com:acme/web.git');
|
|
132
|
+
expect(repo.owner.login).toBe('acme');
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it('preserves a non-default host from the locked URL', () => {
|
|
136
|
+
const repo = reconstructRepo({ name: 'app', owner: 'team', directoryName: 'app', cloneUrl: 'git@gitlab.corp:team/app.git' });
|
|
137
|
+
expect(repo.url).toBe('https://gitlab.corp/team/app');
|
|
138
|
+
expect(repo.sshUrl).toBe('git@gitlab.corp:team/app.git');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('falls back to the stored URL when the host is unparseable', () => {
|
|
142
|
+
const url = './local/path.git';
|
|
143
|
+
const repo = reconstructRepo({ name: 'x', owner: 'y', directoryName: 'x', cloneUrl: url });
|
|
144
|
+
expect(repo.url).toBe(url);
|
|
145
|
+
expect(repo.sshUrl).toBe(url);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('buildLock', () => {
|
|
150
|
+
let tmp: string;
|
|
151
|
+
let repoDir: string;
|
|
152
|
+
|
|
153
|
+
beforeAll(async () => {
|
|
154
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'nemus-lock-'));
|
|
155
|
+
repoDir = path.join(tmp, 'web');
|
|
156
|
+
await fs.mkdir(repoDir, { recursive: true });
|
|
157
|
+
const git = (args: string[]) => execFileAsync('git', args, { cwd: repoDir });
|
|
158
|
+
await git(['init', '-q']);
|
|
159
|
+
await git(['config', 'user.email', 'test@example.com']);
|
|
160
|
+
await git(['config', 'user.name', 'Test']);
|
|
161
|
+
await git(['checkout', '-q', '-b', 'feat/x']);
|
|
162
|
+
await fs.writeFile(path.join(repoDir, 'f.txt'), 'hi');
|
|
163
|
+
await git(['add', '.']);
|
|
164
|
+
await git(['commit', '-q', '-m', 'init']);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
afterAll(async () => {
|
|
168
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('captures the live branch + commit for each repo', async () => {
|
|
172
|
+
const metadata: WorkspaceMetadata = {
|
|
173
|
+
workspaceName: 'demo',
|
|
174
|
+
createdAt: 'now',
|
|
175
|
+
repositories: [
|
|
176
|
+
{ name: 'web', directoryName: 'web', owner: 'acme', clonedAt: 'now', cloneUrl: 'git@github.com:acme/web.git', status: 'success' },
|
|
177
|
+
],
|
|
178
|
+
};
|
|
179
|
+
const lock = await buildLock(tmp, metadata);
|
|
180
|
+
expect(lock.version).toBe(LOCK_VERSION);
|
|
181
|
+
expect(lock.workspace).toBe('demo');
|
|
182
|
+
expect(lock.repositories).toHaveLength(1);
|
|
183
|
+
const [r] = lock.repositories;
|
|
184
|
+
expect(r.branch).toBe('feat/x');
|
|
185
|
+
expect(r.commit).toMatch(/^[0-9a-f]{7,}$/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('skips repos whose clone failed', async () => {
|
|
189
|
+
const metadata: WorkspaceMetadata = {
|
|
190
|
+
workspaceName: 'demo',
|
|
191
|
+
createdAt: 'now',
|
|
192
|
+
repositories: [
|
|
193
|
+
{ name: 'web', directoryName: 'web', owner: 'acme', clonedAt: 'now', cloneUrl: 'x', status: 'success' },
|
|
194
|
+
{ name: 'gone', directoryName: 'gone', owner: 'acme', clonedAt: 'now', cloneUrl: 'x', status: 'failed', error: 'nope' },
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
const lock = await buildLock(tmp, metadata);
|
|
198
|
+
expect(lock.repositories.map((r: LockRepo) => r.name)).toEqual(['web']);
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -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
|
+
}
|