@pcircle/memesh 4.9.3 → 4.9.4
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/dist/skills-manifest.json +7 -2
- package/package.json +5 -2
- package/scripts/check-plugin-hook-artifact.mjs +212 -0
- package/scripts/lib/npm-bin.mjs +123 -0
- package/scripts/upgrade-plugin.sh +24 -0
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"name": "memesh",
|
|
9
9
|
"source": "./",
|
|
10
10
|
"description": "MeMesh \u2014 agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.",
|
|
11
|
-
"version": "4.9.
|
|
11
|
+
"version": "4.9.4",
|
|
12
12
|
"author": {
|
|
13
13
|
"name": "PCIRCLE AI"
|
|
14
14
|
},
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
{
|
|
10
10
|
"path": ".claude-plugin/plugin.json",
|
|
11
|
-
"sha256": "
|
|
11
|
+
"sha256": "cfb107b54e757ba86356a0defdebf7b1f12fbf45acbf8920d91793d849f33f4e",
|
|
12
12
|
"bytes": 572
|
|
13
13
|
},
|
|
14
14
|
{
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"path": ".codex-plugin/plugin.json",
|
|
21
|
-
"sha256": "
|
|
21
|
+
"sha256": "627cae483533b13ec22c0288c8e1abc2d2a7521bc6ccad7813b1065b5ab91816",
|
|
22
22
|
"bytes": 154
|
|
23
23
|
},
|
|
24
24
|
{
|
|
@@ -96,6 +96,11 @@
|
|
|
96
96
|
"sha256": "4474f784643a3384a066b508473ff17ee39beecd86d57451300592971528d3c4",
|
|
97
97
|
"bytes": 48742
|
|
98
98
|
},
|
|
99
|
+
{
|
|
100
|
+
"path": "scripts/hooks/auto-update-runner.mjs",
|
|
101
|
+
"sha256": "6435dbe0d142f52349da0c2ef8f82c455935be506a1b9e78f09f117c776d02b7",
|
|
102
|
+
"bytes": 7347
|
|
103
|
+
},
|
|
99
104
|
{
|
|
100
105
|
"path": "scripts/hooks/decision-nudge.js",
|
|
101
106
|
"sha256": "424bf5cd4f68cb55e441a5aed1db78b262df2a5ef4a7e60a56db2f9d3f103d5d",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pcircle/memesh",
|
|
3
|
-
"version": "4.9.
|
|
3
|
+
"version": "4.9.4",
|
|
4
4
|
"description": "MeMesh — agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -27,6 +27,8 @@
|
|
|
27
27
|
"README.md",
|
|
28
28
|
"LICENSE",
|
|
29
29
|
"llms-install.md",
|
|
30
|
+
"scripts/check-plugin-hook-artifact.mjs",
|
|
31
|
+
"scripts/lib/npm-bin.mjs",
|
|
30
32
|
"AGENTS.md"
|
|
31
33
|
],
|
|
32
34
|
"scripts": {
|
|
@@ -49,7 +51,8 @@
|
|
|
49
51
|
"audit:prod": "node scripts/check-consumer-audit.mjs",
|
|
50
52
|
"audit:memory": "node scripts/audit/memory-invariants.mjs",
|
|
51
53
|
"check:entry-points-start": "node scripts/check-entry-points-start.mjs",
|
|
52
|
-
"
|
|
54
|
+
"check:plugin-hook-artifact": "node scripts/check-plugin-hook-artifact.mjs",
|
|
55
|
+
"verify:release": "npm run lint && npm run typecheck && node scripts/check-version-coherence.mjs && node scripts/check-generated-mirror.mjs && node scripts/check-agent-message-sync.mjs && npm run check:surface-parity && node scripts/check-doc-claims.mjs && node scripts/audit/verification-audit.mjs && npm run audit:prod && npm run check:entry-points-start && npm run check:plugin-hook-artifact",
|
|
53
56
|
"release:finish": "node scripts/finish-release.mjs",
|
|
54
57
|
"verify:artifact": "npm run verify:release && npm run test:isolated && npm run test:packaged && npm run test:packaged:upgrade",
|
|
55
58
|
"prepublishOnly": "npm run build && npm run verify:artifact",
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** Verify plugin hooks and host entrypoints before a cache/artifact swap. */
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { npmSync, envWithNpmCache } from './lib/npm-bin.mjs';
|
|
8
|
+
|
|
9
|
+
export const CLAUDE_PLUGIN_ROOT_PREFIX = '${CLAUDE_PLUGIN_ROOT}/';
|
|
10
|
+
|
|
11
|
+
function isSubpath(parent, child) {
|
|
12
|
+
const relative = path.relative(parent, child);
|
|
13
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function hookTargetsFromManifest(manifest) {
|
|
17
|
+
const targets = [];
|
|
18
|
+
for (const [event, entries] of Object.entries(manifest?.hooks ?? {})) {
|
|
19
|
+
if (!Array.isArray(entries)) throw new Error(`${event} is not an array in hooks/hooks.json`);
|
|
20
|
+
for (const entry of entries) {
|
|
21
|
+
for (const hook of entry?.hooks ?? []) {
|
|
22
|
+
if (hook?.type !== 'command') continue;
|
|
23
|
+
if (typeof hook.command !== 'string' || !hook.command.startsWith(CLAUDE_PLUGIN_ROOT_PREFIX)) {
|
|
24
|
+
throw new Error(`${event} command must start with ${CLAUDE_PLUGIN_ROOT_PREFIX}`);
|
|
25
|
+
}
|
|
26
|
+
const relative = hook.command.slice(CLAUDE_PLUGIN_ROOT_PREFIX.length);
|
|
27
|
+
if (!relative || /\s/.test(relative) || path.posix.normalize(relative) !== relative || relative.startsWith('../')) {
|
|
28
|
+
throw new Error(`${event} command is not a single safe plugin-relative path: ${hook.command}`);
|
|
29
|
+
}
|
|
30
|
+
targets.push({ event, relative });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (targets.length === 0) throw new Error('hooks/hooks.json declares no command hooks');
|
|
35
|
+
return targets;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function readHookManifest(root) {
|
|
39
|
+
return JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'hooks.json'), 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function validateHookTargets(root, manifest = readHookManifest(root)) {
|
|
43
|
+
const rootPath = path.resolve(root);
|
|
44
|
+
const targets = hookTargetsFromManifest(manifest);
|
|
45
|
+
const missing = [];
|
|
46
|
+
for (const target of targets) {
|
|
47
|
+
const resolved = path.resolve(rootPath, target.relative);
|
|
48
|
+
try {
|
|
49
|
+
if (isContainedRegularFile(rootPath, resolved)) continue;
|
|
50
|
+
} catch {
|
|
51
|
+
// Report the same bounded finding for missing and unreadable targets.
|
|
52
|
+
}
|
|
53
|
+
missing.push({ ...target, resolved });
|
|
54
|
+
}
|
|
55
|
+
return { targets, missing, ok: missing.length === 0 };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* True only for a regular file whose every path component lives inside root.
|
|
60
|
+
* `lstat` rejects a symlink as the last component; `realpathSync` on both
|
|
61
|
+
* sides rejects a symlinked directory ABOVE it — `scripts/hooks -> /elsewhere`
|
|
62
|
+
* passed the lstat-only check and would have let a staged cache execute
|
|
63
|
+
* ambient bytes. Throws on a missing path, like the fs calls it wraps.
|
|
64
|
+
*/
|
|
65
|
+
function isContainedRegularFile(rootPath, resolved) {
|
|
66
|
+
if (!isSubpath(rootPath, resolved) || !fs.lstatSync(resolved).isFile()) return false;
|
|
67
|
+
return isSubpath(fs.realpathSync(rootPath), fs.realpathSync(resolved));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function safeRelativePath(value, label) {
|
|
71
|
+
if (typeof value !== 'string' || !value.startsWith('./')) {
|
|
72
|
+
throw new Error(`${label} must be a ./ relative path`);
|
|
73
|
+
}
|
|
74
|
+
const relative = value.slice(2);
|
|
75
|
+
if (!relative || path.posix.normalize(relative) !== relative || relative.startsWith('../') || path.isAbsolute(relative)) {
|
|
76
|
+
throw new Error(`${label} is not a safe plugin-relative path: ${value}`);
|
|
77
|
+
}
|
|
78
|
+
return relative;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function regularFile(root, relative, label) {
|
|
82
|
+
const rootPath = path.resolve(root);
|
|
83
|
+
const resolved = path.resolve(rootPath, relative);
|
|
84
|
+
if (!isSubpath(rootPath, resolved)) throw new Error(`${label} escapes plugin root: ${relative}`);
|
|
85
|
+
try {
|
|
86
|
+
if (isContainedRegularFile(rootPath, resolved)) return relative;
|
|
87
|
+
} catch {
|
|
88
|
+
// Fall through to one bounded diagnostic.
|
|
89
|
+
}
|
|
90
|
+
throw new Error(`${label} is missing or not a regular file: ${relative}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validatePluginEntrypoints(root) {
|
|
94
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
95
|
+
const targets = [];
|
|
96
|
+
for (const [relativeManifest, expectedPrefix] of [
|
|
97
|
+
['.claude-plugin/plugin.json', '${CLAUDE_PLUGIN_ROOT}/'],
|
|
98
|
+
['.codex-plugin/plugin.json', './'],
|
|
99
|
+
]) {
|
|
100
|
+
regularFile(root, relativeManifest, relativeManifest);
|
|
101
|
+
const plugin = JSON.parse(fs.readFileSync(path.join(root, relativeManifest), 'utf8'));
|
|
102
|
+
if (plugin.name !== 'memesh') throw new Error(`${relativeManifest} has unexpected plugin name`);
|
|
103
|
+
if (plugin.version !== packageJson.version) throw new Error(`${relativeManifest} version does not match package.json`);
|
|
104
|
+
const mcpManifest = safeRelativePath(plugin.mcpServers, `${relativeManifest} mcpServers`);
|
|
105
|
+
regularFile(root, mcpManifest, `${relativeManifest} mcpServers`);
|
|
106
|
+
targets.push({ kind: 'manifest', relative: relativeManifest });
|
|
107
|
+
targets.push({ kind: 'manifest', relative: mcpManifest });
|
|
108
|
+
const manifest = JSON.parse(fs.readFileSync(path.join(root, mcpManifest), 'utf8'));
|
|
109
|
+
const entry = manifest.mcpServers?.memesh;
|
|
110
|
+
if (!entry || entry.command !== 'node' || !Array.isArray(entry.args) || typeof entry.args[0] !== 'string') {
|
|
111
|
+
throw new Error(`${mcpManifest} has no valid mcpServers.memesh node entry`);
|
|
112
|
+
}
|
|
113
|
+
const rawTarget = entry.args[0];
|
|
114
|
+
if (!rawTarget.startsWith(expectedPrefix)) {
|
|
115
|
+
throw new Error(`${mcpManifest} entry must start with ${expectedPrefix}`);
|
|
116
|
+
}
|
|
117
|
+
const target = rawTarget.slice(expectedPrefix.length);
|
|
118
|
+
if (path.posix.normalize(target) !== target || target.startsWith('../') || path.isAbsolute(target)) {
|
|
119
|
+
throw new Error(`${mcpManifest} entry is not a safe plugin-relative path: ${rawTarget}`);
|
|
120
|
+
}
|
|
121
|
+
regularFile(root, target, `${mcpManifest} entrypoint`);
|
|
122
|
+
targets.push({ kind: 'entrypoint', relative: target });
|
|
123
|
+
}
|
|
124
|
+
return { targets };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function validateArtifactPaths(targets, files) {
|
|
128
|
+
const shipped = new Set(files.map((file) => typeof file === 'string' ? file : file.path));
|
|
129
|
+
const missing = targets.filter((target) => !shipped.has(target.relative));
|
|
130
|
+
return { missing, ok: missing.length === 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function packFiles(root) {
|
|
134
|
+
const npmCache = fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-pack-cache-'));
|
|
135
|
+
try {
|
|
136
|
+
const stdout = npmSync(['pack', '--dry-run', '--json', '--ignore-scripts'], {
|
|
137
|
+
cwd: root,
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
env: envWithNpmCache(npmCache),
|
|
140
|
+
});
|
|
141
|
+
let report;
|
|
142
|
+
try { report = JSON.parse(String(stdout)); } catch { throw new Error('npm pack --dry-run did not return valid JSON'); }
|
|
143
|
+
const entry = Array.isArray(report) ? report[0] : report;
|
|
144
|
+
if (!entry || !Array.isArray(entry.files)) throw new Error('npm pack --dry-run returned no file list');
|
|
145
|
+
return entry.files;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if (error instanceof Error && /npm pack --dry-run (?:did not return|returned no)/.test(error.message)) throw error;
|
|
148
|
+
const record = error && typeof error === 'object' ? /** @type {Record<string, unknown>} */ (error) : {};
|
|
149
|
+
const status = typeof record.status === 'number' ? record.status : 'unknown';
|
|
150
|
+
const stderr = typeof record.stderr === 'string' ? record.stderr.trim() : '';
|
|
151
|
+
throw new Error(
|
|
152
|
+
`npm pack --dry-run failed (exit ${status}): ${stderr || (error instanceof Error ? error.message : String(error))}`,
|
|
153
|
+
{ cause: error },
|
|
154
|
+
);
|
|
155
|
+
} finally {
|
|
156
|
+
fs.rmSync(npmCache, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function checkPluginHookArtifact(root, { checkPack = true } = {}) {
|
|
161
|
+
const source = validateHookTargets(root);
|
|
162
|
+
const plugin = validatePluginEntrypoints(root);
|
|
163
|
+
const allTargets = [...source.targets, ...plugin.targets];
|
|
164
|
+
if (!checkPack) return { ...source, plugin, artifact: null };
|
|
165
|
+
return { ...source, plugin, artifact: validateArtifactPaths(allTargets, packFiles(root)) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function main(argv) {
|
|
169
|
+
const rootIndex = argv.indexOf('--root');
|
|
170
|
+
if (rootIndex !== -1 && (!argv[rootIndex + 1] || argv[rootIndex + 1].startsWith('--'))) {
|
|
171
|
+
console.error('plugin hook integrity: --root requires a directory argument');
|
|
172
|
+
process.exit(2);
|
|
173
|
+
}
|
|
174
|
+
const root = path.resolve(rootIndex === -1 ? process.cwd() : argv[rootIndex + 1]);
|
|
175
|
+
const checkPack = !argv.includes('--skip-pack');
|
|
176
|
+
try {
|
|
177
|
+
const result = checkPluginHookArtifact(root, { checkPack });
|
|
178
|
+
if (!result.ok) {
|
|
179
|
+
for (const item of result.missing) console.error(`Missing hook target: ${item.event} -> ${item.resolved}`);
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
if (result.artifact && !result.artifact.ok) {
|
|
183
|
+
for (const item of result.artifact.missing) console.error(`Target omitted from npm artifact: ${item.event ?? item.kind} -> ${item.relative}`);
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
console.log(`plugin artifact integrity: PASS (${result.targets.length} hook targets, ${result.plugin.targets.length} plugin/MCP targets${checkPack ? ', npm artifact checked' : ''})`);
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`plugin hook integrity: ${error instanceof Error ? error.message : String(error)}`);
|
|
189
|
+
process.exit(1);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run `main` only when this file is the entrypoint. Both sides go through
|
|
195
|
+
* realpath: Node resolves symlinks in `import.meta.url` but not in argv[1],
|
|
196
|
+
* so `node /var/folders/.../check-plugin-hook-artifact.mjs` (macOS tmp is a
|
|
197
|
+
* symlink) compared unequal, skipped `main`, and exited 0 with no output — a
|
|
198
|
+
* silent PASS from a checker that had checked nothing. Pinned by the tarball
|
|
199
|
+
* test in tests/plugin-hook-artifact.test.ts, which asserts on the output.
|
|
200
|
+
*/
|
|
201
|
+
function isEntrypoint(argv1) {
|
|
202
|
+
if (!argv1) return false;
|
|
203
|
+
const self = fileURLToPath(import.meta.url);
|
|
204
|
+
const given = path.resolve(argv1);
|
|
205
|
+
if (self === given) return true;
|
|
206
|
+
// A realpath failure must not become "not the entrypoint": that is exit 0
|
|
207
|
+
// with nothing checked, the fail-closed guard's caller only reads the exit
|
|
208
|
+
// code, and a staged plugin would swap in unverified. Let it throw.
|
|
209
|
+
return fs.realpathSync(self) === fs.realpathSync(given);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (isEntrypoint(process.argv[1])) main(process.argv.slice(2));
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { execFileSync, execSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Run npm / npx from a build script, on every platform we support.
|
|
5
|
+
*
|
|
6
|
+
* Two separate Windows problems, and fixing only the first is what made this
|
|
7
|
+
* take two attempts:
|
|
8
|
+
*
|
|
9
|
+
* 1. npm ships on Windows as `npm.cmd`. `execFileSync('npm', …)` does not
|
|
10
|
+
* consult `PATHEXT` — that is a shell behaviour and execFile uses no
|
|
11
|
+
* shell — so it fails with `spawnSync npm ENOENT` while the identical
|
|
12
|
+
* command typed into a terminal works.
|
|
13
|
+
*
|
|
14
|
+
* 2. Naming it `npm.cmd` then fails with `spawnSync npm.cmd EINVAL`. Since
|
|
15
|
+
* the fix for CVE-2024-27980, Node refuses to spawn `.cmd` and `.bat`
|
|
16
|
+
* files without `shell: true`, because the Windows command interpreter
|
|
17
|
+
* re-parses the argument list. On Windows there is no shell-free way to
|
|
18
|
+
* invoke npm.
|
|
19
|
+
*
|
|
20
|
+
* So `shell: true` is required there, not a shortcut — and it is scoped to
|
|
21
|
+
* Windows, so macOS and Linux keep passing arguments as an array with no
|
|
22
|
+
* interpreter in the path at all.
|
|
23
|
+
*
|
|
24
|
+
* What `shell: true` costs is argument re-parsing, which matters only for
|
|
25
|
+
* arguments that are not literals. There is exactly one — the tarball name
|
|
26
|
+
* `npm pack` prints — and `assertSafeShellArg()` below is how it is made safe
|
|
27
|
+
* rather than assumed safe. Call it on anything that did not come from this
|
|
28
|
+
* repository's own source.
|
|
29
|
+
*
|
|
30
|
+
* Both scripts on the publish path had written this by hand:
|
|
31
|
+
*
|
|
32
|
+
* check-consumer-audit.mjs -> audit:prod -> verify:release -> prepublishOnly
|
|
33
|
+
* run-tests-isolated.mjs -> test:isolated -> prepublishOnly
|
|
34
|
+
*
|
|
35
|
+
* One owner, so a third caller cannot get it wrong a third way.
|
|
36
|
+
*/
|
|
37
|
+
const isWindows = process.platform === 'win32';
|
|
38
|
+
|
|
39
|
+
export const NPM = isWindows ? 'npm.cmd' : 'npm';
|
|
40
|
+
export const NPX = isWindows ? 'npx.cmd' : 'npx';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Reject anything that could mean something to a command interpreter.
|
|
44
|
+
*
|
|
45
|
+
* Deliberately an allow-list. A deny-list of shell metacharacters has to be
|
|
46
|
+
* complete to be correct, and `cmd.exe` gives `%`, `^` and `&` meanings that a
|
|
47
|
+
* POSIX-shaped deny-list would miss.
|
|
48
|
+
*/
|
|
49
|
+
export function assertSafeShellArg(value, what) {
|
|
50
|
+
// `:` and `~` are here for one reason: Windows absolute paths
|
|
51
|
+
// (`C:\Users\RUNNER~1\AppData\Local\Temp\...` — 8.3 short names carry the `~`,
|
|
52
|
+
// and that is literally what `os.tmpdir()` returns on a Windows runner).
|
|
53
|
+
// `smoke-packed-artifact.mjs` passes an absolute `os.tmpdir()` path to
|
|
54
|
+
// `npm pack --pack-destination` and to `npm install`, so without it
|
|
55
|
+
// `npm run test:packaged` throws on Windows before packing anything —
|
|
56
|
+
// fail-closed, but it means the packaged smoke test cannot run there at all.
|
|
57
|
+
// Neither has meaning to cmd.exe (`~` is a POSIX-shell nicety, and the POSIX
|
|
58
|
+
// branch never reaches here — it uses execFileSync with no shell at all).
|
|
59
|
+
// Every character that DOES mean something to cmd.exe (`%^&|<>"'` and
|
|
60
|
+
// whitespace) stays excluded.
|
|
61
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9._@:~\-+=/\\]+$/.test(value)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`${what} is not a safe argument to pass through a shell: ${JSON.stringify(value)}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Spawn `bin` with `args`.
|
|
71
|
+
*
|
|
72
|
+
* POSIX: no shell, arguments stay an array, nothing is re-parsed.
|
|
73
|
+
*
|
|
74
|
+
* Windows: every argument is validated, then the command line is built here and
|
|
75
|
+
* handed to `execSync`. Passing an ARRAY together with `shell: true` is
|
|
76
|
+
* deprecated (DEP0190) precisely because the arguments are concatenated without
|
|
77
|
+
* escaping — doing the concatenation ourselves, after checking each part, is
|
|
78
|
+
* the same operation with the check that makes it sound, and it does not print
|
|
79
|
+
* a deprecation warning on every release gate.
|
|
80
|
+
*
|
|
81
|
+
* The usual advice — "use execFile with an argument array, never build a shell
|
|
82
|
+
* string" — is right, and is what the POSIX branch does. It does not apply to
|
|
83
|
+
* the Windows branch because there is no execFile that works: npm is a `.cmd`,
|
|
84
|
+
* and Node refuses to exec one without a shell. The choice there is not
|
|
85
|
+
* array-vs-string, it is checked-vs-unchecked. Every argument reaching the
|
|
86
|
+
* concatenation has passed `assertSafeShellArg`, which is an allow-list, so no
|
|
87
|
+
* character with meaning to `cmd.exe` can be in one. `tests/consumer-audit-
|
|
88
|
+
* gate.test.ts` pins that, including the `%` and `^` forms a POSIX-shaped
|
|
89
|
+
* deny-list would miss.
|
|
90
|
+
*/
|
|
91
|
+
function runSync(bin, args, opts) {
|
|
92
|
+
if (!isWindows) return execFileSync(bin, args, opts);
|
|
93
|
+
const safeArgs = args.map((arg, i) => assertSafeShellArg(arg, `${bin} argument ${i} (${arg})`));
|
|
94
|
+
return execSync([bin, ...safeArgs].join(' '), opts);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `npm <args>`, spawned correctly for the platform. */
|
|
98
|
+
export function npmSync(args, opts = {}) {
|
|
99
|
+
return runSync(NPM, args, opts);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `npx <args>`, spawned correctly for the platform. */
|
|
103
|
+
export function npxSync(args, opts = {}) {
|
|
104
|
+
return runSync(NPX, args, opts);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `baseEnv` with every spelling of `npm_config_cache` removed and the private
|
|
109
|
+
* cache set. Windows environment names are case-insensitive and Node keeps
|
|
110
|
+
* the lexicographically first of two keys that differ only in case (`N`
|
|
111
|
+
* before `n`); Vitest adds an upper-cased copy of every variable to its
|
|
112
|
+
* Windows workers. So `{ ...process.env, npm_config_cache }` handed npm the
|
|
113
|
+
* runner's `NPM_CONFIG_CACHE=C:\npm\cache` and never the private directory —
|
|
114
|
+
* measured on windows-latest with the fake npm in
|
|
115
|
+
* `tests/consumer-audit-gate.test.ts`.
|
|
116
|
+
*/
|
|
117
|
+
export function envWithNpmCache(cacheDir, baseEnv = process.env) {
|
|
118
|
+
const env = Object.fromEntries(
|
|
119
|
+
Object.entries(baseEnv).filter(([key]) => key.toLowerCase() !== 'npm_config_cache'),
|
|
120
|
+
);
|
|
121
|
+
env.npm_config_cache = cacheDir;
|
|
122
|
+
return env;
|
|
123
|
+
}
|
|
@@ -91,6 +91,11 @@ MARKETPLACE_DIR="$CLAUDE_CONFIG_ROOT/plugins/marketplaces/pcircle-memesh"
|
|
|
91
91
|
INSTALL_REGISTRY="$CLAUDE_CONFIG_ROOT/plugins/installed_plugins.json"
|
|
92
92
|
CACHE_ROOT="$CLAUDE_CONFIG_ROOT/plugins/cache/pcircle-memesh/memesh"
|
|
93
93
|
LOCK_DIR="$CACHE_ROOT.lock"
|
|
94
|
+
# Use the checker shipped beside this upgrade script, not a file newly staged
|
|
95
|
+
# from the target commit. This keeps upgrades from legacy marketplace commits
|
|
96
|
+
# testable and prevents a target archive from disabling its own validation.
|
|
97
|
+
UPGRADE_SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
98
|
+
PLUGIN_ARTIFACT_CHECKER="$UPGRADE_SCRIPT_DIR/check-plugin-hook-artifact.mjs"
|
|
94
99
|
|
|
95
100
|
# ─── Pre-flight ────────────────────────────────────────────────────────────
|
|
96
101
|
if [ ! -d "$MARKETPLACE_DIR" ]; then
|
|
@@ -514,6 +519,25 @@ echo "==> Installing runtime deps (this may take a minute)..."
|
|
|
514
519
|
exit 1
|
|
515
520
|
}
|
|
516
521
|
|
|
522
|
+
# Validate a real staged plugin before moving it into the live cache. The
|
|
523
|
+
# host registry may load hooks immediately after the swap; a manifest that
|
|
524
|
+
# names a missing script would otherwise surface as a frightening `127` in the
|
|
525
|
+
# user's next session. Tiny legacy marketplace fixtures (and genuinely old
|
|
526
|
+
# plugin archives) have no plugin wiring to validate, so preserve their
|
|
527
|
+
# historical cache-refresh behavior; once wiring is present, the checker is
|
|
528
|
+
# mandatory and fail-closed. The checker itself comes from this script's
|
|
529
|
+
# installed release, never from the target archive being validated.
|
|
530
|
+
if [ -f "$STAGE_PATH/hooks/hooks.json" ] || [ -f "$STAGE_PATH/.claude-plugin/plugin.json" ] || [ -f "$STAGE_PATH/.codex-plugin/plugin.json" ]; then
|
|
531
|
+
if [ ! -f "$PLUGIN_ARTIFACT_CHECKER" ]; then
|
|
532
|
+
echo "ERROR: plugin artifact checker is missing beside the upgrade script — the live cache at $NEW_INSTALL_PATH was not touched" >&2
|
|
533
|
+
exit 1
|
|
534
|
+
fi
|
|
535
|
+
if ! node "$PLUGIN_ARTIFACT_CHECKER" --root "$STAGE_PATH" --skip-pack; then
|
|
536
|
+
echo "ERROR: staged plugin artifact integrity check failed — the live cache at $NEW_INSTALL_PATH was not touched" >&2
|
|
537
|
+
exit 1
|
|
538
|
+
fi
|
|
539
|
+
fi
|
|
540
|
+
|
|
517
541
|
# ─── 5. Swap the staged copy in ───────────────────────────────────────────
|
|
518
542
|
if [ -e "$NEW_INSTALL_PATH" ] || [ -L "$NEW_INSTALL_PATH" ]; then
|
|
519
543
|
HAD_LIVE_CACHE=1
|