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 +26 -0
- package/dist/host/src/config.js +19 -1
- package/dist/host/src/git-sync.js +146 -0
- package/dist/host/src/index.js +1 -0
- package/dist/host/src/server.js +23 -3
- package/package.json +2 -2
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
|

|
package/dist/host/src/config.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/host/src/index.js
CHANGED
package/dist/host/src/server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"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){
|
|
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",
|