Files
Rohit Ghumare 8b98432853 chore(release): v0.9.13 — env-example + init + CJK + load bench + deploy templates + Gemini GA bumps (#383)
* chore(release): v0.9.13 — env-example discovery + CJK tokenizer + load
harness + deploy templates + Gemini GA bumps + 14 advisories closed

Six PRs landed since v0.9.12:

- #372 .env.example discovery (this commit) — repo-root template + `init`
  CLI command + CI sync-checker
- #362 CJK BM25 tokenizer (`@node-rs/jieba` + tiny-segmenter + Hangul)
- #363 `benchmark/load-100k.ts` harness with p50/p90/p99 + per-release
  results dir
- #361 one-click deploy templates for fly.io / Railway / Render / Coolify
  (multi-stage Dockerfile, `iiidev/iii` base, `gosu` privilege drop,
  first-boot HMAC, verified end-to-end on fly.io)
- #364 Python ecosystem via `iii-sdk` example (replaces closed PR #360)
- #370 Gemini GA bumps (LLM default → gemini-2.5-flash, embedding →
  gemini-embedding-001 + L2-norm + 768 dims)

Plus 14 open Dependabot advisories closed in PR #348 via Next.js → 16.2.6
and PostCSS → 8.5.10 overrides.

Bumped:

- src/version.ts: VERSION 0.9.12 → 0.9.13
- package.json: 0.9.12 → 0.9.13, files += ".env.example", build script
  copies .env.example into dist/
- packages/mcp/package.json: 0.9.12 → 0.9.13 (lockstep with main)
- plugin/.claude-plugin/plugin.json, plugin/.codex-plugin/plugin.json:
  0.9.12 → 0.9.13
- src/types.ts: ExportData.version union extended with "0.9.13"
- src/functions/export-import.ts: supportedVersions Set extended
- test/export-import.test.ts: expected version updated

New surface:

- .env.example at repo root — every env var read by src/ documented in
  one place, grouped by surface (LLM, embedding, auth, search tuning,
  behaviour flags, CLI runtime, ports, iii engine pin, Claude Code
  bridge, Obsidian export). Every line commented out by default so the
  file is a template.
- agentmemory init — copies bundled .env.example to ~/.agentmemory/.env
  if absent, refuses to overwrite, prints a diff command. Wired into
  CLI dispatch + help block.
- scripts/check-env-example.mjs — walks src/ for env-read patterns,
  fails CI on drift in either direction. Plugged into ci.yml after
  npm test. Initial bootstrap: 60 keys in sync.

Verified: npm test 903/903, npm run build clean, init smoke pass
(creates ~/.agentmemory/.env on first run, refuses overwrite on second).

* fix(init): atomic copy via COPYFILE_EXCL; address CodeRabbit review

Two valid findings from the CodeRabbit pass on PR #383.

1. `runInit` race between existsSync(target) + copyFile(template, target).
   A parallel `agentmemory init` (or any other process touching
   ~/.agentmemory/.env between the two calls) would silently overwrite
   the config the operator just wrote. Switch to a single atomic
   `copyFile(template, target, fsConstants.COPYFILE_EXCL)` and treat
   the EEXIST error as the "already configured" signal — same warning
   + diff hint as before, but the check + copy now happen in one
   syscall so they cannot race. Other failure paths still surface as
   process exit 1.

2. Comment on `scripts/check-env-example.mjs::walk` claimed it matched
   ".ts / .mts / .mjs" but the regex also matched ".js". Rewrote the
   comment to match the regex (".ts / .mts / .mjs / .js"). Same comment
   pass: noted that test/ never enters because the walk is rooted at
   src/, not because of an explicit skip.

Skipped findings:

- WHAT-style comment on `findEnvExample` — kept a one-liner explaining
  the package-vs-source priority since both paths are real; reduced
  the block from 4 lines to 2 instead of removing it entirely.
- "Add trailing newline to .env.example" — file already ends with `\n`
  (verified `tail -c 5` shows `tion\n`).

Verified locally:
- `npm run build` clean.
- `npm test` 903 / 903 pass.
- First `agentmemory init` against a clean HOME creates the file.
- Second init against the same HOME hits EEXIST and prints the
  "leaving it untouched" warning + diff hint without overwriting.
- `node scripts/check-env-example.mjs` — in sync (60 keys).
2026-05-15 10:11:37 +01:00

102 lines
3.3 KiB
JavaScript

#!/usr/bin/env node
//
// Sync-check: every env var read by `src/` MUST be documented in
// `.env.example`. Runs in CI as a soft guard rail — keeps `.env.example`
// from drifting behind real config-surface additions.
//
// Usage:
// node scripts/check-env-example.mjs
//
// Returns 0 when in sync, 1 with a diff when out of sync.
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
const ROOT = new URL("..", import.meta.url).pathname;
const SRC = join(ROOT, "src");
const ENV_FILE = join(ROOT, ".env.example");
// Env vars read by the runtime but NOT user-facing config — these are
// either process-injected (HOME, PATH, USERPROFILE), set by the build /
// wrapper (NODE_*, npm_*), or set by tests (VITEST, *_TEST_*). Skipping
// them keeps `.env.example` a documented config surface rather than an
// inventory of every getenv anywhere in the codebase.
const RUNTIME_ONLY = new Set([
"HOME",
"PATH",
"USERPROFILE",
"NODE_ENV",
"AGENTMEMORY_SDK_CHILD",
]);
// Walk src/ for .ts / .mts / .mjs / .js files (excluding `.d.ts` declarations
// and dotfile dirs / node_modules). test/ lives outside src/ so it never enters.
function walk(dir) {
const out = [];
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const s = statSync(full);
if (s.isDirectory()) {
if (entry === "node_modules" || entry.startsWith(".")) continue;
out.push(...walk(full));
} else if (/\.(ts|mts|mjs|js)$/.test(entry) && !entry.endsWith(".d.ts")) {
out.push(full);
}
}
return out;
}
// Multiple patterns:
// process.env["KEY"] — direct access
// env["KEY"] — local alias inside detectProvider, etc.
// getEnvVar("KEY") — helper from src/config.ts
// env: ProcessEnv → env.KEY — caught as `env["KEY"]` only; if you add
// a dotted-access path, extend the regex.
const PATTERNS = [
// Direct map index: process.env["KEY"], env["KEY"], getMergedEnv()["KEY"].
// The trailing `]\s*` form covers `…)["KEY"]` and `…env["KEY"]`.
/\[\s*"([A-Z][A-Z0-9_]+)"\s*\]/g,
/getEnvVar\(\s*"([A-Z][A-Z0-9_]+)"\s*\)/g,
];
const used = new Set();
for (const file of walk(SRC)) {
const text = readFileSync(file, "utf8");
for (const pat of PATTERNS) {
pat.lastIndex = 0;
let m;
while ((m = pat.exec(text)) !== null) {
const name = m[1];
if (!RUNTIME_ONLY.has(name)) used.add(name);
}
}
}
const envText = readFileSync(ENV_FILE, "utf8");
const documented = new Set();
for (const line of envText.split("\n")) {
const m = line.match(/^#?\s*([A-Z][A-Z0-9_]+)=/);
if (m) documented.add(m[1]);
}
const missing = [...used].filter((k) => !documented.has(k)).sort();
const orphan = [...documented].filter((k) => !used.has(k)).sort();
if (missing.length === 0 && orphan.length === 0) {
console.log(`env-example: in sync (${used.size} keys documented)`);
process.exit(0);
}
if (missing.length > 0) {
console.error(
`env-example: MISSING from .env.example — add documentation for these keys:`,
);
for (const k of missing) console.error(` - ${k}`);
}
if (orphan.length > 0) {
console.error(
`env-example: ORPHAN in .env.example — no longer read by src/, remove or move to runtime-only allowlist:`,
);
for (const k of orphan) console.error(` - ${k}`);
}
process.exit(1);