stikfix 1.3.1 → 1.5.0

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
@@ -115,6 +115,32 @@ cp /path/to/stikfix/skill/SKILL.md .claude/skills/review-notes/SKILL.md
115
115
 
116
116
  Run it on a clean directory and it just says "no unread notes." Safe to fire any time.
117
117
 
118
+ ## Git-sync mode (optional)
119
+
120
+ By default stikfix is purely local: notes land in `notes/` and nothing else
121
+ happens. **Git-sync mode** is an opt-in, per-project mode for working across
122
+ more than one computer — capture a note on your laptop, `git pull` on your
123
+ desktop, and your agent sees it there.
124
+
125
+ - **Enable it** with the **"Sync notes to git"** toggle in the extension
126
+ popup for that project, or set it as a machine-level default by launching
127
+ the host with the `--git-sync` flag.
128
+ - **Requirements:** the project must be a git repository with a configured
129
+ remote you can push to. stikfix uses your machine's existing git auth
130
+ (SSH key / credential manager) — it never stores or handles a token itself.
131
+ - **What it does:** after writing a captured note (and any screenshot PNGs)
132
+ to disk, the host runs `git add`, `git commit`, and `git push` — all
133
+ pathspec-limited to `notes/`, so it only ever commits notes and never
134
+ touches your code changes.
135
+ - **Multi-computer workflow:** capture notes on machine A → they're pushed
136
+ automatically → `git pull` on machine B → your AI agent (via the
137
+ `review-notes` skill) sees them. The skill also pulls before reading and
138
+ pushes its own frontmatter updates (`status: resolved`, `reply`, etc.) back
139
+ up — see [`skill/SKILL.md`](skill/SKILL.md).
140
+ - **Repo size:** screenshots are committed as PNGs alongside their notes, so
141
+ the repo will grow over time with review history — something to be aware
142
+ of on long-lived projects.
143
+
118
144
  ## Demo
119
145
 
120
146
  ![stikfix demo](docs/demo-placeholder.png)
@@ -67,6 +67,21 @@ export function resolveConfigValues(values, env = process.env) {
67
67
  const token = values['token'] ??
68
68
  env['STIKFIX_TOKEN'] ??
69
69
  env['npm_config_token'];
70
+ // git-sync — flag (boolean) > STIKFIX_GIT_SYNC > npm_config_git_sync.
71
+ // Presence of --git-sync (boolean true), or env value '1'/'true', enables it.
72
+ // Returned under the same 'git-sync' key so it survives the double resolution
73
+ // (index.ts resolves once, resolveConfig resolves again).
74
+ const gitSyncFlag = values['git-sync'];
75
+ let gitSync;
76
+ if (typeof gitSyncFlag === 'boolean') {
77
+ gitSync = gitSyncFlag;
78
+ }
79
+ else {
80
+ const gitSyncEnv = env['STIKFIX_GIT_SYNC'] ?? env['npm_config_git_sync'];
81
+ if (gitSyncEnv !== undefined) {
82
+ gitSync = gitSyncEnv === '1' || gitSyncEnv.toLowerCase() === 'true';
83
+ }
84
+ }
70
85
  return {
71
86
  root,
72
87
  origin: origins,
@@ -74,6 +89,7 @@ export function resolveConfigValues(values, env = process.env) {
74
89
  'notes-dir': notesDir,
75
90
  port,
76
91
  token,
92
+ 'git-sync': gitSync,
77
93
  };
78
94
  }
79
95
  // ---------------------------------------------------------------------------
@@ -110,7 +126,9 @@ export function resolveConfig(values) {
110
126
  }
111
127
  // D-07 token resolution order (npm_config_token handled in resolveConfigValues)
112
128
  const token = v['token'] ?? randomUUID();
113
- return { root, notesDir, name, origins, port, token };
129
+ // git-sync default false (opt-in). resolveConfigValues resolved the three tiers.
130
+ const gitSync = v['git-sync'] ?? false;
131
+ return { root, notesDir, name, origins, port, token, gitSync };
114
132
  }
115
133
  // ---------------------------------------------------------------------------
116
134
  // ensureNotesDir (HOST-12)
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Opt-in git-sync for stikfix-host.
3
+ *
4
+ * When git-sync is enabled, after a note's .md + screenshots are written AND the
5
+ * HTTP 200 is already sent, the host commits ONLY the notes/ folder and pushes —
6
+ * non-blocking, so Send stays instant. Default (git-sync OFF) = no git calls at all;
7
+ * this module is never even imported into the hot path unless a sync is requested
8
+ * (server.ts fires gitSyncNote only when doSync is true) and isGitRepo is only
9
+ * queried by /status.
10
+ *
11
+ * Security invariant (matches host/src/folder-picker.ts and
12
+ * host/src/bootstrap/register.ts): ALL shell-outs go through
13
+ * execFile('git', [argArray], { cwd: root }) — NEVER exec, NEVER shell:true, and
14
+ * NEVER string-interpolate user input into a command. Arg arrays only. The commit
15
+ * message is a fixed `stikfix: note NNNN` (NNNN = digits) passed as a single arg
16
+ * element, so command injection is impossible.
17
+ *
18
+ * Node builtins only — no WXT, no Chrome imports.
19
+ */
20
+ import { execFile } from 'node:child_process';
21
+ import { promisify } from 'node:util';
22
+ const execFileAsync = promisify(execFile);
23
+ // ---------------------------------------------------------------------------
24
+ // Module-level state
25
+ // ---------------------------------------------------------------------------
26
+ /** Per-root cache of "is this a git work tree" — repo status is stable per session. */
27
+ const gitRepoCache = new Map();
28
+ /** Result of the most recent git-sync attempt (null until the first attempt). */
29
+ let lastStatus = null;
30
+ /**
31
+ * Serialize all git operations through a promise-chain queue (mutex): each
32
+ * gitSyncNote call chains after the previous so concurrent notes never race git.
33
+ * Never rejects — the chain always continues.
34
+ */
35
+ let gitQueue = Promise.resolve();
36
+ // ---------------------------------------------------------------------------
37
+ // isGitRepo — cached "is <root> inside a git work tree"
38
+ // ---------------------------------------------------------------------------
39
+ /**
40
+ * Return true iff `git rev-parse --is-inside-work-tree` (cwd=root) exits 0 and
41
+ * prints "true". Cached per-root (repo status doesn't change during a session)
42
+ * so /status calls are cheap.
43
+ */
44
+ export async function isGitRepo(root, execFileFn = execFileAsync) {
45
+ const cached = gitRepoCache.get(root);
46
+ if (cached !== undefined)
47
+ return cached;
48
+ let result = false;
49
+ try {
50
+ const { stdout } = await execFileFn('git', ['rev-parse', '--is-inside-work-tree'], { cwd: root });
51
+ result = stdout.trim() === 'true';
52
+ }
53
+ catch {
54
+ result = false;
55
+ }
56
+ gitRepoCache.set(root, result);
57
+ return result;
58
+ }
59
+ // ---------------------------------------------------------------------------
60
+ // getLastGitSyncStatus — read the most recent attempt (for /status)
61
+ // ---------------------------------------------------------------------------
62
+ export function getLastGitSyncStatus() {
63
+ return lastStatus;
64
+ }
65
+ // ---------------------------------------------------------------------------
66
+ // gitSyncNote — commit ONLY notes/ and push (queued, never rejects)
67
+ // ---------------------------------------------------------------------------
68
+ /**
69
+ * Heuristic: did this git error mean "there was nothing to commit"? git commit
70
+ * exits 1 with a clean tree and prints "nothing to commit" / "no changes added".
71
+ */
72
+ function isNothingToCommit(err) {
73
+ const e = err;
74
+ const text = `${e.stdout ?? ''}\n${e.stderr ?? ''}\n${e.message ?? ''}`.toLowerCase();
75
+ return (text.includes('nothing to commit') ||
76
+ text.includes('no changes added to commit') ||
77
+ text.includes('nothing added to commit'));
78
+ }
79
+ /**
80
+ * Sync a single note to git: add → commit (pathspec-limited to notes/) → push.
81
+ *
82
+ * Behavior:
83
+ * 1. Not a git repo → record {ok:false, error:'not a git repository'} and return
84
+ * (does NOT throw).
85
+ * 2. `git add -- <notesDir>` — stage only the note files.
86
+ * 3. `git commit -m "stikfix: note <NNNN>" -- <notesDir>` — pathspec-limited so it
87
+ * commits ONLY notes/, never the owner's other staged/unstaged code. A
88
+ * "nothing to commit" exit is treated as OK/no-op, not an error.
89
+ * 4. `git push` — uses the current branch's upstream. On push failure (no upstream,
90
+ * non-fast-forward, …) the error is recorded but NO automatic pull/rebase is
91
+ * attempted (never mutate the owner's branch beyond adding the note commit).
92
+ * The commit is already local and safe.
93
+ * 5. Full success → record {ok:true}.
94
+ *
95
+ * Serialized through a module-level queue; catches ALL errors internally so it can
96
+ * be fired non-awaited without ever crashing the host.
97
+ */
98
+ export function gitSyncNote(args) {
99
+ const { root, notesDir, serial } = args;
100
+ const run = args.execFileFn ?? execFileAsync;
101
+ // Chain onto the queue; the wrapped body never rejects.
102
+ const next = gitQueue.then(async () => {
103
+ try {
104
+ if (!(await isGitRepo(root, run))) {
105
+ lastStatus = { ok: false, error: 'not a git repository', at: Date.now() };
106
+ return;
107
+ }
108
+ // Stage only the notes dir (absolute path is fine with cwd=root).
109
+ await run('git', ['add', '--', notesDir], { cwd: root });
110
+ // Pathspec-limited commit — fixed message, NNNN = zero-padded serial digits.
111
+ const message = `stikfix: note ${String(serial).padStart(4, '0')}`;
112
+ try {
113
+ await run('git', ['commit', '-m', message, '--', notesDir], { cwd: root });
114
+ }
115
+ catch (commitErr) {
116
+ if (isNothingToCommit(commitErr)) {
117
+ // Note path already committed — treat as success/no-op.
118
+ lastStatus = { ok: true, at: Date.now() };
119
+ return;
120
+ }
121
+ throw commitErr;
122
+ }
123
+ // Push to the current branch's upstream. On failure, record but do NOT
124
+ // pull/rebase — the commit is already local and safe.
125
+ await run('git', ['push'], { cwd: root });
126
+ lastStatus = { ok: true, at: Date.now() };
127
+ }
128
+ catch (err) {
129
+ const e = err;
130
+ const detail = (e.stderr && e.stderr.trim()) || e.message || String(err);
131
+ lastStatus = { ok: false, error: detail, at: Date.now() };
132
+ }
133
+ });
134
+ // Keep the queue alive even if something unexpected escapes (it shouldn't).
135
+ gitQueue = next.catch(() => { });
136
+ return next;
137
+ }
138
+ // ---------------------------------------------------------------------------
139
+ // Test-only reset — clear module state between unit tests
140
+ // ---------------------------------------------------------------------------
141
+ /** @internal Reset cache/status/queue. Used by unit tests only. */
142
+ export function __resetGitSyncStateForTests() {
143
+ gitRepoCache.clear();
144
+ lastStatus = null;
145
+ gitQueue = Promise.resolve();
146
+ }
@@ -28,6 +28,7 @@ const { values: rawValues } = parseArgs({
28
28
  'notes-dir': { type: 'string' },
29
29
  port: { type: 'string' },
30
30
  token: { type: 'string' },
31
+ 'git-sync': { type: 'boolean' },
31
32
  },
32
33
  strict: false,
33
34
  });
@@ -16,6 +16,7 @@ import { withSerialLock, getNextSerial } from './serial.js';
16
16
  import { writeNote } from './write-note.js';
17
17
  import { listAnnotations, editNote, deleteNote } from './read-note.js';
18
18
  import { validateChosenFolder } from './validate-folder.js';
19
+ import { isGitRepo, getLastGitSyncStatus, gitSyncNote } from './git-sync.js';
19
20
  // ---------------------------------------------------------------------------
20
21
  // Per-request notes-dir resolution (D-04 — targetDir support)
21
22
  // ---------------------------------------------------------------------------
@@ -81,7 +82,7 @@ function setPreflightHeaders(req, res) {
81
82
  // ---------------------------------------------------------------------------
82
83
  // Route handlers
83
84
  // ---------------------------------------------------------------------------
84
- function handleStatus(req, res, cfg) {
85
+ async function handleStatus(req, res, cfg) {
85
86
  setCorsHeaders(req, res);
86
87
  const body = JSON.stringify({
87
88
  app: 'stikfix',
@@ -90,6 +91,10 @@ function handleStatus(req, res, cfg) {
90
91
  root: cfg.root,
91
92
  notesDir: cfg.notesDir,
92
93
  origins: cfg.origins,
94
+ // git-sync surface (shared contract): is cfg.root a git work tree, and the
95
+ // result of the most recent git-sync attempt (null if none yet).
96
+ gitRepo: await isGitRepo(cfg.root),
97
+ gitSyncStatus: getLastGitSyncStatus(),
93
98
  });
94
99
  res.writeHead(200, { 'Content-Type': 'application/json' });
95
100
  res.end(body);
@@ -162,12 +167,21 @@ async function handleAnnotation(req, res, cfg) {
162
167
  // Writes are confined to <notesDir> (= cfg.notesDir OR <validated targetDir>/notes).
163
168
  // targetDir is NOT persisted into the note frontmatter (routing-only field).
164
169
  try {
170
+ let serialNum = 0;
165
171
  const { file, serial } = await withSerialLock(async () => {
166
- const serialNum = getNextSerial(notesDir);
172
+ serialNum = getNextSerial(notesDir);
167
173
  return writeNote(notesDir, payload, serialNum);
168
174
  });
169
175
  res.writeHead(200, { 'Content-Type': 'application/json' });
170
176
  res.end(JSON.stringify({ ok: true, file, serial }));
177
+ // git-sync (opt-in) — AFTER the 200 is sent, outside withSerialLock, and
178
+ // fire-and-forget so Send stays instant. Runs iff the CLI/machine default
179
+ // (cfg.gitSync) OR the per-send flag (payload.gitSync) is true. Its own queue
180
+ // serializes concurrent notes; .catch-guarded so it can never crash the host.
181
+ const doSync = cfg.gitSync === true || payload.gitSync === true;
182
+ if (doSync) {
183
+ void gitSyncNote({ root: cfg.root, notesDir, serial: serialNum }).catch(() => { });
184
+ }
171
185
  }
172
186
  catch (e) {
173
187
  // CR-02: propagate statusCode from write-phase errors (e.g. bad screenshot → 400)
@@ -384,7 +398,13 @@ export function createHostServer(cfg) {
384
398
  return;
385
399
  }
386
400
  if (method === 'GET' && path === '/status') {
387
- handleStatus(req, res, cfg);
401
+ handleStatus(req, res, cfg).catch((e) => {
402
+ if (!res.headersSent) {
403
+ setCorsHeaders(req, res);
404
+ res.writeHead(500, { 'Content-Type': 'application/json' });
405
+ res.end(JSON.stringify({ ok: false, error: String(e) }));
406
+ }
407
+ });
388
408
  return;
389
409
  }
390
410
  if (method === 'POST' && path === '/annotation') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "stikfix",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "Pin sticky notes on any page — your AI reads them.",
5
5
  "license": "MIT",
6
6
  "author": "Omer Nesher <omernesher@gmail.com>",
@@ -50,7 +50,7 @@
50
50
  "test:lib": "tsc -p tsconfig.lib.json && node --test \"dist/lib/lib/test/**/*.test.js\"",
51
51
  "test": "tsc -p tsconfig.host.json && node --test \"dist/host/test/**/*.test.js\"",
52
52
  "check": "tsc --noEmit && tsc --noEmit -p tsconfig.host.json && node scripts/clean-room-check.mjs && node scripts/host-smoke-test.mjs && npm run test:lib && npm test",
53
- "postinstall": "node -e \"try{require.resolve('wxt')}catch(e){process.exit(0)}\" && wxt prepare"
53
+ "postinstall": "node -e \"try{require.resolve('wxt');require('node:child_process').execSync('wxt prepare',{stdio:'inherit'})}catch(e){}\""
54
54
  },
55
55
  "devDependencies": {
56
56
  "@medv/finder": "4.0.2",