hypomnema 1.4.0 → 1.4.1

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.
@@ -1,261 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * scripts/smoke-pack.mjs — pre-publish smoke test
4
- *
5
- * Runs `npm pack`, installs the resulting tarball into an isolated temp
6
- * directory, and exercises the installed CLI to verify the published
7
- * artifact actually works. Catches mistakes that local tests miss:
8
- * - `files:` / `.npmignore` excluding required assets
9
- * - `bin` entry pointing to a missing file
10
- * - runtime requires on paths not shipped in the tarball
11
- *
12
- * Usage:
13
- * node scripts/smoke-pack.mjs [--keep]
14
- *
15
- * --keep Leave the temp directory in place for inspection on success.
16
- */
17
-
18
- import { spawnSync } from 'node:child_process';
19
- import {
20
- mkdtempSync,
21
- rmSync,
22
- readFileSync,
23
- writeFileSync,
24
- existsSync,
25
- readdirSync,
26
- renameSync,
27
- } from 'node:fs';
28
- import { join } from 'node:path';
29
- import { tmpdir } from 'node:os';
30
- import { fileURLToPath } from 'node:url';
31
-
32
- const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..');
33
- const KEEP = process.argv.includes('--keep');
34
-
35
- // When this script runs inside `npm publish --dry-run` (the release workflow's
36
- // publish-credential pre-check), npm exports `npm_config_dry_run=true` into the
37
- // lifecycle environment. spawnSync inherits process.env, so the nested
38
- // `npm pack` below would ALSO run in dry-run mode — reporting a tarball
39
- // filename/size it never actually writes to disk — and the subsequent
40
- // `npm install <tarball>` then fails with ENOENT (npm maps errno -2 → exit 254).
41
- // Strip the flag so the nested npm commands always perform real writes,
42
- // matching the real tag-push publish job (which has no outer `--dry-run`). The
43
- // outer workflow stays dry-run and still skips the registry PUT.
44
- // (This was the precheck-254 root cause; the earlier "missing NODE_AUTH_TOKEN"
45
- // theory was wrong — it is file absence, not auth.)
46
- const npmEnv = { ...process.env };
47
- for (const key of Object.keys(npmEnv)) {
48
- if (key.toLowerCase().replaceAll('-', '_') === 'npm_config_dry_run') {
49
- delete npmEnv[key];
50
- }
51
- }
52
-
53
- const PKG = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf-8'));
54
- const PKG_NAME = PKG.name;
55
-
56
- function run(cmd, args, opts = {}) {
57
- const res = spawnSync(cmd, args, { stdio: 'pipe', encoding: 'utf-8', ...opts });
58
- if (res.status !== 0) {
59
- process.stderr.write(res.stdout || '');
60
- process.stderr.write(res.stderr || '');
61
- throw new Error(`${cmd} ${args.join(' ')} exited ${res.status}`);
62
- }
63
- return res;
64
- }
65
-
66
- function step(msg) {
67
- console.log(`\n▸ ${msg}`);
68
- }
69
-
70
- const work = mkdtempSync(join(tmpdir(), 'hypo-smoke-'));
71
- const sandboxHome = join(work, 'home');
72
- const installRoot = join(work, 'install');
73
- const wikiDir = join(work, 'wiki');
74
- let cleanupOk = false;
75
-
76
- try {
77
- // Capture pre-commit hook contents (if any) BEFORE pack so we can prove
78
- // `npm pack` didn't mutate it. The `prepare` lifecycle script runs during
79
- // `npm pack` and could theoretically touch .git/hooks/pre-commit; the
80
- // installer's CI/lifecycle guards must prevent that.
81
- const preCommitPath = join(REPO, '.git', 'hooks', 'pre-commit');
82
- const preCommitBefore = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
83
-
84
- step('npm pack');
85
- const pack = run('npm', ['pack', '--json'], { cwd: REPO, env: npmEnv });
86
- const meta = JSON.parse(pack.stdout)[0];
87
- const tarball = join(REPO, meta.filename);
88
- console.log(` → ${meta.filename} (${meta.size} bytes, ${meta.entryCount} entries)`);
89
-
90
- step(`install into ${installRoot}`);
91
- run('mkdir', ['-p', installRoot]);
92
- writeFileSync(
93
- join(installRoot, 'package.json'),
94
- JSON.stringify({
95
- name: 'hypo-smoke-host',
96
- version: '0.0.0',
97
- private: true,
98
- }) + '\n',
99
- );
100
- // No `--silent`: run() only prints npm's stdout/stderr on a non-zero exit, so
101
- // a real nested-install failure must not be muted (the precheck-254 error was
102
- // masked by `--silent` for two release cycles).
103
- run('npm', ['install', '--no-audit', '--no-fund', tarball], { cwd: installRoot, env: npmEnv });
104
-
105
- // Move tarball into the work dir so it's not left in the repo.
106
- renameSync(tarball, join(work, meta.filename));
107
-
108
- const cliBin = join(installRoot, 'node_modules', '.bin', 'hypomnema');
109
- if (!existsSync(cliBin)) {
110
- throw new Error(`bin not installed at ${cliBin}`);
111
- }
112
-
113
- step('hypomnema --help (installed)');
114
- const help = run(cliBin, ['--help']);
115
- if (!help.stdout.includes('Usage: hypomnema')) {
116
- throw new Error('--help output did not match expected banner');
117
- }
118
-
119
- step('hypomnema init --dry-run (installed)');
120
- const dryEnv = { ...process.env, HOME: sandboxHome, HYPO_DIR: wikiDir };
121
- const dry = run(
122
- cliBin,
123
- [
124
- 'init',
125
- '--dry-run',
126
- `--hypo-dir=${wikiDir}`,
127
- '--no-hooks',
128
- '--no-commands',
129
- '--no-git-init',
130
- '--no-shell',
131
- ],
132
- { env: dryEnv },
133
- );
134
- if (!/dry[\s-]?run/i.test(dry.stdout + dry.stderr)) {
135
- console.log(dry.stdout);
136
- throw new Error('init --dry-run did not announce dry-run mode');
137
- }
138
-
139
- step('verify shipped assets are present');
140
- const required = [
141
- 'scripts/init.mjs',
142
- 'scripts/upgrade.mjs',
143
- 'hooks',
144
- 'commands',
145
- 'templates',
146
- 'README.md',
147
- ];
148
- const pkgRoot = join(installRoot, 'node_modules', PKG_NAME);
149
- for (const rel of required) {
150
- if (!existsSync(join(pkgRoot, rel))) {
151
- throw new Error(`shipped tarball missing: ${rel}`);
152
- }
153
- }
154
-
155
- step('hypomnema feedback-sync (installed) — check/write idempotency + bootstrap');
156
- // Seed a fixture wiki + claude-home and exercise the feedback-sync surface
157
- // through the installed CLI (proves the subcommand is routed + shipped and the
158
- // Phase D bootstrap helper runs end-to-end). --check exits 1 on a fresh
159
- // (un-written) projection, so use spawnSync (run() throws on non-zero).
160
- const fbWiki = join(work, 'fb-wiki');
161
- const fbHome = join(work, 'fb-home');
162
- const fbProject = 'smoke';
163
- run('mkdir', ['-p', join(fbWiki, 'pages', 'feedback')]);
164
- run('mkdir', ['-p', join(fbHome, 'projects', fbProject, 'memory')]);
165
- writeFileSync(
166
- join(fbWiki, 'hypo-config.md'),
167
- '---\ntitle: config\ntype: reference\n---\n# config\n',
168
- );
169
- writeFileSync(
170
- join(fbWiki, 'pages', 'feedback', 'smoke-rule.md'),
171
- [
172
- '---',
173
- 'title: Smoke Rule',
174
- 'type: feedback',
175
- 'status: active',
176
- 'scope: global',
177
- 'tier: L1',
178
- 'targets: [project-memory, claude-learned]',
179
- 'sensitivity: public',
180
- 'priority: 4',
181
- 'memory_summary: smoke rule for the packed CLI',
182
- 'global_summary: always smoke-test the packed CLI',
183
- 'promote_to_global: true',
184
- 'reason: catch packaging regressions',
185
- 'source: session:2026-05-21',
186
- 'updated: 2026-05-21',
187
- '---',
188
- 'body',
189
- '',
190
- ].join('\n'),
191
- );
192
- writeFileSync(
193
- join(fbHome, 'CLAUDE.md'),
194
- '# Global\n<learned_behaviors>\n- [2026-05-20] legacy hand rule — 이유: bootstrap source\n</learned_behaviors>\n',
195
- );
196
- writeFileSync(join(fbHome, 'projects', fbProject, 'memory', 'MEMORY.md'), '# Memory Index\n');
197
-
198
- const fbArgs = [`--hypo-dir=${fbWiki}`, `--claude-home=${fbHome}`, `--project-id=${fbProject}`];
199
- const fbRun = (extra) =>
200
- spawnSync(cliBin, ['feedback-sync', ...extra, ...fbArgs], { encoding: 'utf-8' });
201
-
202
- const fbCheck1 = fbRun(['--check']);
203
- if (fbCheck1.status !== 1) {
204
- throw new Error(
205
- `feedback-sync --check on fresh projection expected exit 1, got ${fbCheck1.status}`,
206
- );
207
- }
208
- const fbWrite = fbRun(['--write']);
209
- if (fbWrite.status !== 0) {
210
- throw new Error(
211
- `feedback-sync --write expected exit 0, got ${fbWrite.status}: ${fbWrite.stderr}`,
212
- );
213
- }
214
- if (
215
- !readFileSync(join(fbHome, 'CLAUDE.md'), 'utf-8').includes(
216
- 'HYPO:FEEDBACK-SYNC:START source=smoke-rule',
217
- )
218
- ) {
219
- throw new Error('feedback-sync --write did not project the managed block into CLAUDE.md');
220
- }
221
- const fbCheck2 = fbRun(['--check']);
222
- if (fbCheck2.status !== 0) {
223
- throw new Error(
224
- `feedback-sync --check after --write expected clean exit 0, got ${fbCheck2.status}`,
225
- );
226
- }
227
- const fbBootstrap = fbRun(['--bootstrap', '--dry-run']);
228
- if (fbBootstrap.status !== 0 || !/would create draft/.test(fbBootstrap.stderr)) {
229
- throw new Error(
230
- `feedback-sync --bootstrap --dry-run did not announce drafts (exit ${fbBootstrap.status})`,
231
- );
232
- }
233
-
234
- step('verify pre-commit hook was not mutated by npm pack');
235
- const preCommitAfter = existsSync(preCommitPath) ? readFileSync(preCommitPath, 'utf-8') : null;
236
- if (preCommitBefore !== preCommitAfter) {
237
- throw new Error(
238
- 'npm pack mutated .git/hooks/pre-commit — the prepare lifecycle ' +
239
- 'guard (npm_command=pack) is not firing.',
240
- );
241
- }
242
-
243
- console.log('\n✓ smoke-pack passed');
244
- cleanupOk = true;
245
- } catch (err) {
246
- console.error(`\n✗ smoke-pack failed: ${err.message}`);
247
- console.error(` work dir preserved: ${work}`);
248
- process.exitCode = 1;
249
- } finally {
250
- if (cleanupOk && !KEEP) {
251
- rmSync(work, { recursive: true, force: true });
252
- } else if (KEEP) {
253
- console.log(`\nwork dir kept: ${work}`);
254
- }
255
- // Sweep any stray tarballs left in the repo root from a crashed run.
256
- for (const f of readdirSync(REPO)) {
257
- if (f.startsWith(`${PKG_NAME}-`) && f.endsWith('.tgz')) {
258
- rmSync(join(REPO, f), { force: true });
259
- }
260
- }
261
- }
@@ -1,194 +0,0 @@
1
- #!/usr/bin/env node
2
- // smoke-plugin.mjs — a cheap, LOCAL structural check that the Claude Code plugin
3
- // would load, with no network and no Claude CLI dependency (so it runs in CI). It
4
- // validates the three surfaces Claude Code actually reads:
5
- // 1. .claude-plugin/plugin.json — manifest (name, version, commands/skills paths)
6
- // 2. hooks/hooks.json — every `${CLAUDE_PLUGIN_ROOT}/...` target must exist
7
- // 3. component files — commands/*.md and skills/*/SKILL.md must be REAL
8
- // (a .gitkeep alone is not a surface — a plugin with zero commands/skills must fail)
9
- // plus marketplace↔plugin name parity and that the marketplace `source` resolves to a
10
- // dir holding .claude-plugin/plugin.json.
11
- //
12
- // For a deep, real load check on a dev machine: `claude --plugin-dir . plugin list`.
13
- // That needs Claude Code installed, so it is NOT used here; this is the CI floor.
14
- //
15
- // Usage:
16
- // node scripts/smoke-plugin.mjs # smoke the repo's plugin
17
- // node scripts/smoke-plugin.mjs --root <dir> # point at a fixture (tests)
18
- //
19
- // Exit 0 = plugin surfaces are structurally valid. Exit 1 = a load-blocking problem.
20
-
21
- import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
22
- import { join, dirname } from 'path';
23
- import { fileURLToPath } from 'url';
24
-
25
- function parseArgs(argv) {
26
- const args = { root: null };
27
- for (let i = 0; i < argv.length; i++) {
28
- const a = argv[i];
29
- if (a.startsWith('--root=')) args.root = a.slice(7);
30
- else if (a === '--root') args.root = argv[++i];
31
- }
32
- return args;
33
- }
34
-
35
- const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
36
-
37
- function isDir(p) {
38
- try {
39
- return statSync(p).isDirectory();
40
- } catch {
41
- return false;
42
- }
43
- }
44
-
45
- function isFile(p) {
46
- try {
47
- return statSync(p).isFile();
48
- } catch {
49
- return false;
50
- }
51
- }
52
-
53
- function smoke(root) {
54
- const errors = [];
55
- const notes = [];
56
- const fail = (msg) => errors.push(msg);
57
-
58
- // 1. plugin.json manifest.
59
- let plugin = null;
60
- const pluginPath = join(root, '.claude-plugin', 'plugin.json');
61
- try {
62
- plugin = JSON.parse(readFileSync(pluginPath, 'utf-8'));
63
- } catch (err) {
64
- fail(`.claude-plugin/plugin.json: ${err?.message ?? err}`);
65
- }
66
- if (plugin) {
67
- if (typeof plugin.name !== 'string' || !plugin.name) fail('plugin.json: missing "name"');
68
- if (typeof plugin.version !== 'string' || !plugin.version)
69
- fail('plugin.json: missing "version"');
70
- // commands/skills are declared as relative dir paths; if declared they must resolve.
71
- for (const key of ['commands', 'skills']) {
72
- if (plugin[key] != null) {
73
- const rel = String(plugin[key]).replace(/^\.\//, '');
74
- if (!isDir(join(root, rel)))
75
- fail(`plugin.json: "${key}" path "${plugin[key]}" is not a directory`);
76
- }
77
- }
78
- }
79
-
80
- // 2. Component files must be REAL regular files, not just a .gitkeep placeholder
81
- // (or a directory that happens to be named like a component — isFile, not exists).
82
- const commandsMd = isDir(join(root, 'commands'))
83
- ? readdirSync(join(root, 'commands')).filter(
84
- (f) => f.endsWith('.md') && isFile(join(root, 'commands', f)),
85
- )
86
- : [];
87
- if (commandsMd.length === 0) fail('commands/: no *.md command files (only a placeholder?)');
88
- else notes.push(`commands: ${commandsMd.length}`);
89
-
90
- let skillCount = 0;
91
- if (isDir(join(root, 'skills'))) {
92
- for (const entry of readdirSync(join(root, 'skills'))) {
93
- if (isFile(join(root, 'skills', entry, 'SKILL.md'))) skillCount++;
94
- }
95
- }
96
- if (skillCount === 0) fail('skills/: no */SKILL.md skills (only a placeholder?)');
97
- else notes.push(`skills: ${skillCount}`);
98
-
99
- // 3. hooks/hooks.json — every command target AND every `shared` file must be a
100
- // real regular file on disk. hooks.json is the hook source of truth, so a missing
101
- // hypo-shared.mjs / version-check.mjs would pass a manifest-only check but break
102
- // hook imports at runtime.
103
- const hooksPath = join(root, 'hooks', 'hooks.json');
104
- if (!existsSync(hooksPath)) {
105
- fail('hooks/hooks.json: missing');
106
- } else {
107
- let hooksJson = null;
108
- try {
109
- hooksJson = JSON.parse(readFileSync(hooksPath, 'utf-8'));
110
- } catch (err) {
111
- fail(`hooks/hooks.json: ${err?.message ?? err}`);
112
- }
113
- if (hooksJson) {
114
- if (!hooksJson.hooks || typeof hooksJson.hooks !== 'object') {
115
- fail('hooks/hooks.json: missing top-level "hooks" object');
116
- } else {
117
- const targets = new Set();
118
- for (const groups of Object.values(hooksJson.hooks)) {
119
- for (const group of groups || []) {
120
- for (const hk of group?.hooks || []) {
121
- if (hk?.command) {
122
- // command looks like `node ${CLAUDE_PLUGIN_ROOT}/hooks/foo.mjs [args]`
123
- const m = String(hk.command).match(/\$\{CLAUDE_PLUGIN_ROOT\}\/(\S+)/);
124
- if (m) targets.add(m[1]);
125
- }
126
- }
127
- }
128
- }
129
- if (targets.size === 0)
130
- fail('hooks/hooks.json: no ${CLAUDE_PLUGIN_ROOT} command targets found');
131
- for (const rel of targets) {
132
- if (!isFile(join(root, rel))) fail(`hooks/hooks.json: target "${rel}" is not a file`);
133
- }
134
- notes.push(`hook targets: ${targets.size}`);
135
- }
136
- // `shared` lists hook-relative support files that the targets import.
137
- if (Array.isArray(hooksJson.shared)) {
138
- for (const shared of hooksJson.shared) {
139
- if (!isFile(join(root, 'hooks', shared)))
140
- fail(`hooks/hooks.json: shared file "hooks/${shared}" is not a file`);
141
- }
142
- notes.push(`shared: ${hooksJson.shared.length}`);
143
- }
144
- }
145
- }
146
-
147
- // 4. marketplace.json — name parity + source resolves.
148
- const mpPath = join(root, '.claude-plugin', 'marketplace.json');
149
- let mp = null;
150
- try {
151
- mp = JSON.parse(readFileSync(mpPath, 'utf-8'));
152
- } catch (err) {
153
- fail(`.claude-plugin/marketplace.json: ${err?.message ?? err}`);
154
- }
155
- if (mp) {
156
- const plugins = Array.isArray(mp.plugins) ? mp.plugins : [];
157
- if (plugins.length === 0) fail('marketplace.json: "plugins" is empty');
158
- const name = plugin?.name;
159
- if (name) {
160
- const matches = plugins.filter((p) => p && p.name === name);
161
- if (matches.length !== 1) {
162
- fail(
163
- `marketplace.json: expected exactly one entry named "${name}", found ${matches.length}`,
164
- );
165
- } else {
166
- const src = matches[0].source ?? './';
167
- const srcDir = join(root, String(src).replace(/^\.\//, ''));
168
- if (!existsSync(join(srcDir, '.claude-plugin', 'plugin.json'))) {
169
- fail(
170
- `marketplace.json: source "${src}" does not resolve to a dir containing .claude-plugin/plugin.json`,
171
- );
172
- }
173
- }
174
- }
175
- }
176
-
177
- return { errors, notes };
178
- }
179
-
180
- function main() {
181
- const args = parseArgs(process.argv.slice(2));
182
- const root = args.root || REPO_ROOT;
183
- const { errors, notes } = smoke(root);
184
-
185
- if (errors.length) {
186
- for (const e of errors) console.error(` ✗ ${e}`);
187
- console.error(`\n✗ plugin smoke failed (${errors.length} problem(s)).`);
188
- process.exit(1);
189
- }
190
- console.log(`✓ plugin surfaces valid (${notes.join(', ')}).`);
191
- process.exit(0);
192
- }
193
-
194
- main();