nearly-cli 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -31,31 +31,25 @@ Gating, recording and the record itself work on macOS, Linux and Windows. **Spok
31
31
  One command, in the repo you want recorded.
32
32
 
33
33
  ```bash
34
- cd ~/code/my-app
35
- nearly
34
+ npx nearly-cli
36
35
  ```
37
36
 
38
37
  ```
39
38
  ✓ Nearly is on for my-app
40
39
 
41
40
  · every Claude Code session here is gated and recorded
41
+ · upgrades reach this repo automatically
42
42
  · the record is offered when you push
43
- · records publish to https://you.github.io/nearly/records
44
43
 
45
44
  Now just work. Requests that need you appear at http://127.0.0.1:47653
46
45
  Nothing to leave running. Turn it off again with --off.
47
46
  ```
48
47
 
49
- It installs the Claude Code hooks and the git pre-push hook, and works out where records publish by reading the Nearly's own remote. Nothing to configure. `nearly off` removes all of it.
48
+ That installs Nearly, turns it on for this repo, and works out where records go.
49
+ Nothing to configure, no server to start, and `nearly off` removes all of it.
50
50
 
51
- **To get that command,** until this is on npm:
52
-
53
- ```bash
54
- git clone https://github.com/anujpatel06/nearly ~/nearly
55
- npm link --prefix ~/nearly
56
- ```
57
-
58
- No dependencies, so the link is instant. Once published it becomes `npx nearly-cli` with nothing to clone at all.
51
+ Run it again in any other repo you want recorded. After the first time the
52
+ command is just `nearly`.
59
53
 
60
54
  ### Upgrading
61
55
 
@@ -95,7 +89,7 @@ Nothing about how you work changes. Open the repo in VS Code or a terminal, star
95
89
 
96
90
  ### For a team
97
91
 
98
- `.claude/settings.local.json` is per-person and stays out of git, which is right while you are trying it. To turn it on for everyone, move the same hooks into `.claude/settings.json` and commit that file. Once this is on npm the hooks invoke `npx nearly-cli`, so a teammate who clones the repo needs nothing installed beyond Node.
92
+ `.claude/settings.local.json` is per-person and stays out of git, which is right while you are trying it. To turn it on for everyone, move the same hooks into `.claude/settings.json` and commit that file. The hooks invoke `nearly` by name, so a teammate who clones the repo runs `npx nearly-cli` once and is set up.
99
93
 
100
94
  ## Why the gate is not the point
101
95
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nearly-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "A pull request tells you what changed. Nearly tells you what nearly happened: the commands a human refused, the pushes policy blocked, the turns rolled back.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "license": "MIT",
31
31
  "repository": {
32
32
  "type": "git",
33
- "url": "https://github.com/anujpatel06/nearly.git"
33
+ "url": "git+https://github.com/anujpatel06/nearly.git"
34
34
  },
35
35
  "homepage": "https://anujpatel06.github.io/nearly/",
36
36
  "scripts": {
@@ -20,6 +20,7 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
20
20
  import { join, resolve, dirname, basename } from 'node:path';
21
21
  import { fileURLToPath } from 'node:url';
22
22
  import { execFileSync, spawnSync } from 'node:child_process';
23
+ import { dataRoot } from '../server/paths.mjs';
23
24
 
24
25
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
25
26
  const HOOK = join(root, 'scripts', 'hook.mjs');
@@ -51,7 +52,25 @@ function onPath() {
51
52
  }
52
53
 
53
54
  const fromPackage = /[\\/]node_modules[\\/]/.test(root) || /[\\/]_npx[\\/]/.test(root);
54
- const installed = onPath();
55
+ const viaNpx = /[\\/]_npx[\\/]/.test(root);
56
+
57
+ // `npx nearly-cli` should be the whole of it. An npx run is a temporary
58
+ // download, so hooks pointing at it would pin a version in a directory npm
59
+ // clears, and no fix would ever reach this repo. Install it properly instead,
60
+ // once, out loud, so the one command someone types actually leaves them with a
61
+ // working tool. --no-install skips it.
62
+ function installGlobally() {
63
+ if (!viaNpx || onPath() || argv.includes('--no-install') || process.env.NEARLY_NO_INSTALL === '1') return false;
64
+ process.stdout.write(dim(` Installing nearly so upgrades reach you… `));
65
+ const r = spawnSync('npm', ['install', '-g', `nearly-cli@${pkgVersion()}`, '--silent', '--no-fund', '--no-audit'],
66
+ { encoding: 'utf8', timeout: 180_000 });
67
+ if (r.status === 0 && onPath()) { console.log('done'); return true; }
68
+ console.log(dim('skipped'));
69
+ console.log(dim(' Running from npx instead. Upgrades will not reach this repo automatically;'));
70
+ console.log(dim(' npm install -g nearly-cli when you want that.'));
71
+ return false;
72
+ }
73
+ let installed = onPath();
55
74
  const hookCmd = (ev) => installed
56
75
  ? `nearly hook ${ev}`
57
76
  : fromPackage
@@ -80,6 +99,10 @@ if (!existsSync(join(repo, '.git'))) {
80
99
  process.exit(1);
81
100
  }
82
101
 
102
+ // Do this before the hooks are written, so they can point at the installed
103
+ // command rather than a temporary npx download.
104
+ if (!off && installGlobally()) installed = onPath();
105
+
83
106
  // ---------------------------------------------------------------------------
84
107
  // Claude Code hooks
85
108
  // ---------------------------------------------------------------------------
@@ -185,9 +208,9 @@ if (base) {
185
208
  } else {
186
209
  // Only reachable from a clone of the upstream repo, where publishing needs a
187
210
  // fork the person actually controls.
188
- console.log(` ${ok('·')} ${dim('records stay on this machine')}`);
189
- console.log(` ${dim('to publish them, fork this repo, turn on GitHub Pages, then:')}`);
190
- console.log(` ${dim('NEARLY_URL_BASE=https://<you>.github.io/<fork>/records nearly')}`);
211
+ console.log(` ${ok('·')} ${dim(`records are kept in ${dataRoot.replace(process.env.HOME || '~', '~')}`)}`);
212
+ console.log(` ${dim('to link them from a pull request, host that folder anywhere and:')}`);
213
+ console.log(` ${dim('NEARLY_URL_BASE=https://your-host/records nearly')}`);
191
214
  }
192
215
  console.log('');
193
216
  console.log(` Now just work. Requests that need you appear at ${bold(`http://127.0.0.1:${PORT}`)}`);
@@ -16,11 +16,12 @@ import { join, dirname, basename, resolve } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { execFileSync, spawnSync } from 'node:child_process';
18
18
  import { tmpdir } from 'node:os';
19
+ import { paths } from '../server/paths.mjs';
19
20
 
20
21
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
21
- const recordingsDir = process.env.NEARLY_RECORDINGS || join(root, 'recordings');
22
+ const recordingsDir = paths.recordings();
22
23
  const templatePath = join(root, 'ui', 'recap.template.html');
23
- const outDir = process.env.NEARLY_OUT || join(root, 'ui', 'records');
24
+ const outDir = paths.pages();
24
25
 
25
26
  // ---------------------------------------------------------------------------
26
27
  // args
@@ -635,7 +636,7 @@ if (!noAudio) {
635
636
  sb.totalS = sb.scenes.reduce((n, s) => n + s.durS, 0);
636
637
 
637
638
  mkdirSync(outDir, { recursive: true });
638
- const storyDirOut = process.env.NEARLY_STORY || join(root, 'records');
639
+ const storyDirOut = paths.records();
639
640
  mkdirSync(storyDirOut, { recursive: true });
640
641
  const safe = (x) => String(x).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
641
642
  const slug = BRANCH ? `${safe(sb.name)}--${safe(BRANCH)}` : `${sb.name}-${sb.id.slice(0, 4)}`;
@@ -14,6 +14,7 @@ import { join, dirname } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { spawnSync } from 'node:child_process';
16
16
  import { tmpdir } from 'node:os';
17
+ import { paths } from '../server/paths.mjs';
17
18
 
18
19
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
19
20
  const argv = process.argv.slice(2);
@@ -22,7 +23,7 @@ const ubIdx = argv.indexOf('--url-base');
22
23
  const urlBase = (ubIdx !== -1 ? argv[ubIdx + 1] : process.env.NEARLY_URL_BASE || '').replace(/\/$/, '');
23
24
  const target = argv.find((a, i) => !a.startsWith('--') && argv[i - 1] !== '--url-base') || 'latest';
24
25
 
25
- const dir = join(root, 'records');
26
+ const dir = paths.records();
26
27
  const files = readdirSync(dir).filter((f) => f.endsWith('.json'));
27
28
  if (!files.length) { console.error('no storyboards in records/. Run build-recap first.'); process.exit(1); }
28
29
  let file;
@@ -16,11 +16,12 @@
16
16
  import { readFileSync, writeFileSync, readdirSync, mkdirSync, copyFileSync, existsSync, statSync } from 'node:fs';
17
17
  import { join, dirname } from 'node:path';
18
18
  import { fileURLToPath } from 'node:url';
19
+ import { paths } from '../server/paths.mjs';
19
20
 
20
21
  const root = join(dirname(fileURLToPath(import.meta.url)), '..');
21
- const storyDir = join(root, 'records');
22
- const builtDir = join(root, 'ui', 'records');
23
- const docsDir = join(root, 'docs');
22
+ const storyDir = paths.records();
23
+ const builtDir = paths.pages();
24
+ const docsDir = paths.docs();
24
25
  const outRecaps = join(docsDir, 'records');
25
26
 
26
27
  const argv = process.argv.slice(2);
@@ -10,6 +10,7 @@ import { join, dirname, resolve, basename } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
11
  import { execFileSync, spawnSync } from 'node:child_process';
12
12
  import { createInterface } from 'node:readline';
13
+ import { paths } from '../server/paths.mjs';
13
14
 
14
15
  const root = resolve(join(dirname(fileURLToPath(import.meta.url)), '..'));
15
16
  const repo = resolve(process.argv[2] || '.');
@@ -55,7 +56,7 @@ if (build.status !== 0) {
55
56
 
56
57
  const safe = (x) => String(x).replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '').toLowerCase();
57
58
  const slug = `${safe(basename(repo))}--${safe(branch)}`;
58
- const storyPath = join(root, 'records', `${slug}.json`);
59
+ const storyPath = join(paths.records(), `${slug}.json`);
59
60
  if (!existsSync(storyPath)) bail('record built but not found on disk');
60
61
 
61
62
  const sb = JSON.parse(readFileSync(storyPath, 'utf8'));
package/server/index.mjs CHANGED
@@ -11,13 +11,14 @@ import path from 'node:path';
11
11
  import { randomUUID } from 'node:crypto';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { DEFAULT_TIER, ruleKey, classify as classifyWith } from './policy.mjs';
14
+ import { paths } from './paths.mjs';
14
15
 
15
16
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
16
17
  const PORT = Number(process.env.NEARLY_PORT || 47653);
17
18
  const HOST = '127.0.0.1';
18
19
  const WORKSPACE = path.join(ROOT, 'workspace');
19
20
  const WORKTREES = path.join(WORKSPACE, '.worktrees');
20
- const RECORDINGS = process.env.NEARLY_RECORDINGS || path.join(ROOT, 'recordings');
21
+ const RECORDINGS = paths.recordings();
21
22
  const UI = path.join(ROOT, 'ui', 'index.html');
22
23
  const MAX_SESSIONS = 3; // 8 GB machine
23
24
  const ASK_TIMEOUT_MS = Number(process.env.NEARLY_ASK_TIMEOUT_MS || 120_000); // UI must answer before this; then we fail CLOSED (deny)
@@ -0,0 +1,38 @@
1
+ // Where Nearly keeps what it records.
2
+ //
3
+ // Installed from npm, the package directory is replaced wholesale on every
4
+ // upgrade, so anything written there is destroyed by the next `npm install -g`.
5
+ // Recordings are the one thing in this project that cannot be regenerated:
6
+ // every record, every count, every refusal is derived from them. They belong in
7
+ // the user's own space.
8
+ //
9
+ // Run from a checkout, the checkout is the workspace, which is what a
10
+ // contributor expects and what the tests rely on.
11
+
12
+ import { existsSync, mkdirSync } from 'node:fs';
13
+ import { join, dirname } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { homedir } from 'node:os';
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const pkgRoot = join(here, '..');
19
+
20
+ // A checkout has a .git directory; an installed package does not.
21
+ export const fromCheckout = existsSync(join(pkgRoot, '.git'));
22
+
23
+ export const dataRoot = fromCheckout
24
+ ? pkgRoot
25
+ : join(process.env.NEARLY_HOME || join(homedir(), '.nearly'));
26
+
27
+ export function dataDir(...parts) {
28
+ const p = join(dataRoot, ...parts);
29
+ try { mkdirSync(p, { recursive: true }); } catch { /* caller will report */ }
30
+ return p;
31
+ }
32
+
33
+ export const paths = {
34
+ recordings: () => process.env.NEARLY_RECORDINGS || dataDir('recordings'),
35
+ records: () => process.env.NEARLY_STORY || dataDir('records'),
36
+ pages: () => process.env.NEARLY_OUT || (fromCheckout ? join(pkgRoot, 'ui', 'records') : dataDir('pages', 'records')),
37
+ docs: () => (fromCheckout ? join(pkgRoot, 'docs') : dataDir('pages')),
38
+ };