stikfix 1.3.2 → 1.6.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 +53 -7
- package/dist/host/src/bootstrap/register.js +108 -17
- package/dist/host/src/config.js +42 -8
- package/dist/host/src/doctor.js +459 -0
- package/dist/host/src/exe-main.js +254 -0
- package/dist/host/src/git-sync.js +146 -0
- package/dist/host/src/index.js +156 -117
- package/dist/host/src/native-host.js +34 -7
- package/dist/host/src/server.js +23 -3
- package/dist/host/stikfix-init.cjs +61 -28
- package/dist/host/stikfix-native.cjs +17 -2
- package/dist/lib/pin-position.js +69 -0
- package/package.json +8 -1
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stikfix-host.exe — single-executable dispatcher (Node SEA entry).
|
|
3
|
+
*
|
|
4
|
+
* One binary multiplexes every host role by subcommand, plus a native-messaging
|
|
5
|
+
* auto-detect so the SAME exe can be registered directly as the Chrome/Edge
|
|
6
|
+
* native-messaging host (no node + .cjs wrapper needed):
|
|
7
|
+
*
|
|
8
|
+
* stikfix-host serve [--root <dir> ...] → HTTP host (index.ts startHttpHost)
|
|
9
|
+
* stikfix-host native → native-messaging stdio host
|
|
10
|
+
* stikfix-host register [flags] → non-interactive installer wiring
|
|
11
|
+
* stikfix-host uninstall [flags] → remove native host + launchers + config
|
|
12
|
+
* stikfix-host doctor → diagnostics (stub for now)
|
|
13
|
+
* stikfix-host --version|-v → print version
|
|
14
|
+
* stikfix-host --help|-h|(no args) → usage
|
|
15
|
+
*
|
|
16
|
+
* NATIVE AUTO-DETECT: a Chromium browser launches its native host as
|
|
17
|
+
* stikfix-host.exe "chrome-extension://<id>/" --parent-window=<hwnd>
|
|
18
|
+
* so if the first positional looks like a chrome-extension:// origin, or any arg
|
|
19
|
+
* starts with --parent-window, we route to the native handler — never treating
|
|
20
|
+
* the origin as a subcommand.
|
|
21
|
+
*
|
|
22
|
+
* Node builtins + bundled deps only — no WXT, no Chrome imports.
|
|
23
|
+
*/
|
|
24
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { resolve, join, basename } from 'node:path';
|
|
26
|
+
import { homedir } from 'node:os';
|
|
27
|
+
import { parseArgs } from 'node:util';
|
|
28
|
+
import { VERSION } from './config.js';
|
|
29
|
+
import { startHttpHost } from './index.js';
|
|
30
|
+
import { runNativeHost } from './native-host.js';
|
|
31
|
+
import { runDoctor } from './doctor.js';
|
|
32
|
+
import { registerNativeHost, createLauncherFiles, registerStartup, unregisterStartup, teardownHost } from './bootstrap/register.js';
|
|
33
|
+
import { STABLE_EXTENSION_ID } from './extension-id.js';
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Argument extraction — robust across `node script.js` and a Node SEA exe
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* User args, independent of how the process was started. On every path that
|
|
39
|
+
* runs this dispatcher — `node …/exe-main.js`, `node …/bundle.cjs`, and the
|
|
40
|
+
* Node SEA exe (which mirrors execPath into argv[1]) — user args begin at
|
|
41
|
+
* argv[2]. Verified against the built stikfix-host.exe.
|
|
42
|
+
*/
|
|
43
|
+
function getUserArgs() {
|
|
44
|
+
return process.argv.slice(2);
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// register subcommand
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
const CONFIG_DIR = join(homedir(), '.config', 'stikfix');
|
|
50
|
+
const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
51
|
+
/** Default notes root for the installer when --root is omitted. */
|
|
52
|
+
function defaultRoot() {
|
|
53
|
+
if (process.platform === 'win32') {
|
|
54
|
+
return join(homedir(), 'Documents', 'stikfix-notes');
|
|
55
|
+
}
|
|
56
|
+
return join(homedir(), 'stikfix-notes');
|
|
57
|
+
}
|
|
58
|
+
async function runRegister(args) {
|
|
59
|
+
const { values } = parseArgs({
|
|
60
|
+
args,
|
|
61
|
+
options: {
|
|
62
|
+
root: { type: 'string' },
|
|
63
|
+
'extension-id': { type: 'string' },
|
|
64
|
+
port: { type: 'string' },
|
|
65
|
+
startup: { type: 'boolean' },
|
|
66
|
+
'no-startup': { type: 'boolean' },
|
|
67
|
+
'host-exe': { type: 'string' },
|
|
68
|
+
},
|
|
69
|
+
strict: false,
|
|
70
|
+
});
|
|
71
|
+
const hostExe = resolve(typeof values['host-exe'] === 'string' && values['host-exe'] ? values['host-exe'] : process.execPath);
|
|
72
|
+
const root = resolve(typeof values['root'] === 'string' && values['root'] ? values['root'] : defaultRoot());
|
|
73
|
+
const extensionId = typeof values['extension-id'] === 'string' && values['extension-id']
|
|
74
|
+
? values['extension-id']
|
|
75
|
+
: STABLE_EXTENSION_ID;
|
|
76
|
+
const port = typeof values['port'] === 'string' && values['port'] ? parseInt(values['port'], 10) : undefined;
|
|
77
|
+
const name = basename(root);
|
|
78
|
+
const notesDir = join(root, 'notes');
|
|
79
|
+
// Ensure the root exists so START_HOST's directory check passes later.
|
|
80
|
+
mkdirSync(root, { recursive: true });
|
|
81
|
+
// (a) config.json — hostExe drives START_HOST/launchers to run the exe `serve`.
|
|
82
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
83
|
+
const config = {
|
|
84
|
+
root,
|
|
85
|
+
name,
|
|
86
|
+
notesDir,
|
|
87
|
+
hostEntry: hostExe,
|
|
88
|
+
nodePath: hostExe,
|
|
89
|
+
hostExe,
|
|
90
|
+
};
|
|
91
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
92
|
+
// (b) native-messaging manifest points DIRECTLY at the exe (self-detects native mode).
|
|
93
|
+
registerNativeHost({ extensionId, hostBinPath: hostExe, directPath: true });
|
|
94
|
+
// (c) desktop launcher runs `<exe> serve --root <root>`.
|
|
95
|
+
const launcherResult = createLauncherFiles({
|
|
96
|
+
hostEntryPath: hostExe,
|
|
97
|
+
root,
|
|
98
|
+
port,
|
|
99
|
+
hostExe,
|
|
100
|
+
});
|
|
101
|
+
// (d) Windows login autoload per flags (Windows-only; no-op elsewhere).
|
|
102
|
+
let startupRegistered = false;
|
|
103
|
+
const wantStartup = values['startup'] === true && values['no-startup'] !== true;
|
|
104
|
+
if (process.platform === 'win32') {
|
|
105
|
+
if (wantStartup) {
|
|
106
|
+
try {
|
|
107
|
+
registerStartup();
|
|
108
|
+
startupRegistered = true;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
startupRegistered = false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
try {
|
|
116
|
+
unregisterStartup();
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// idempotent
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
console.log(JSON.stringify({
|
|
124
|
+
ok: true,
|
|
125
|
+
action: 'register',
|
|
126
|
+
hostExe,
|
|
127
|
+
root,
|
|
128
|
+
notesDir,
|
|
129
|
+
extensionId,
|
|
130
|
+
port: port ?? null,
|
|
131
|
+
configPath: CONFIG_PATH,
|
|
132
|
+
launchersWritten: launcherResult.written,
|
|
133
|
+
launcherWarnings: launcherResult.warnings,
|
|
134
|
+
startupRegistered,
|
|
135
|
+
}));
|
|
136
|
+
}
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
// uninstall subcommand
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
/**
|
|
141
|
+
* Mirror bin/stikfix.ts's `uninstall` teardown exactly, via the SAME shared
|
|
142
|
+
* `teardownHost` helper (host/src/bootstrap/register.ts) — so the exe's
|
|
143
|
+
* uninstall route is equivalent: native-messaging manifest + HKCU registry
|
|
144
|
+
* keys removed, unregisterStartup() called, launcher files/config removed.
|
|
145
|
+
* Idempotent — a missing key/file is fine (teardownHost tolerates it).
|
|
146
|
+
*
|
|
147
|
+
* --extension-id / --root are accepted (teardown doesn't need either to
|
|
148
|
+
* locate the manifest/registry keys — those are derived from home + browser)
|
|
149
|
+
* and echoed back in the JSON result for operator visibility.
|
|
150
|
+
*/
|
|
151
|
+
async function runUninstall(args) {
|
|
152
|
+
const { values } = parseArgs({
|
|
153
|
+
args,
|
|
154
|
+
options: {
|
|
155
|
+
'extension-id': { type: 'string' },
|
|
156
|
+
root: { type: 'string' },
|
|
157
|
+
},
|
|
158
|
+
strict: false,
|
|
159
|
+
});
|
|
160
|
+
const extensionId = typeof values['extension-id'] === 'string' && values['extension-id']
|
|
161
|
+
? values['extension-id']
|
|
162
|
+
: null;
|
|
163
|
+
const root = typeof values['root'] === 'string' && values['root']
|
|
164
|
+
? resolve(values['root'])
|
|
165
|
+
: null;
|
|
166
|
+
const result = teardownHost({});
|
|
167
|
+
console.log(JSON.stringify({
|
|
168
|
+
ok: true,
|
|
169
|
+
action: 'uninstall',
|
|
170
|
+
extensionId,
|
|
171
|
+
root,
|
|
172
|
+
manifestRemoved: result.manifestRemoved,
|
|
173
|
+
manifestError: result.manifestError ?? null,
|
|
174
|
+
startupError: result.startupError ?? null,
|
|
175
|
+
configPath: result.configPath,
|
|
176
|
+
configRemoved: result.configRemoved,
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
// usage
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
function printUsage() {
|
|
183
|
+
console.log([
|
|
184
|
+
'stikfix-host ' + VERSION,
|
|
185
|
+
'',
|
|
186
|
+
'Usage: stikfix-host <command> [options]',
|
|
187
|
+
'',
|
|
188
|
+
'Commands:',
|
|
189
|
+
' serve [--root <dir>] [--port <n>] [--git-sync] Start the HTTP host',
|
|
190
|
+
' native Native-messaging stdio host',
|
|
191
|
+
' register [--root <dir>] [--extension-id <id>] Register the native host + launchers',
|
|
192
|
+
' [--port <n>] [--startup|--no-startup]',
|
|
193
|
+
' [--host-exe <path>]',
|
|
194
|
+
' uninstall [--extension-id <id>] [--root <dir>] Remove the native host + launchers + config',
|
|
195
|
+
' doctor Print environment diagnostics',
|
|
196
|
+
'',
|
|
197
|
+
'Options:',
|
|
198
|
+
' -v, --version Print version and exit',
|
|
199
|
+
' -h, --help Print this help and exit',
|
|
200
|
+
'',
|
|
201
|
+
'Note: when launched by a Chromium browser as a native-messaging host',
|
|
202
|
+
' (first arg chrome-extension://…, or --parent-window=…), the exe',
|
|
203
|
+
' auto-detects native mode regardless of subcommand.',
|
|
204
|
+
].join('\n'));
|
|
205
|
+
}
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// dispatch
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
async function main() {
|
|
210
|
+
const args = getUserArgs();
|
|
211
|
+
// NATIVE-MODE AUTO-DETECT (must be first): a Chromium browser invokes
|
|
212
|
+
// stikfix-host.exe "chrome-extension://<id>/" --parent-window=<hwnd>
|
|
213
|
+
const first = args[0] ?? '';
|
|
214
|
+
const looksNative = /^chrome-extension:\/\//i.test(first) || args.some((a) => a.startsWith('--parent-window'));
|
|
215
|
+
if (looksNative) {
|
|
216
|
+
await runNativeHost();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const cmd = args[0];
|
|
220
|
+
switch (cmd) {
|
|
221
|
+
case 'serve':
|
|
222
|
+
await startHttpHost(args.slice(1));
|
|
223
|
+
return;
|
|
224
|
+
case 'native':
|
|
225
|
+
await runNativeHost();
|
|
226
|
+
return;
|
|
227
|
+
case 'register':
|
|
228
|
+
await runRegister(args.slice(1));
|
|
229
|
+
return;
|
|
230
|
+
case 'uninstall':
|
|
231
|
+
await runUninstall(args.slice(1));
|
|
232
|
+
return;
|
|
233
|
+
case 'doctor':
|
|
234
|
+
await runDoctor(args.slice(1));
|
|
235
|
+
return;
|
|
236
|
+
case '--version':
|
|
237
|
+
case '-v':
|
|
238
|
+
console.log(VERSION);
|
|
239
|
+
return;
|
|
240
|
+
case '--help':
|
|
241
|
+
case '-h':
|
|
242
|
+
case undefined:
|
|
243
|
+
printUsage();
|
|
244
|
+
return;
|
|
245
|
+
default:
|
|
246
|
+
console.error(`stikfix-host: unknown command "${cmd}"`);
|
|
247
|
+
printUsage();
|
|
248
|
+
process.exit(1);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
main().catch((err) => {
|
|
252
|
+
console.error(err instanceof Error ? (err.stack ?? err.message) : String(err));
|
|
253
|
+
process.exit(1);
|
|
254
|
+
});
|
|
@@ -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
|
+
}
|