@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,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);
|
package/src/mcp/tools.ts
CHANGED
|
@@ -21,6 +21,8 @@ import { removeNodeModules, removeBuildArtifacts } from '../utils/cleanup-operat
|
|
|
21
21
|
import { runPostCloneHooks } from '../utils/hooks';
|
|
22
22
|
import { SuiteEntry, WorkspaceSuite, SuitesStore } from '../types';
|
|
23
23
|
import { resolveWorkspaceNameConflict, sanitizeWorkspaceName, safeWorkspacePath } from '../utils/validation';
|
|
24
|
+
import { buildLock, writeLock, readLockFile, parseLock, LOCK_FILENAME } from '../utils/workspace-lock';
|
|
25
|
+
import { restoreWorkspace } from '../commands/restore';
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* Redirects stdout to stderr for the duration of a function call.
|
|
@@ -1017,3 +1019,70 @@ export async function handleSaveContext(workspace: string, content: string, appe
|
|
|
1017
1019
|
};
|
|
1018
1020
|
});
|
|
1019
1021
|
}
|
|
1022
|
+
|
|
1023
|
+
// ── Portable workspaces: lock / restore ─────────────────────────────────────
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* Snapshot a workspace into a nemus.lock manifest (repos + branch + commit).
|
|
1027
|
+
* Writes it to the workspace root by default and also returns the manifest so
|
|
1028
|
+
* an agent can share/commit it.
|
|
1029
|
+
*/
|
|
1030
|
+
export async function handleLockWorkspace(workspace: string, output?: string) {
|
|
1031
|
+
return withStdoutProtection(async () => {
|
|
1032
|
+
if (!workspace || workspace.trim().length === 0) {
|
|
1033
|
+
throw new Error('Workspace name is required');
|
|
1034
|
+
}
|
|
1035
|
+
const workspacePath = safeWorkspacePath(sanitizeWorkspaceName(workspace));
|
|
1036
|
+
const metadata = await loadMetadata(workspacePath);
|
|
1037
|
+
if (!metadata) {
|
|
1038
|
+
throw new Error(`Workspace not found: ${workspace}`);
|
|
1039
|
+
}
|
|
1040
|
+
const lock = await buildLock(workspacePath, metadata);
|
|
1041
|
+
const outPath = output ? path.resolve(output) : path.join(workspacePath, LOCK_FILENAME);
|
|
1042
|
+
await writeLock(outPath, lock);
|
|
1043
|
+
return {
|
|
1044
|
+
workspace: metadata.workspaceName,
|
|
1045
|
+
lockfilePath: outPath,
|
|
1046
|
+
repoCount: lock.repositories.length,
|
|
1047
|
+
lock,
|
|
1048
|
+
};
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Recreate a workspace from a nemus.lock. Accepts either inline `lockContent`
|
|
1054
|
+
* (the manifest JSON) or a `lockfile` path (defaults to ./nemus.lock). Clones
|
|
1055
|
+
* every repo and checks out the recorded branch (or exact commit with `pin`).
|
|
1056
|
+
*/
|
|
1057
|
+
export async function handleRestoreWorkspace(opts: {
|
|
1058
|
+
workspace?: string;
|
|
1059
|
+
lockfile?: string;
|
|
1060
|
+
lockContent?: string;
|
|
1061
|
+
pin?: boolean;
|
|
1062
|
+
}) {
|
|
1063
|
+
return withStdoutProtection(async () => {
|
|
1064
|
+
const lock = opts.lockContent
|
|
1065
|
+
? parseLock(opts.lockContent)
|
|
1066
|
+
: await readLockFile(path.resolve(opts.lockfile || LOCK_FILENAME));
|
|
1067
|
+
|
|
1068
|
+
const { workspaceName, workspacePath, results } = await restoreWorkspace(lock, {
|
|
1069
|
+
workspace: opts.workspace,
|
|
1070
|
+
pin: opts.pin,
|
|
1071
|
+
});
|
|
1072
|
+
|
|
1073
|
+
const cloned = results.filter(r => r.status === 'success');
|
|
1074
|
+
const failed = results.filter(r => r.status === 'failed');
|
|
1075
|
+
return {
|
|
1076
|
+
workspace: workspaceName,
|
|
1077
|
+
path: workspacePath,
|
|
1078
|
+
cloned: cloned.length,
|
|
1079
|
+
failed: failed.length,
|
|
1080
|
+
repositories: results.map(r => ({
|
|
1081
|
+
name: r.repo.name,
|
|
1082
|
+
directoryName: r.directoryName,
|
|
1083
|
+
status: r.status,
|
|
1084
|
+
...(r.error ? { error: r.error } : {}),
|
|
1085
|
+
})),
|
|
1086
|
+
};
|
|
1087
|
+
});
|
|
1088
|
+
}
|
package/src/postinstall.test.ts
CHANGED
|
@@ -1,52 +1,125 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
3
4
|
import { mkdtempSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
|
|
4
5
|
import { tmpdir } from 'node:os';
|
|
5
6
|
import { join } from 'node:path';
|
|
6
7
|
|
|
7
8
|
const SCRIPT = join(__dirname, '..', 'scripts', 'postinstall.js');
|
|
9
|
+
// postinstall.js guards its side effects with `require.main !== module`, so
|
|
10
|
+
// importing it just exposes the pure classifier.
|
|
11
|
+
const { classifyInstall } = createRequire(__filename)(SCRIPT) as {
|
|
12
|
+
classifyInstall: (i: { env: Record<string, string | undefined>; dirname: string; tmpDir: string }) => string;
|
|
13
|
+
};
|
|
8
14
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
it('
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
15
|
+
const NPM_UA = 'npm/10.9.0 node/v22.13.0 darwin arm64 workspaces/false';
|
|
16
|
+
const YARN_UA = 'yarn/1.22.22 npm/? node/v22.13.0 darwin arm64';
|
|
17
|
+
const PNPM_UA = 'pnpm/10.34.5 npm/? node/v22.13.0';
|
|
18
|
+
const TMP = '/var/folders/xy/T';
|
|
19
|
+
|
|
20
|
+
describe('classifyInstall', () => {
|
|
21
|
+
it('CI → ci', () => {
|
|
22
|
+
expect(classifyInstall({ env: { CI: 'true' }, dirname: '/anywhere', tmpDir: TMP })).toBe('ci');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('npm -g → global-npm; npm local → local', () => {
|
|
26
|
+
expect(classifyInstall({ env: { npm_config_global: 'true', npm_config_user_agent: NPM_UA }, dirname: '/usr/local/lib/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('global-npm');
|
|
27
|
+
expect(classifyInstall({ env: { npm_config_global: 'false', npm_config_user_agent: NPM_UA }, dirname: '/proj/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('local');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('npx (npm_command=exec) → transient', () => {
|
|
31
|
+
expect(classifyInstall({ env: { npm_command: 'exec', npm_config_user_agent: NPM_UA }, dirname: '/home/u/.npm/_npx/abc123/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('transient');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('yarn global add (bare yarn UA, no npm_command/global) → global-other', () => {
|
|
35
|
+
expect(classifyInstall({ env: { npm_config_user_agent: YARN_UA }, dirname: '/home/u/.config/yarn/global/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('global-other');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('pnpm add -g (bare pnpm UA) → global-other', () => {
|
|
39
|
+
expect(classifyInstall({ env: { npm_config_user_agent: PNPM_UA }, dirname: '/home/u/Library/pnpm/global/5/node_modules/@nemus-cli/nemus/scripts', tmpDir: TMP })).toBe('global-other');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// The reviewer's case: `pnpm dlx` sets NEITHER npm_command NOR
|
|
43
|
+
// npm_config_global — only the pnpm user-agent. UA-only logic would call this
|
|
44
|
+
// "global-other" and re-introduce the /dev/tty hang. The install PATH
|
|
45
|
+
// (".../pnpm/dlx/<hash>/...") is the signal that saves us.
|
|
46
|
+
it('pnpm dlx (only pnpm UA, staged under .../pnpm/dlx/...) → transient', () => {
|
|
47
|
+
const dir = '/home/u/Library/Caches/pnpm/dlx/ed050d93/1a07/node_modules/@nemus-cli/nemus/scripts';
|
|
48
|
+
expect(classifyInstall({ env: { npm_config_user_agent: PNPM_UA }, dirname: dir, tmpDir: TMP })).toBe('transient');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// A realistic pnpm dlx __dirname (verified by dumping a real run): the /dlx/
|
|
52
|
+
// marker sits BEFORE the symlinky `.pnpm` store segments. postinstall.js
|
|
53
|
+
// passes the RAW __dirname (never fs.realpathSync'd) precisely so the marker
|
|
54
|
+
// isn't resolved away into a content-addressed store path — this asserts the
|
|
55
|
+
// deep, real-shaped path still classifies transient.
|
|
56
|
+
it('pnpm dlx deep real path (marker before the .pnpm store segments) → transient', () => {
|
|
57
|
+
const dir =
|
|
58
|
+
'/Users/u/Library/Caches/pnpm/dlx/9622a716fa/1a0757951f6-12d63/node_modules/.pnpm/nemus@file+..+..+tmp/node_modules/@nemus-cli/nemus/scripts';
|
|
59
|
+
expect(classifyInstall({ env: { npm_config_user_agent: PNPM_UA }, dirname: dir, tmpDir: TMP })).toBe('transient');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('yarn berry dlx (only yarn UA, staged under the OS temp dir) → transient', () => {
|
|
63
|
+
const dir = `${TMP}/xfs-9f/node_modules/@nemus-cli/nemus/scripts`;
|
|
64
|
+
expect(classifyInstall({ env: { npm_config_user_agent: YARN_UA }, dirname: dir, tmpDir: TMP })).toBe('transient');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// The real macOS case: os.tmpdir() reports /var/folders/… (a symlink) while a
|
|
68
|
+
// package staged under it resolves to /private/var/folders/… . A raw
|
|
69
|
+
// startsWith would miss this and misclassify the dlx run as global-other
|
|
70
|
+
// (spurious shell-RC write). The classifier must normalize the /private prefix.
|
|
71
|
+
it('yarn/pnpm dlx staged under /private/var while tmpDir is /var → transient', () => {
|
|
72
|
+
const tmpDir = '/var/folders/xy/T';
|
|
73
|
+
const dir = '/private/var/folders/xy/T/xfs-9f/node_modules/@nemus-cli/nemus/scripts';
|
|
74
|
+
expect(classifyInstall({ env: { npm_config_user_agent: YARN_UA }, dirname: dir, tmpDir })).toBe('transient');
|
|
75
|
+
// symmetric: tmpDir realpath'd to /private while dir stays /var
|
|
76
|
+
expect(classifyInstall({ env: { npm_config_user_agent: PNPM_UA }, dirname: '/var/folders/xy/T/d/node_modules/x', tmpDir: '/private/var/folders/xy/T' })).toBe('transient');
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
/** Run the real postinstall.js in a sandbox HOME with a controlled environment.
|
|
81
|
+
* stdio is ignored and there's no tty, and NEMUS_SKIP_CONFIGURE guards a
|
|
82
|
+
* tty-bearing host, so we exercise the gate + non-interactive shell-integration
|
|
83
|
+
* path end-to-end (dirname is the real repo path — never transient). */
|
|
84
|
+
function runPostinstall(rcName: string, extraEnv: Record<string, string | undefined>) {
|
|
85
|
+
const home = mkdtempSync(join(tmpdir(), 'nemus-postinstall-'));
|
|
86
|
+
const rc = join(home, rcName);
|
|
87
|
+
writeFileSync(rc, '# user rc\n');
|
|
88
|
+
const env: Record<string, string> = {
|
|
89
|
+
...(process.env as Record<string, string>),
|
|
90
|
+
HOME: home,
|
|
91
|
+
NEMUS_CACHE_DIR: join(home, '.nemus'),
|
|
92
|
+
NEMUS_SKIP_CONFIGURE: '1',
|
|
93
|
+
};
|
|
94
|
+
delete env.CI;
|
|
95
|
+
for (const [k, v] of Object.entries(extraEnv)) {
|
|
96
|
+
if (v === undefined) delete env[k];
|
|
97
|
+
else env[k] = v;
|
|
98
|
+
}
|
|
99
|
+
execFileSync(process.execPath, [SCRIPT], { env, stdio: 'ignore', timeout: 20_000 });
|
|
100
|
+
const rcAfter = readFileSync(rc, 'utf8');
|
|
101
|
+
rmSync(home, { recursive: true, force: true });
|
|
102
|
+
return rcAfter;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
describe('postinstall.js (end-to-end)', () => {
|
|
106
|
+
it('npx / npm exec → no-op, RC untouched', () => {
|
|
107
|
+
expect(runPostinstall('.zshrc', { npm_command: 'exec', npm_config_user_agent: NPM_UA, npm_config_global: undefined, SHELL: '/bin/zsh' })).toBe('# user rc\n');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('npm local dependency install (global="false") → no-op, RC untouched', () => {
|
|
111
|
+
expect(runPostinstall('.zshrc', { npm_command: 'install', npm_config_user_agent: NPM_UA, npm_config_global: 'false', SHELL: '/bin/zsh' })).toBe('# user rc\n');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('npm global install → runs shell integration (RC gets the source line)', () => {
|
|
115
|
+
expect(runPostinstall('.zshrc', { npm_command: 'install', npm_config_user_agent: NPM_UA, npm_config_global: 'true', SHELL: '/bin/zsh' })).toContain('.nemus/shell-integration.sh');
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('yarn global add (no npm_config_global) → runs shell integration', () => {
|
|
119
|
+
expect(runPostinstall('.bashrc', { npm_command: undefined, npm_config_user_agent: YARN_UA, npm_config_global: undefined, SHELL: '/bin/bash' })).toContain('.nemus/shell-integration.sh');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('pnpm add -g (no npm_config_global) → runs shell integration', () => {
|
|
123
|
+
expect(runPostinstall('.bashrc', { npm_command: undefined, npm_config_user_agent: PNPM_UA, npm_config_global: undefined, SHELL: '/bin/bash' })).toContain('.nemus/shell-integration.sh');
|
|
51
124
|
});
|
|
52
125
|
});
|
package/src/program.ts
CHANGED
|
@@ -64,6 +64,8 @@ import { registerMigrateCommand } from './commands/migrate';
|
|
|
64
64
|
import { registerReportBugCommand } from './commands/report-bug';
|
|
65
65
|
import { registerCompletionCommand } from './commands/completion';
|
|
66
66
|
import { registerReflectCommand } from './commands/reflect';
|
|
67
|
+
import { registerLockCommand } from './commands/lock';
|
|
68
|
+
import { registerRestoreCommand } from './commands/restore';
|
|
67
69
|
|
|
68
70
|
registerCreateCommand(program);
|
|
69
71
|
registerListCommand(program);
|
|
@@ -92,6 +94,8 @@ registerMigrateCommand(program);
|
|
|
92
94
|
registerReportBugCommand(program);
|
|
93
95
|
registerCompletionCommand(program);
|
|
94
96
|
registerReflectCommand(program);
|
|
97
|
+
registerLockCommand(program);
|
|
98
|
+
registerRestoreCommand(program);
|
|
95
99
|
|
|
96
100
|
// Register TUI (delegates to existing Ink/React implementation)
|
|
97
101
|
program
|