mirror of
https://github.com/thedotmack/claude-mem.git
synced 2026-09-20 04:23:02 +08:00
Merge remote-tracking branch 'origin/main' into mutual-aardvark
# Conflicts: # package.json # plugin/scripts/context-generator.cjs # plugin/scripts/mcp-server.cjs # plugin/scripts/server-service.cjs # plugin/scripts/worker-service.cjs # plugin/ui/viewer-bundle.js # src/server/runtime/create-server-service.ts # tests/sdk/parse-summary.test.ts # tests/sdk/parser.test.ts
This commit is contained in:
+67
-13
@@ -27,6 +27,11 @@ const CONTEXT_GENERATOR = {
|
||||
source: 'src/services/context-generator.ts'
|
||||
};
|
||||
|
||||
const TRANSCRIPT_WATCHER = {
|
||||
name: 'transcript-watcher',
|
||||
source: 'src/services/transcripts/transcript-watcher-entry.ts'
|
||||
};
|
||||
|
||||
function stripHardcodedDirname(filePath) {
|
||||
let content = fs.readFileSync(filePath, 'utf-8');
|
||||
const before = content.length;
|
||||
@@ -106,8 +111,9 @@ function shellTemplateManifest(buildShellCommand) {
|
||||
'plugin/.mcp.json': {
|
||||
kind: 'mcp',
|
||||
command: buildShellCommand({
|
||||
// The mcp Node launcher derives its spawn target from requireFile, so
|
||||
// no trailingCommand is needed (it is ignored for this host).
|
||||
host: 'mcp', requireFile: 'mcp-server.cjs',
|
||||
trailingCommand: ['exec', 'node', '"$_P/scripts/mcp-server.cjs"'],
|
||||
notFoundMessage: 'claude-mem: mcp server not found',
|
||||
mcpExtraCandidates: ['$PWD/plugin', '$PWD'],
|
||||
mcpExtraCacheRoots: [
|
||||
@@ -175,6 +181,19 @@ async function verifyShellTemplateCanonical() {
|
||||
);
|
||||
}
|
||||
|
||||
// Parser-compat guard (issue #2791): bun-runner.js is invoked by hosts that
|
||||
// may run a pre-ES2020 Node whose ESM loader throws on optional chaining.
|
||||
// Strip comments, then forbid `?.` / `??` in executable code.
|
||||
const bunRunnerCode = bunRunner
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||
if (/\?\.|\?\?/.test(bunRunnerCode)) {
|
||||
throw new Error(
|
||||
'plugin/scripts/bun-runner.js uses optional chaining (?.) or nullish coalescing (??) — ' +
|
||||
'this launcher must parse on pre-ES2020 Node (issue #2791). Rewrite with explicit guards.'
|
||||
);
|
||||
}
|
||||
|
||||
console.log('✓ Rule A shell templates match the canonical generator');
|
||||
}
|
||||
|
||||
@@ -206,7 +225,7 @@ async function buildHooks() {
|
||||
description: 'Runtime dependencies for claude-mem bundled hooks',
|
||||
type: 'module',
|
||||
dependencies: {
|
||||
'zod': '^4.3.6',
|
||||
'zod': '^4.4.3',
|
||||
'tree-sitter-cli': '^0.26.5',
|
||||
'tree-sitter-c': '^0.24.1',
|
||||
'tree-sitter-cpp': '^0.23.4',
|
||||
@@ -314,17 +333,14 @@ async function buildHooks() {
|
||||
const workerStats = fs.statSync(`${hooksDir}/${WORKER_SERVICE.name}.cjs`);
|
||||
console.log(`✓ worker-service built (${(workerStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
// Bundle-size guardrail for the worker. After externalizing the dead better-auth
|
||||
// dependency (#2584) the worker bundle is ~2.29 MB. The threshold below leaves
|
||||
// ~25% headroom so normal growth is fine, but a regression that re-bundles a
|
||||
// heavy server-only dependency (e.g. better-auth, kysely, a Postgres driver)
|
||||
// into the worker artifact will blow past it and fail the build/CI.
|
||||
// Advisory only — a sudden jump usually means a heavy server-only dependency
|
||||
// (better-auth, kysely, a database driver) leaked into the worker bundle via a
|
||||
// transitive import (#2584). Never blocks the build.
|
||||
const WORKER_SERVICE_MAX_BYTES = 2900 * 1024;
|
||||
if (workerStats.size > WORKER_SERVICE_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`worker-service.cjs is ${(workerStats.size / 1024).toFixed(2)} KB, exceeding the ${(WORKER_SERVICE_MAX_BYTES / 1024).toFixed(0)} KB budget. ` +
|
||||
`This usually means a heavy, server-only dependency leaked into the worker bundle — most likely a transitive (or dynamic) import dragged something like better-auth, kysely, or a database driver into worker-service.ts. ` +
|
||||
`Such deps must be marked 'external' in the worker build's external array (see #2584 for the better-auth case) or gated behind the server-beta runtime so the worker never bundles them.`
|
||||
console.warn(
|
||||
`⚠️ worker-service.cjs is ${(workerStats.size / 1024).toFixed(2)} KB (advisory budget ${(WORKER_SERVICE_MAX_BYTES / 1024).toFixed(0)} KB). ` +
|
||||
`If this jumped unexpectedly, check whether a server-only dependency leaked into the worker bundle (see #2584).`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -430,8 +446,8 @@ async function buildHooks() {
|
||||
|
||||
const MCP_SERVER_MAX_BYTES = 600 * 1024;
|
||||
if (mcpServerStats.size > MCP_SERVER_MAX_BYTES) {
|
||||
throw new Error(
|
||||
`mcp-server.cjs is ${(mcpServerStats.size / 1024).toFixed(2)} KB, exceeding the ${(MCP_SERVER_MAX_BYTES / 1024).toFixed(0)} KB budget. This usually means a transitive import pulled worker-service.ts (or another heavy module) into the MCP bundle. The MCP server is supposed to be a thin HTTP wrapper — audit recent imports in src/servers/mcp-server.ts and src/services/worker-spawner.ts. See PR #1645 for context on why this guardrail exists.`
|
||||
console.warn(
|
||||
`⚠️ mcp-server.cjs is ${(mcpServerStats.size / 1024).toFixed(2)} KB (advisory budget ${(MCP_SERVER_MAX_BYTES / 1024).toFixed(0)} KB). If this jumped unexpectedly, a transitive import may have pulled worker-service.ts or another heavy module into the MCP bundle (see #1645).`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -457,6 +473,43 @@ async function buildHooks() {
|
||||
const contextGenStats = fs.statSync(`${hooksDir}/${CONTEXT_GENERATOR.name}.cjs`);
|
||||
console.log(`✓ context-generator built (${(contextGenStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
console.log(`\n🔧 Building transcript watcher...`);
|
||||
await build({
|
||||
entryPoints: [TRANSCRIPT_WATCHER.source],
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node18',
|
||||
format: 'cjs',
|
||||
outfile: `${hooksDir}/${TRANSCRIPT_WATCHER.name}.cjs`,
|
||||
minify: true,
|
||||
logLevel: 'error',
|
||||
// Externalize zod for consistency with worker-service / server-beta-service —
|
||||
// any zod usage in the processor.ts import chain should resolve at runtime
|
||||
// against plugin/node_modules instead of being inlined (avoids duplicate-
|
||||
// instance hazards and keeps the bundle slim).
|
||||
external: ['bun:sqlite', 'zod'],
|
||||
define: {
|
||||
'__DEFAULT_PACKAGE_VERSION__': `"${version}"`
|
||||
},
|
||||
banner: {
|
||||
js: '#!/usr/bin/env bun'
|
||||
}
|
||||
});
|
||||
|
||||
stripHardcodedDirname(`${hooksDir}/${TRANSCRIPT_WATCHER.name}.cjs`);
|
||||
|
||||
fs.chmodSync(`${hooksDir}/${TRANSCRIPT_WATCHER.name}.cjs`, 0o755);
|
||||
const transcriptWatcherStats = fs.statSync(`${hooksDir}/${TRANSCRIPT_WATCHER.name}.cjs`);
|
||||
console.log(`✓ transcript-watcher built (${(transcriptWatcherStats.size / 1024).toFixed(2)} KB)`);
|
||||
|
||||
// Advisory only — the watcher is meant to be a thin file-tail loop.
|
||||
const TRANSCRIPT_WATCHER_MAX_BYTES = 200 * 1024;
|
||||
if (transcriptWatcherStats.size > TRANSCRIPT_WATCHER_MAX_BYTES) {
|
||||
console.warn(
|
||||
`⚠️ transcript-watcher.cjs is ${(transcriptWatcherStats.size / 1024).toFixed(2)} KB (advisory budget ${(TRANSCRIPT_WATCHER_MAX_BYTES / 1024).toFixed(0)} KB). If this jumped unexpectedly, check src/services/transcripts/processor.ts and watcher.ts for heavy imports.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\n🔧 Building NPX CLI...`);
|
||||
const npxCliOutDir = 'dist/npx-cli';
|
||||
if (!fs.existsSync(npxCliOutDir)) {
|
||||
@@ -604,6 +657,7 @@ async function buildHooks() {
|
||||
console.log(` - Server: server-service.cjs`);
|
||||
console.log(` - MCP Server: mcp-server.cjs`);
|
||||
console.log(` - Context Generator: context-generator.cjs`);
|
||||
console.log(` - Transcript Watcher: transcript-watcher.cjs`);
|
||||
console.log(` Output: ${npxCliOutDir}/`);
|
||||
console.log(` - NPX CLI: index.js`);
|
||||
if (fs.existsSync('openclaw/dist/index.js')) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Generates plugin/bun.lock as a build artifact from the GENERATED
|
||||
// plugin/package.json (written by scripts/build-hooks.js). Shipping this
|
||||
// lockfile lets the runtime installer (src/npx-cli/install/setup-runtime.ts)
|
||||
// run `bun install --frozen-lockfile --ignore-scripts` for a deterministic
|
||||
// dependency closure. See plan-10 (Build Artifact Hygiene, Approach A).
|
||||
//
|
||||
// MUST run AFTER build-hooks.js. Uses --ignore-scripts so generating the
|
||||
// lockfile never triggers tree-sitter postinstall builds.
|
||||
|
||||
const { execSync } = require('child_process');
|
||||
const { existsSync } = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
const pluginDir = path.join(rootDir, 'plugin');
|
||||
const pluginManifest = path.join(pluginDir, 'package.json');
|
||||
|
||||
console.log('\n🔒 Generating plugin/bun.lock...');
|
||||
|
||||
if (!existsSync(pluginManifest)) {
|
||||
throw new Error(
|
||||
`gen-plugin-lockfile: no package.json at ${pluginManifest}. ` +
|
||||
`Run scripts/build-hooks.js first (it generates plugin/package.json).`
|
||||
);
|
||||
}
|
||||
|
||||
const lockfile = path.join(pluginDir, 'bun.lock');
|
||||
|
||||
// bun is the only tool that can regenerate the lockfile. On hosts that build
|
||||
// without bun on PATH (e.g. the Windows build CI job), regeneration is skipped:
|
||||
// the committed lockfile is already the deterministic closure, so a missing bun
|
||||
// is non-fatal AS LONG AS the lockfile is present. With neither, we cannot
|
||||
// produce the closure and must fail loud.
|
||||
function bunAvailable() {
|
||||
try {
|
||||
execSync('bun --version', { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bunAvailable()) {
|
||||
if (existsSync(lockfile)) {
|
||||
console.log('⚠️ bun not found on PATH — skipping regeneration; using committed plugin/bun.lock.');
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(
|
||||
`gen-plugin-lockfile: bun is not on PATH and no committed lockfile exists at ${lockfile}. ` +
|
||||
`Install bun (https://bun.sh) so the deterministic dependency closure can be generated.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
execSync('bun install --ignore-scripts', {
|
||||
cwd: pluginDir,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`bun install failed in ${pluginDir}\n${error.message}`);
|
||||
}
|
||||
|
||||
if (!existsSync(lockfile)) {
|
||||
throw new Error(
|
||||
`gen-plugin-lockfile: bun install completed but ${lockfile} was not produced.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log('✓ plugin/bun.lock generated');
|
||||
@@ -0,0 +1,366 @@
|
||||
#!/usr/bin/env node
|
||||
// Clean-room install + import smoke test.
|
||||
//
|
||||
// PURPOSE: regression backstop for the #2730 `Cannot find module 'zod/v3'`
|
||||
// class of bug. zod v4 ships `./v3`, `./v4`, and `./v4-mini` subpath exports,
|
||||
// and @modelcontextprotocol/sdk internals require `zod/v3` at runtime. If the
|
||||
// plugin lockfile / package closure ever stops shipping those subpaths (a bad
|
||||
// hoist, a dropped dep, a stale lockfile, or a missing-from-tarball file), the
|
||||
// worker dies at require-time the first time a user runs it post-update. This
|
||||
// test reproduces a USER's fresh install in throwaway temp dirs and asserts the
|
||||
// runtime dependency closure resolves and the entrypoints load.
|
||||
//
|
||||
// Two independent checks:
|
||||
// PART 1 — Plugin runtime closure: bun-install plugin/ from its frozen
|
||||
// lockfile into a fresh temp dir (parity with the real runtime
|
||||
// install in src/npx-cli/install/setup-runtime.ts:415), assert the
|
||||
// zod subpaths resolve, and boot the bundled worker so every
|
||||
// top-level require executes — surfacing any missing module.
|
||||
// PART 2 — npm-package completeness: `npm pack` the repo, install the tarball
|
||||
// into a second fresh temp dir, and load the published entrypoints to
|
||||
// catch dist runtime deps that are missing from the tarball.
|
||||
//
|
||||
// NETWORK: this script makes network calls ONLY for the two installs
|
||||
// (bun install in PART 1, npm install of the tarball in PART 2).
|
||||
// Everything else is local. Both installs pass --ignore-scripts.
|
||||
//
|
||||
// SAFETY: runs exclusively against FRESH temp dirs — it never touches the
|
||||
// repo's already-installed node_modules. Both temp dirs and the .tgz
|
||||
// are removed in a finally block, even on failure.
|
||||
//
|
||||
// RUNTIME: roughly 30s–2min wall-clock, dominated by the two installs.
|
||||
//
|
||||
// EXIT: 0 on success (both parts pass); non-zero with a precise message naming
|
||||
// the missing module(s) on any failure.
|
||||
|
||||
const { execSync, spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const REPO_ROOT = path.join(__dirname, '..');
|
||||
const PLUGIN_DIR = path.join(REPO_ROOT, 'plugin');
|
||||
|
||||
// zod v4 subpath exports that @modelcontextprotocol/sdk (and friends) require at
|
||||
// runtime. These are the exact specifiers behind the #2730 incident.
|
||||
const ZOD_SPECIFIERS = ['zod', 'zod/v3', 'zod/v4', 'zod/v4-mini'];
|
||||
|
||||
// Patterns that mean "a require/import blew up because a module was missing".
|
||||
const MODULE_NOT_FOUND_RE = /Cannot find module|MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|ERR_PACKAGE_PATH_NOT_EXPORTED/;
|
||||
|
||||
// Track everything we create so `finally` can clean up unconditionally.
|
||||
const cleanup = { tmpPlugin: null, tmpPkg: null, tarball: null };
|
||||
|
||||
function log(msg) {
|
||||
console.log(msg);
|
||||
}
|
||||
|
||||
function fail(messages) {
|
||||
console.error('\n\x1b[31mClean-room smoke test FAILED.\x1b[0m');
|
||||
for (const m of messages) console.error(` - ${m}`);
|
||||
console.error('\nThis is the #2730 backstop: a fresh user install would hit the');
|
||||
console.error('same broken module resolution. Do NOT ship until the runtime');
|
||||
console.error('dependency closure (plugin/bun.lock + tarball files) is fixed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function rmrf(p) {
|
||||
if (!p) return;
|
||||
try {
|
||||
fs.rmSync(p, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best-effort cleanup; never mask the real failure.
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PART 1 — Plugin runtime closure (the #2730 guard)
|
||||
// ---------------------------------------------------------------------------
|
||||
function checkPluginClosure(failures) {
|
||||
log('PART 1 — Plugin runtime closure (#2730 guard)');
|
||||
|
||||
const tmpPlugin = fs.mkdtempSync(path.join(os.tmpdir(), 'cmem-smoke-plugin-'));
|
||||
cleanup.tmpPlugin = tmpPlugin;
|
||||
log(` Temp plugin dir: ${tmpPlugin}`);
|
||||
|
||||
// Recursively copy the whole plugin/ tree (package.json, bun.lock, bundled
|
||||
// scripts). Skip any pre-existing node_modules so we install fresh from the
|
||||
// frozen lockfile rather than inheriting the repo's resolution.
|
||||
fs.cpSync(PLUGIN_DIR, tmpPlugin, {
|
||||
recursive: true,
|
||||
filter: (src) => path.basename(src) !== 'node_modules',
|
||||
});
|
||||
|
||||
// Runtime install parity with src/npx-cli/install/setup-runtime.ts:415 —
|
||||
// `bun install --frozen-lockfile --ignore-scripts`. Frozen lockfile is what
|
||||
// makes this a real closure assertion: if plugin/bun.lock omits a subpath's
|
||||
// provider, the install reproduces the broken tree a user would get.
|
||||
log(' Running: bun install --frozen-lockfile --ignore-scripts');
|
||||
try {
|
||||
execSync('bun install --frozen-lockfile --ignore-scripts', {
|
||||
cwd: tmpPlugin,
|
||||
stdio: 'pipe',
|
||||
timeout: 180000,
|
||||
});
|
||||
} catch (error) {
|
||||
const out = `${error.stdout || ''}${error.stderr || ''}`.trim();
|
||||
failures.push(`bun install failed in fresh plugin temp dir: ${out || error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Assert each zod subpath resolves from the freshly installed node_modules.
|
||||
// require.resolve with an explicit paths root simulates how the bundled
|
||||
// worker (which lives under <tmpPlugin>/scripts) resolves its bare requires.
|
||||
const nodeModules = path.join(tmpPlugin, 'node_modules');
|
||||
const missing = [];
|
||||
for (const spec of ZOD_SPECIFIERS) {
|
||||
try {
|
||||
require.resolve(spec, { paths: [nodeModules] });
|
||||
} catch {
|
||||
missing.push(spec);
|
||||
}
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
failures.push(
|
||||
`plugin closure is missing module(s): ${missing.join(', ')} ` +
|
||||
`(not resolvable from ${nodeModules})`
|
||||
);
|
||||
} else {
|
||||
log(` Resolved all zod subpaths: ${ZOD_SPECIFIERS.join(', ')}`);
|
||||
}
|
||||
|
||||
// Boot the bundled worker so EVERY top-level require executes. The worker is a
|
||||
// long-running server, so we invoke it via `--version`. Invoking with
|
||||
// `--version` loads the full bundle — executing every eager top-level require,
|
||||
// including `require("zod/v3")` — and exits without starting the long-running
|
||||
// server (the worker has no `--version` handler; argv simply falls through to a
|
||||
// no-op path that prints nothing and exits 0). We bound it with a timeout as
|
||||
// belt-and-suspenders: a TIMEOUT means the bundle loaded fine and started
|
||||
// running (treated as success); the ONLY failure signal we assert on is a
|
||||
// module-resolution error in the output. We deliberately do NOT assert on the
|
||||
// minified internals of the bundle — only on the absence of
|
||||
// `Cannot find module` / `MODULE_NOT_FOUND` and a non-crash exit.
|
||||
const workerEntry = path.join(tmpPlugin, 'scripts', 'worker-service.cjs');
|
||||
if (!fs.existsSync(workerEntry)) {
|
||||
failures.push(`bundled worker not found at ${workerEntry}`);
|
||||
return;
|
||||
}
|
||||
log(' Booting worker via: bun scripts/worker-service.cjs --version');
|
||||
const res = spawnSync('bun', [workerEntry, '--version'], {
|
||||
cwd: tmpPlugin,
|
||||
encoding: 'utf8',
|
||||
timeout: 20000,
|
||||
// Force resolution to land inside the temp node_modules, never the repo's.
|
||||
env: { ...process.env, NODE_PATH: nodeModules },
|
||||
});
|
||||
const workerOut = `${res.stdout || ''}${res.stderr || ''}`;
|
||||
if (MODULE_NOT_FOUND_RE.test(workerOut)) {
|
||||
const firstLine = workerOut
|
||||
.split('\n')
|
||||
.find((l) => MODULE_NOT_FOUND_RE.test(l));
|
||||
failures.push(`worker boot hit a module-resolution error: ${firstLine.trim()}`);
|
||||
} else if (res.error && res.error.code === 'ETIMEDOUT') {
|
||||
// Loaded fine and kept running — that's a healthy worker. Success.
|
||||
log(' Worker loaded and started running (timeout reached, no missing module).');
|
||||
} else if (res.error) {
|
||||
// Any OTHER spawn error (ENOENT if bun isn't on PATH, EACCES, etc.) means we
|
||||
// never actually exercised the bundle — that is NOT a pass. Only a genuine
|
||||
// ETIMEDOUT (handled above) counts as the worker loading cleanly.
|
||||
failures.push(`worker boot failed to spawn: ${res.error.message}`);
|
||||
} else if (res.status !== 0 && res.status !== null) {
|
||||
// Non-zero exit without a module error is suspicious enough to surface, but
|
||||
// it is not the #2730 signature; report it with context.
|
||||
failures.push(
|
||||
`worker boot exited ${res.status} (no missing-module error, but non-clean): ` +
|
||||
`${workerOut.trim().split('\n').slice(-3).join(' | ')}`
|
||||
);
|
||||
} else {
|
||||
log(' Worker bundle loaded cleanly (no missing module).');
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PART 2 — npm-package completeness
|
||||
// ---------------------------------------------------------------------------
|
||||
function checkPackageCompleteness(failures) {
|
||||
log('\nPART 2 — npm-package completeness');
|
||||
|
||||
// `npm pack --silent` prints just the tarball filename. Pack from repo root.
|
||||
let tarballName;
|
||||
try {
|
||||
tarballName = execSync('npm pack --silent', {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
.trim()
|
||||
.split('\n')
|
||||
.pop()
|
||||
.trim();
|
||||
} catch (error) {
|
||||
failures.push(`npm pack failed: ${error.stderr || error.message}`);
|
||||
return;
|
||||
}
|
||||
const tarball = path.join(REPO_ROOT, tarballName);
|
||||
cleanup.tarball = tarball;
|
||||
log(` Packed tarball: ${tarballName}`);
|
||||
|
||||
const tmpPkg = fs.mkdtempSync(path.join(os.tmpdir(), 'cmem-smoke-pkg-'));
|
||||
cleanup.tmpPkg = tmpPkg;
|
||||
log(` Temp install prefix: ${tmpPkg}`);
|
||||
|
||||
log(' Installing tarball: npm install <tarball> --ignore-scripts --no-audit --no-fund');
|
||||
try {
|
||||
execSync(
|
||||
`npm install "${tarball}" --prefix "${tmpPkg}" --ignore-scripts --no-audit --no-fund`,
|
||||
{ cwd: tmpPkg, stdio: 'pipe', timeout: 180000 }
|
||||
);
|
||||
} catch (error) {
|
||||
const out = `${error.stdout || ''}${error.stderr || ''}`.trim();
|
||||
failures.push(`npm install of tarball failed: ${out || error.message}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pkgRoot = path.join(tmpPkg, 'node_modules', 'claude-mem');
|
||||
if (!fs.existsSync(pkgRoot)) {
|
||||
failures.push(`installed package not found at ${pkgRoot}`);
|
||||
return;
|
||||
}
|
||||
const installedPkg = JSON.parse(
|
||||
fs.readFileSync(path.join(pkgRoot, 'package.json'), 'utf8')
|
||||
);
|
||||
|
||||
// Build the candidate list of published entrypoints to load. Prefer the `bin`
|
||||
// (the real, runnable user entry — `npx claude-mem`), which exposes a safe
|
||||
// `--version` flag that loads the whole CLI and exits 0. Then add any
|
||||
// `main`/`exports` targets THAT ACTUALLY EXIST in the tarball. The package
|
||||
// currently declares exports['.'] -> ./dist/index.js and exports['./sdk'] ->
|
||||
// ./dist/sdk/index.js, neither of which the current build emits. We only
|
||||
// load-test what actually shipped (a missing-from-build entry is NOT a hard
|
||||
// failure here — that's the deferred #2537 slice), but we WARN loudly for each
|
||||
// declared-but-missing target so the latent gap is visible in CI logs rather
|
||||
// than silently swallowed.
|
||||
const entries = [];
|
||||
|
||||
// bin — this is the hard check: it must exist and load.
|
||||
const binField = installedPkg.bin;
|
||||
const binPath =
|
||||
typeof binField === 'string'
|
||||
? binField
|
||||
: binField && binField['claude-mem'];
|
||||
if (binPath) {
|
||||
const abs = path.join(pkgRoot, binPath);
|
||||
if (fs.existsSync(abs)) entries.push({ label: `bin (${binPath})`, abs, kind: 'bin' });
|
||||
}
|
||||
|
||||
// Collect every declared main/exports target with a human label so we can warn
|
||||
// precisely about the ones missing from the tarball.
|
||||
const declaredTargets = [];
|
||||
if (installedPkg.main) {
|
||||
declaredTargets.push({ label: "main", rel: installedPkg.main });
|
||||
}
|
||||
const exportsField = installedPkg.exports || {};
|
||||
for (const [key, value] of Object.entries(exportsField)) {
|
||||
// Skip wildcard subpaths (e.g. "./modes/*") — there's no single concrete
|
||||
// file to existence-check.
|
||||
if (key.includes('*')) continue;
|
||||
let rel;
|
||||
if (typeof value === 'string') rel = value;
|
||||
else if (value && value.import) rel = value.import;
|
||||
if (rel) declaredTargets.push({ label: `exports['${key}']`, rel });
|
||||
}
|
||||
|
||||
for (const { label, rel } of declaredTargets) {
|
||||
const abs = path.join(pkgRoot, rel);
|
||||
if (fs.existsSync(abs)) {
|
||||
entries.push({ label: `${label} (${rel})`, abs, kind: 'esm' });
|
||||
} else {
|
||||
log(
|
||||
` WARN: package.json declares ${label} -> ${rel} but it is absent from ` +
|
||||
`the published tarball (latent gap, not a hard failure — see #2537).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
failures.push('no published entrypoints were found in the tarball to load-test');
|
||||
return;
|
||||
}
|
||||
|
||||
const isEsm = installedPkg.type === 'module';
|
||||
for (const entry of entries) {
|
||||
let res;
|
||||
if (entry.kind === 'bin') {
|
||||
// The bin has a safe `--version` that loads the CLI and exits 0.
|
||||
res = spawnSync('node', [entry.abs, '--version'], {
|
||||
cwd: pkgRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
});
|
||||
} else if (isEsm) {
|
||||
// ESM module: dynamically import it so all its imports resolve. We only
|
||||
// care that it LOADS without a module-resolution error.
|
||||
const importUrl = require('url').pathToFileURL(entry.abs).href;
|
||||
res = spawnSync(
|
||||
'node',
|
||||
['--input-type=module', '-e', `await import(${JSON.stringify(importUrl)})`],
|
||||
{ cwd: pkgRoot, encoding: 'utf8', timeout: 30000 }
|
||||
);
|
||||
} else {
|
||||
res = spawnSync('node', ['-e', `require(${JSON.stringify(entry.abs)})`], {
|
||||
cwd: pkgRoot,
|
||||
encoding: 'utf8',
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
const out = `${res.stdout || ''}${res.stderr || ''}`;
|
||||
if (MODULE_NOT_FOUND_RE.test(out)) {
|
||||
const firstLine = out.split('\n').find((l) => MODULE_NOT_FOUND_RE.test(l));
|
||||
failures.push(
|
||||
`published entry ${entry.label} hit a module-resolution error: ${firstLine.trim()}`
|
||||
);
|
||||
} else if (res.error && res.error.code === 'ETIMEDOUT') {
|
||||
// A long-running entry that didn't crash on load is fine.
|
||||
log(` Loaded ${entry.label} (still running at timeout, no missing module).`);
|
||||
} else if (res.status !== 0 && res.status !== null) {
|
||||
failures.push(
|
||||
`published entry ${entry.label} exited ${res.status}: ` +
|
||||
`${out.trim().split('\n').slice(-3).join(' | ')}`
|
||||
);
|
||||
} else {
|
||||
log(` Loaded ${entry.label} cleanly (no missing module).`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main
|
||||
// ---------------------------------------------------------------------------
|
||||
function main() {
|
||||
const started = Date.now();
|
||||
const failures = [];
|
||||
|
||||
try {
|
||||
checkPluginClosure(failures);
|
||||
checkPackageCompleteness(failures);
|
||||
} finally {
|
||||
rmrf(cleanup.tmpPlugin);
|
||||
rmrf(cleanup.tmpPkg);
|
||||
if (cleanup.tarball) {
|
||||
try {
|
||||
fs.unlinkSync(cleanup.tarball);
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
||||
if (failures.length > 0) {
|
||||
fail(failures);
|
||||
}
|
||||
log(`\n\x1b[32mClean-room smoke test passed\x1b[0m — plugin closure + npm tarball entrypoints load cleanly (${seconds}s).`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -8,13 +8,6 @@ const os = require('os');
|
||||
const INSTALLED_PATH = path.join(os.homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
|
||||
const CACHE_BASE_PATH = path.join(os.homedir(), '.claude', 'plugins', 'cache', 'thedotmack', 'claude-mem');
|
||||
|
||||
// Reject obviously invalid ports before they reach http.request, which would
|
||||
// throw with a confusing error like "RangeError: Port should be > 0 and < 65536".
|
||||
function parseWorkerPort(value) {
|
||||
const port = Number.parseInt(String(value ?? ''), 10);
|
||||
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
||||
}
|
||||
|
||||
function getCurrentBranch() {
|
||||
try {
|
||||
if (!existsSync(path.join(INSTALLED_PATH, '.git'))) {
|
||||
@@ -76,60 +69,6 @@ function getPluginVersion() {
|
||||
}
|
||||
}
|
||||
|
||||
function detectInstalledVersion(buildVersion) {
|
||||
const dataDir = process.env.CLAUDE_MEM_DATA_DIR || path.join(os.homedir(), '.claude-mem');
|
||||
const settingsPath = path.join(dataDir, 'settings.json');
|
||||
let port = parseWorkerPort(process.env.CLAUDE_MEM_WORKER_PORT);
|
||||
if (!port && existsSync(settingsPath)) {
|
||||
try {
|
||||
const s = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
||||
const settingsPort = parseWorkerPort(s.CLAUDE_MEM_WORKER_PORT);
|
||||
if (settingsPort) port = settingsPort;
|
||||
} catch {}
|
||||
}
|
||||
if (!port) {
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 77;
|
||||
port = 37700 + (uid % 100);
|
||||
}
|
||||
let healthBody;
|
||||
try {
|
||||
healthBody = execSync(`curl -s --max-time 2 http://127.0.0.1:${port}/api/health`, {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).toString().trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!healthBody) return null;
|
||||
let installedVersion;
|
||||
let installedPath;
|
||||
try {
|
||||
const j = JSON.parse(healthBody);
|
||||
installedVersion = j.version;
|
||||
installedPath = j.workerPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!installedVersion || installedVersion === buildVersion) return null;
|
||||
return { installedVersion, installedPath };
|
||||
}
|
||||
|
||||
const installedMismatch = detectInstalledVersion(getPluginVersion());
|
||||
if (installedMismatch) {
|
||||
console.log('');
|
||||
console.log('\x1b[33m%s\x1b[0m', 'Version mismatch detected:');
|
||||
console.log(` Building: ${getPluginVersion()}`);
|
||||
console.log(` Installed: ${installedMismatch.installedVersion}`);
|
||||
if (installedMismatch.installedPath) console.log(` Worker path: ${installedMismatch.installedPath}`);
|
||||
console.log('');
|
||||
console.log('Claude Code is pinned to the installed version, so the worker loads from');
|
||||
console.log(`its cache dir. Mirroring this build into the installed-version cache so the`);
|
||||
console.log('worker restart picks up new code without a Claude Code session restart.');
|
||||
console.log('');
|
||||
console.log('\x1b[36m%s\x1b[0m', `For a formal version bump, run \`claude plugin update thedotmack/claude-mem\``);
|
||||
console.log('\x1b[36m%s\x1b[0m', `and restart Claude Code so it loads the ${getPluginVersion()} cache dir.`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
console.log('Syncing to marketplace...');
|
||||
try {
|
||||
const rootDir = path.join(__dirname, '..');
|
||||
@@ -161,60 +100,8 @@ try {
|
||||
console.log(`Running bun install in cache folder (version ${version})...`);
|
||||
execSync(`bun install`, { cwd: CACHE_VERSION_PATH, stdio: 'inherit' });
|
||||
|
||||
if (installedMismatch && installedMismatch.installedVersion !== version) {
|
||||
const INSTALLED_CACHE_PATH = path.join(CACHE_BASE_PATH, installedMismatch.installedVersion);
|
||||
console.log(`Mirroring to installed-version cache (${installedMismatch.installedVersion}) for hot reload...`);
|
||||
execSync(
|
||||
`rsync -av --delete --exclude=.git ${pluginGitignoreExcludes} plugin/ "${INSTALLED_CACHE_PATH}/"`,
|
||||
{ stdio: 'inherit' }
|
||||
);
|
||||
console.log(`Running bun install in installed-version cache (${installedMismatch.installedVersion})...`);
|
||||
execSync(`bun install`, { cwd: INSTALLED_CACHE_PATH, stdio: 'inherit' });
|
||||
}
|
||||
|
||||
console.log('\x1b[32m%s\x1b[0m', 'Sync complete!');
|
||||
|
||||
console.log('\n🔄 Triggering worker restart...');
|
||||
const http = require('http');
|
||||
const dataDir = process.env.CLAUDE_MEM_DATA_DIR || path.join(os.homedir(), '.claude-mem');
|
||||
const settingsPath = path.join(dataDir, 'settings.json');
|
||||
let settingsPort = null;
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
const settings = JSON.parse(readFileSync(settingsPath, 'utf8'));
|
||||
settingsPort = parseWorkerPort(settings.CLAUDE_MEM_WORKER_PORT);
|
||||
} catch {
|
||||
// fall through to env / default
|
||||
}
|
||||
}
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : 77;
|
||||
const defaultPort = 37700 + (uid % 100);
|
||||
const workerPort =
|
||||
parseWorkerPort(process.env.CLAUDE_MEM_WORKER_PORT) ??
|
||||
settingsPort ??
|
||||
defaultPort;
|
||||
const req = http.request({
|
||||
hostname: '127.0.0.1',
|
||||
port: workerPort,
|
||||
path: '/api/admin/restart',
|
||||
method: 'POST',
|
||||
timeout: 2000
|
||||
}, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
console.log('\x1b[32m%s\x1b[0m', `✓ Worker restart triggered on port ${workerPort}`);
|
||||
} else {
|
||||
console.log('\x1b[33m%s\x1b[0m', `ℹ Worker restart on port ${workerPort} returned status ${res.statusCode}`);
|
||||
}
|
||||
});
|
||||
req.on('error', () => {
|
||||
console.log('\x1b[33m%s\x1b[0m', `ℹ No worker reachable on port ${workerPort}; the next worker:restart step will start one.`);
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
console.log('\x1b[33m%s\x1b[0m', `ℹ Worker restart on port ${workerPort} timed out`);
|
||||
});
|
||||
req.end();
|
||||
|
||||
} catch (error) {
|
||||
console.error('\x1b[31m%s\x1b[0m', 'Sync failed:', error.message);
|
||||
process.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user