gigarag-cursor 0.1.2 → 0.2.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.
- package/.cursor-plugin/plugin.json +1 -1
- package/cli/auth/account.js +106 -0
- package/cli/auth/credentials.js +125 -16
- package/cli/binding.js +117 -0
- package/cli/cli.js +10 -0
- package/cli/commands/authHeader.js +5 -8
- package/cli/commands/connect.js +4 -2
- package/cli/commands/gitHooks.js +367 -0
- package/cli/commands/indexSync.js +9 -5
- package/cli/commands/login.js +47 -12
- package/cli/commands/mcp.js +10 -1
- package/cli/commands/repo.js +1 -0
- package/cli/commands/scan.js +21 -1
- package/cli/commands/status.js +33 -15
- package/cli/commands/sync.js +223 -0
- package/cli/commands/trust.js +31 -0
- package/cli/commands/use.js +81 -0
- package/cli/commands/workspaces.js +40 -0
- package/cli/git/git.js +187 -0
- package/cli/git/hookScript.js +83 -0
- package/cli/hooks.js +32 -15
- package/cli/mcp/client.js +4 -0
- package/cli/mcp/session.js +66 -7
- package/cli/package.json +1 -1
- package/cli/prompts.generated.js +2 -2
- package/cli/scan/repo.js +16 -2
- package/cli/scan/scan.js +1 -1
- package/cli/state.js +71 -11
- package/cli/sync/notes.js +50 -0
- package/cli/sync/plan.js +76 -0
- package/cli/sync/remote.js +72 -0
- package/cli/sync/rewriter.js +125 -0
- package/cli/sync/slug.js +30 -0
- package/cli/sync/stage.js +323 -0
- package/cli/sync/target.js +14 -0
- package/cli/sync/worker.js +159 -0
- package/package.json +1 -1
- package/skills/gigaindex/SKILL.md +6 -3
- package/skills/gigasync/SKILL.md +6 -3
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { parseArgs } from 'node:util';
|
|
4
|
+
import { resolveLauncher } from '../clients/launcher.js';
|
|
5
|
+
import { git, gitInfoExcludeFile, hooksDir, isGitRepo } from '../git/git.js';
|
|
6
|
+
import { HOOKS, classify, hasBlock, renderBlock, withBlock, withoutBlock } from '../git/hookScript.js';
|
|
7
|
+
import { findRepoRoot } from '../scan/repo.js';
|
|
8
|
+
import { State } from '../state.js';
|
|
9
|
+
import { err, out, table } from '../ui.js';
|
|
10
|
+
/**
|
|
11
|
+
* The hooks directory git reports resolves outside both `.git` and the work tree: usually a
|
|
12
|
+
* global `core.hooksPath` shared by every repository on the machine. Installing there would start
|
|
13
|
+
* a worker for all of them, and uninstalling here would remove it for all of them too, so `target`
|
|
14
|
+
* refuses instead.
|
|
15
|
+
*/
|
|
16
|
+
export class HooksOutsideRepoError extends Error {
|
|
17
|
+
}
|
|
18
|
+
const HELP = `Usage: gigarag hooks install|uninstall|status [path]
|
|
19
|
+
|
|
20
|
+
Git hooks that keep GigaRAG in step with this repository. After a commit, merge, checkout or
|
|
21
|
+
rebase on the branch GigaRAG syncs, they start gigarag sync in the background. They never slow
|
|
22
|
+
or fail git. /gigaindex installs them.
|
|
23
|
+
|
|
24
|
+
install Add our lines to post-commit, post-merge, post-checkout and post-rewrite
|
|
25
|
+
uninstall Remove only our lines, and any hook file that held nothing else
|
|
26
|
+
status Which hooks carry our lines`;
|
|
27
|
+
/** The argv prefix the hook runs, or undefined when only a gigarag on PATH will do (npx is too slow for a hook). */
|
|
28
|
+
function command() {
|
|
29
|
+
const launcher = resolveLauncher();
|
|
30
|
+
if (launcher.kind === 'npx')
|
|
31
|
+
return undefined;
|
|
32
|
+
return [launcher.command, ...launcher.args.filter(a => a !== 'mcp')];
|
|
33
|
+
}
|
|
34
|
+
function read(file) {
|
|
35
|
+
try {
|
|
36
|
+
return readFileSync(file, 'utf8');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Temporary name then rename, like every config write here, so a crash never leaves half a hook. */
|
|
43
|
+
function writeHook(file, text) {
|
|
44
|
+
const tmpFile = `${file}.gigarag-tmp`;
|
|
45
|
+
writeFileSync(tmpFile, text, { mode: 0o755 });
|
|
46
|
+
try {
|
|
47
|
+
chmodSync(tmpFile, 0o755);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* Windows */
|
|
51
|
+
}
|
|
52
|
+
renameSync(tmpFile, file);
|
|
53
|
+
}
|
|
54
|
+
function target(root) {
|
|
55
|
+
const found = hooksDir(root);
|
|
56
|
+
if (!found)
|
|
57
|
+
throw new Error('unreachable: isGitRepo was checked');
|
|
58
|
+
if (!found.insideGitDir && !found.insideWorkTree) {
|
|
59
|
+
throw new HooksOutsideRepoError(`This repository's hooks directory (${found.dir}) is outside both .git and the work tree, which is what a global core.hooksPath looks like: it is shared by every repository on this machine, so installing here would start GigaRAG's background sync for all of them, and gigarag hooks uninstall here would remove it for all of them too. Give this repository its own hooks path first, for example git config --local core.hooksPath .githooks (or git config --unset core.hooksPath to fall back to .git/hooks), then run gigarag hooks install again.`);
|
|
60
|
+
}
|
|
61
|
+
mkdirSync(found.dir, { recursive: true });
|
|
62
|
+
return { dir: found.dir, insideWorkTree: found.insideWorkTree };
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Whether a hook file is already committed. Only meaningful inside the work tree (`.husky` or a
|
|
66
|
+
* repo-relative `core.hooksPath`): `.git/hooks` is never tracked. A team that already committed
|
|
67
|
+
* its own copy of the file, husky's included, is common, and editing it in place would put this
|
|
68
|
+
* machine's absolute launcher path into whatever a plain `git commit -a` ships next, to every
|
|
69
|
+
* teammate who pulls it, none of whom ran `gigarag hooks install` themselves.
|
|
70
|
+
*/
|
|
71
|
+
function isTracked(root, file) {
|
|
72
|
+
const rel = relative(root, file).split(sep).join('/');
|
|
73
|
+
return git(root, ['ls-files', '--error-unmatch', '--', rel]).ok;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The repository's top level, canonicalised so install, uninstall and the hook-mode check all
|
|
77
|
+
* land on the same key regardless of which of the repository's directories each was given:
|
|
78
|
+
* `realpathSync` follows symlinks the way git itself would, and the result is lowercased on
|
|
79
|
+
* Windows, where two paths differing only in a drive letter's or a directory's case name the same
|
|
80
|
+
* checkout. Falls back to a plain `resolve` when the path doesn't exist (nothing to realpath) or
|
|
81
|
+
* isn't inside a repository at all, so this never throws.
|
|
82
|
+
*/
|
|
83
|
+
function repoTopLevel(root) {
|
|
84
|
+
const { root: top } = findRepoRoot(resolve(root));
|
|
85
|
+
let real;
|
|
86
|
+
try {
|
|
87
|
+
real = realpathSync.native(top);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
real = top;
|
|
91
|
+
}
|
|
92
|
+
return process.platform === 'win32' ? real.toLowerCase() : real;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The key under which this machine records that a person ran `gigarag hooks install` in this
|
|
96
|
+
* repository. Hook mode reads it through `hooksOptedIn` before doing anything, instead of
|
|
97
|
+
* trusting the bucket's shared `initialised` flag, because that flag is the same for every
|
|
98
|
+
* teammate: a hook file that reaches a machine without this person ever running install here (
|
|
99
|
+
* checked into git, or copied from a teammate) must not start a background sync on their behalf.
|
|
100
|
+
* Keyed on the repository's top level (see `repoTopLevel`), never on whatever directory install,
|
|
101
|
+
* uninstall or the hook itself happened to run from: installing from a subfolder, and a hook that
|
|
102
|
+
* always runs at the root, must agree on one opt-in, not keep two.
|
|
103
|
+
*/
|
|
104
|
+
export function hooksOptInKey(root) {
|
|
105
|
+
return `hooks_opt_in:${repoTopLevel(root)}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The pre-fix key: the raw, unresolved-to-the-top-level `resolve(root)`, with no realpath and no
|
|
109
|
+
* case-folding. `hooksOptedIn` still reads it for a machine that opted in before this fix, and
|
|
110
|
+
* both it and `setHooksOptIn` clear it once it's no longer needed, so it can't migrate an opt-in
|
|
111
|
+
* back after an uninstall has cleared the normalised key.
|
|
112
|
+
*/
|
|
113
|
+
function legacyHooksOptInKey(root) {
|
|
114
|
+
return `hooks_opt_in:${resolve(root)}`;
|
|
115
|
+
}
|
|
116
|
+
function withState(state, fn) {
|
|
117
|
+
if (state)
|
|
118
|
+
return fn(state);
|
|
119
|
+
const s = new State();
|
|
120
|
+
try {
|
|
121
|
+
return fn(s);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
s.close();
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** Whether this machine ran `gigarag hooks install` (directly, or through `sync end --init`) in
|
|
128
|
+
* this checkout. See `hooksOptInKey`.
|
|
129
|
+
*
|
|
130
|
+
* Also accepts the key a pre-fix install left behind (`legacyHooksOptInKey`). From the repository
|
|
131
|
+
* root, which is where install overwhelmingly ran, that legacy key is identical to today's for a
|
|
132
|
+
* plain `.git/hooks` checkout on a case-preserving path, so a machine that opted in before this
|
|
133
|
+
* fix keeps working without re-running install. Found once, it's copied onto the new key and then
|
|
134
|
+
* deleted, so every check after this one is a single read, and so a later `uninstall`, which only
|
|
135
|
+
* clears the normalised key, can't have this migration bring the opt-in straight back afterwards. */
|
|
136
|
+
export function hooksOptedIn(root, state) {
|
|
137
|
+
try {
|
|
138
|
+
return withState(state, s => {
|
|
139
|
+
const key = hooksOptInKey(root);
|
|
140
|
+
if (s.getMeta(key) === '1')
|
|
141
|
+
return true;
|
|
142
|
+
const legacyKey = legacyHooksOptInKey(root);
|
|
143
|
+
if (legacyKey !== key && s.getMeta(legacyKey) === '1') {
|
|
144
|
+
s.setMeta(key, '1');
|
|
145
|
+
s.setMeta(legacyKey, '');
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Clearing must reach the legacy key too, or `hooksOptedIn` migrates a pre-fix opt-in straight
|
|
157
|
+
* back the next time anything checks it: a machine that opted in before this fix, and never wrote
|
|
158
|
+
* the normalised key any other way, would otherwise survive `uninstall`.
|
|
159
|
+
*/
|
|
160
|
+
function setHooksOptIn(root, value, state) {
|
|
161
|
+
try {
|
|
162
|
+
withState(state, s => {
|
|
163
|
+
s.setMeta(hooksOptInKey(root), value ? '1' : '');
|
|
164
|
+
if (!value) {
|
|
165
|
+
const legacyKey = legacyHooksOptInKey(root);
|
|
166
|
+
if (legacyKey !== hooksOptInKey(root))
|
|
167
|
+
s.setMeta(legacyKey, '');
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
/* a machine-local convenience; never a reason to fail install or uninstall */
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Keeps our own hook files, once they live inside the work tree (`.husky` or a repo-relative
|
|
177
|
+
* core.hooksPath), out of `git status` for everyone who runs it directly, not only our own
|
|
178
|
+
* isClean check. Best effort: a courtesy, never a reason to fail an install.
|
|
179
|
+
*/
|
|
180
|
+
function excludeFromGitStatus(root, dir, hooks) {
|
|
181
|
+
if (hooks.length === 0)
|
|
182
|
+
return;
|
|
183
|
+
try {
|
|
184
|
+
const file = gitInfoExcludeFile(root);
|
|
185
|
+
if (!file)
|
|
186
|
+
return;
|
|
187
|
+
const existing = read(file) ?? '';
|
|
188
|
+
const already = new Set(existing.split(/\r?\n/).filter(Boolean));
|
|
189
|
+
const rels = hooks.map(h => relative(root, join(dir, h)).split(sep).join('/'));
|
|
190
|
+
const missing = rels.filter(r => !already.has(r));
|
|
191
|
+
if (missing.length === 0)
|
|
192
|
+
return;
|
|
193
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
194
|
+
const spacer = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
|
|
195
|
+
writeFileSync(file, `${existing}${spacer}# added by gigarag hooks install\n${missing.join('\n')}\n`);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
/* a courtesy for a plain `git status`; never a reason to fail install */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
export function installHooks(root, onlyWhereInstalled = false, state) {
|
|
202
|
+
const { dir, insideWorkTree } = target(root);
|
|
203
|
+
const argv = command();
|
|
204
|
+
const reports = HOOKS.map(hook => {
|
|
205
|
+
const file = join(dir, hook);
|
|
206
|
+
if (insideWorkTree && isTracked(root, file))
|
|
207
|
+
return { hook, file, result: 'tracked' };
|
|
208
|
+
const existing = read(file);
|
|
209
|
+
if (onlyWhereInstalled && !(existing && hasBlock(existing)))
|
|
210
|
+
return { hook, file, result: 'absent' };
|
|
211
|
+
const kind = classify(existing);
|
|
212
|
+
if (kind === 'foreign')
|
|
213
|
+
return { hook, file, result: 'foreign' };
|
|
214
|
+
const next = withBlock(existing, renderBlock(hook, argv));
|
|
215
|
+
if (next === existing)
|
|
216
|
+
return { hook, file, result: 'unchanged' };
|
|
217
|
+
writeHook(file, next);
|
|
218
|
+
return { hook, file, result: kind === 'missing' ? 'written' : 'updated' };
|
|
219
|
+
});
|
|
220
|
+
if (insideWorkTree) {
|
|
221
|
+
excludeFromGitStatus(root, dir, reports.filter(r => r.result === 'written' || r.result === 'updated' || r.result === 'unchanged').map(r => r.hook));
|
|
222
|
+
}
|
|
223
|
+
// Recorded only when install actually put our block in a file it controls, never merely for
|
|
224
|
+
// having been run: an interactive `sync begin` calls this (through refreshHooks) before every
|
|
225
|
+
// /gigasync, and a virgin machine where nothing is installed yet must not opt itself in that way.
|
|
226
|
+
if (reports.some(r => r.result === 'written' || r.result === 'updated' || r.result === 'unchanged')) {
|
|
227
|
+
setHooksOptIn(root, true, state);
|
|
228
|
+
}
|
|
229
|
+
return reports;
|
|
230
|
+
}
|
|
231
|
+
/** Rewrites our block where it already is, so a moved CLI path heals. Never installs where it was removed. */
|
|
232
|
+
export function refreshHooks(root, state) {
|
|
233
|
+
try {
|
|
234
|
+
if (isGitRepo(root))
|
|
235
|
+
installHooks(root, true, state);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
/* a refresh is a courtesy, never a reason to stop a sync */
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The reverse of `excludeFromGitStatus`: drops the line for each hook file uninstall removed, but
|
|
243
|
+
* only the lines that sit under our own `# added by gigarag hooks install` marker, so anything a
|
|
244
|
+
* person or another tool put in this file, before or after that marker, is left exactly as it was.
|
|
245
|
+
* A courtesy, like the install side: never a reason to fail uninstall.
|
|
246
|
+
*/
|
|
247
|
+
function unexcludeFromGitStatus(root, dir, hooks) {
|
|
248
|
+
if (hooks.length === 0)
|
|
249
|
+
return;
|
|
250
|
+
try {
|
|
251
|
+
const file = gitInfoExcludeFile(root);
|
|
252
|
+
if (!file)
|
|
253
|
+
return;
|
|
254
|
+
const existing = read(file);
|
|
255
|
+
if (!existing)
|
|
256
|
+
return;
|
|
257
|
+
const rels = new Set(hooks.map(h => relative(root, join(dir, h)).split(sep).join('/')));
|
|
258
|
+
const lines = existing.split(/\r?\n/);
|
|
259
|
+
const markerAt = lines.findIndex(l => l.trim() === '# added by gigarag hooks install');
|
|
260
|
+
if (markerAt === -1)
|
|
261
|
+
return;
|
|
262
|
+
const kept = lines.filter((line, i) => i <= markerAt || !rels.has(line));
|
|
263
|
+
const next = kept.join('\n');
|
|
264
|
+
if (next !== existing)
|
|
265
|
+
writeFileSync(file, next);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
/* a courtesy for a plain `git status`; never a reason to fail uninstall */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
export function uninstallHooks(root, state) {
|
|
272
|
+
const { dir, insideWorkTree } = target(root);
|
|
273
|
+
const reports = HOOKS.map(hook => {
|
|
274
|
+
const file = join(dir, hook);
|
|
275
|
+
if (insideWorkTree && isTracked(root, file))
|
|
276
|
+
return { hook, file, result: 'tracked' };
|
|
277
|
+
const existing = read(file);
|
|
278
|
+
if (!existing || !hasBlock(existing))
|
|
279
|
+
return { hook, file, result: 'absent' };
|
|
280
|
+
const next = withoutBlock(existing);
|
|
281
|
+
if (next === undefined)
|
|
282
|
+
unlinkSync(file);
|
|
283
|
+
else
|
|
284
|
+
writeHook(file, next);
|
|
285
|
+
return { hook, file, result: 'removed' };
|
|
286
|
+
});
|
|
287
|
+
unexcludeFromGitStatus(root, dir, reports.filter(r => r.result === 'removed').map(r => r.hook));
|
|
288
|
+
setHooksOptIn(root, false, state);
|
|
289
|
+
return reports;
|
|
290
|
+
}
|
|
291
|
+
export function hookStatus(root) {
|
|
292
|
+
const { dir } = target(root);
|
|
293
|
+
return HOOKS.map(hook => {
|
|
294
|
+
const file = join(dir, hook);
|
|
295
|
+
const existing = read(file);
|
|
296
|
+
if (existing && hasBlock(existing))
|
|
297
|
+
return { hook, file, result: 'installed' };
|
|
298
|
+
return { hook, file, result: classify(existing) === 'foreign' ? 'foreign' : 'absent' };
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const LABEL = {
|
|
302
|
+
written: 'written',
|
|
303
|
+
updated: 'updated',
|
|
304
|
+
unchanged: 'unchanged',
|
|
305
|
+
foreign: 'not ours',
|
|
306
|
+
tracked: 'tracked',
|
|
307
|
+
removed: 'removed',
|
|
308
|
+
absent: 'absent',
|
|
309
|
+
installed: 'installed',
|
|
310
|
+
};
|
|
311
|
+
export async function hooks(argv) {
|
|
312
|
+
const { values, positionals } = parseArgs({ args: argv, allowPositionals: true, options: { help: { type: 'boolean', short: 'h' } } });
|
|
313
|
+
const [action, path] = positionals;
|
|
314
|
+
if (values.help || !action || !['install', 'uninstall', 'status'].includes(action)) {
|
|
315
|
+
out(HELP);
|
|
316
|
+
return action ? 2 : 0;
|
|
317
|
+
}
|
|
318
|
+
const root = resolve(path ?? '.');
|
|
319
|
+
if (!isGitRepo(root)) {
|
|
320
|
+
err(`${root} is not inside a git repository, so there is nothing to hook. Run this from the repository you indexed.`);
|
|
321
|
+
return 1;
|
|
322
|
+
}
|
|
323
|
+
let reports;
|
|
324
|
+
const state = new State();
|
|
325
|
+
try {
|
|
326
|
+
try {
|
|
327
|
+
reports = action === 'install' ? installHooks(root, false, state) : action === 'uninstall' ? uninstallHooks(root, state) : hookStatus(root);
|
|
328
|
+
}
|
|
329
|
+
catch (e) {
|
|
330
|
+
if (e instanceof HooksOutsideRepoError) {
|
|
331
|
+
err(e.message);
|
|
332
|
+
return 1;
|
|
333
|
+
}
|
|
334
|
+
throw e;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
state.close();
|
|
339
|
+
}
|
|
340
|
+
for (const line of table(reports.map(r => [r.hook, LABEL[r.result], r.file])))
|
|
341
|
+
out(line);
|
|
342
|
+
const foreign = reports.filter(r => r.result === 'foreign');
|
|
343
|
+
if (action === 'install' && foreign.length > 0) {
|
|
344
|
+
out('');
|
|
345
|
+
out('These hooks are not shell scripts, so they were left as they are. To sync after them too, add the');
|
|
346
|
+
out('matching lines below to each one yourself, or call them from it:');
|
|
347
|
+
for (const r of foreign) {
|
|
348
|
+
out('');
|
|
349
|
+
out(`${r.file}:`);
|
|
350
|
+
out(renderBlock(r.hook, command()));
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const tracked = reports.filter(r => r.result === 'tracked');
|
|
354
|
+
if (tracked.length > 0) {
|
|
355
|
+
out('');
|
|
356
|
+
out('These hook files are already tracked by git, so editing them was left alone: a plain git commit -a would');
|
|
357
|
+
out('ship this machine\'s own path into a file every teammate who pulls it gets, whether or not they run');
|
|
358
|
+
out('gigarag hooks install themselves. Add the lines below by hand if this repository wants that, and commit');
|
|
359
|
+
out('them once everyone who pulls this branch has agreed to it:');
|
|
360
|
+
for (const r of tracked) {
|
|
361
|
+
out('');
|
|
362
|
+
out(`${r.file}:`);
|
|
363
|
+
out(renderBlock(r.hook, command()));
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return 0;
|
|
367
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
1
2
|
import { GigaRag } from '../sdk.js';
|
|
2
3
|
import { State } from '../state.js';
|
|
3
4
|
const MAX_NODES = 5000;
|
|
@@ -8,7 +9,9 @@ const LOCK_MS = 2 * 60_000;
|
|
|
8
9
|
* so it is what makes memos saved from other clients, a chat app or another
|
|
9
10
|
* machine, show up in the next session. Runs detached from a hook, never in one.
|
|
10
11
|
*/
|
|
11
|
-
export async function indexSync() {
|
|
12
|
+
export async function indexSync(argv = []) {
|
|
13
|
+
const { values } = parseArgs({ args: argv, options: { workspace: { type: 'string' } } });
|
|
14
|
+
const workspace = values.workspace;
|
|
12
15
|
const state = new State();
|
|
13
16
|
let held = false;
|
|
14
17
|
try {
|
|
@@ -16,7 +19,7 @@ export async function indexSync() {
|
|
|
16
19
|
held = state.tryAcquire('sync_lock', LOCK_MS);
|
|
17
20
|
if (!held)
|
|
18
21
|
return 0;
|
|
19
|
-
const rag = new GigaRag();
|
|
22
|
+
const rag = new GigaRag(workspace ? { workspaceId: workspace } : {});
|
|
20
23
|
const rows = [];
|
|
21
24
|
for await (const n of rag.iterateNodes({ max: MAX_NODES })) {
|
|
22
25
|
rows.push({
|
|
@@ -26,16 +29,17 @@ export async function indexSync() {
|
|
|
26
29
|
type: n.node_type ?? null,
|
|
27
30
|
tokens: null,
|
|
28
31
|
touched_at: Date.parse(n.updated_at ?? '') || 0,
|
|
32
|
+
stale: n.stale ? 1 : 0,
|
|
29
33
|
});
|
|
30
34
|
}
|
|
31
|
-
state.replaceNodes(rows);
|
|
32
|
-
state.setMeta(
|
|
35
|
+
state.replaceNodes(rows, workspace ?? '');
|
|
36
|
+
state.setMeta(`last_sync:${workspace ?? ''}`, String(Date.now()));
|
|
33
37
|
return 0;
|
|
34
38
|
}
|
|
35
39
|
catch {
|
|
36
40
|
// Silent on purpose: nobody is watching a detached worker. The attempt is recorded so a machine
|
|
37
41
|
// that cannot sync is not retried by every session start and every Stop.
|
|
38
|
-
state.setMeta(
|
|
42
|
+
state.setMeta(`last_attempt:${workspace ?? ''}`, String(Date.now()));
|
|
39
43
|
return 1;
|
|
40
44
|
}
|
|
41
45
|
finally {
|
package/cli/commands/login.js
CHANGED
|
@@ -1,23 +1,26 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { updateConfig, readConfig } from '../config.js';
|
|
3
3
|
import { DEFAULT_ISSUER } from '../constants.js';
|
|
4
|
-
import { clearCredential, envKey, looksLikeAgentKey, mask, saveCredential } from '../auth/credentials.js';
|
|
4
|
+
import { ACCOUNT_SLOT, clearCredential, clearWorkspaceSlots, envKey, looksLikeAgentKey, mask, saveCredential, workspaceSlot, writeSlot } from '../auth/credentials.js';
|
|
5
5
|
import { runOAuthLogin } from '../auth/oauth.js';
|
|
6
|
+
import { listAccountWorkspaces } from '../auth/account.js';
|
|
6
7
|
import { GigaRag, McpHttpError } from '../sdk.js';
|
|
7
8
|
import { assertSecureUrl } from '../secureUrl.js';
|
|
8
9
|
import { err, out, promptHidden, readStdinLine, UsageError } from '../ui.js';
|
|
9
|
-
const HELP = `Usage: gigarag login [--paste] [--key <key>] [--url <mcp url>] [--no-verify]
|
|
10
|
+
const HELP = `Usage: gigarag login [--paste] [--key <key>] [--workspace <id>] [--url <mcp url>] [--no-verify]
|
|
10
11
|
|
|
11
12
|
Signs this machine in to GigaRAG. With no flags it opens your browser, and nothing
|
|
12
13
|
holds a key afterwards: the gateway swaps your sign-in for one behind the scenes.
|
|
13
14
|
|
|
14
15
|
Piped input still takes a key, so scripts keep working: echo $KEY | gigarag login
|
|
15
16
|
|
|
16
|
-
--paste
|
|
17
|
-
--browser
|
|
18
|
-
--key <key>
|
|
19
|
-
--
|
|
20
|
-
--
|
|
17
|
+
--paste Paste an agent key instead of opening a browser
|
|
18
|
+
--browser Open the browser. The default, accepted so older scripts still run
|
|
19
|
+
--key <key> Give the key on the command line (it will sit in your shell history)
|
|
20
|
+
--workspace <id> Store the key for that workspace (with --key or --paste)
|
|
21
|
+
--url <url> Use a different MCP endpoint
|
|
22
|
+
--no-verify Store the key without checking it against GigaRAG first`;
|
|
23
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
21
24
|
export async function login(argv) {
|
|
22
25
|
const { values } = parseArgs({
|
|
23
26
|
args: argv,
|
|
@@ -25,6 +28,7 @@ export async function login(argv) {
|
|
|
25
28
|
browser: { type: 'boolean' },
|
|
26
29
|
paste: { type: 'boolean' },
|
|
27
30
|
key: { type: 'string' },
|
|
31
|
+
workspace: { type: 'string' },
|
|
28
32
|
url: { type: 'string' },
|
|
29
33
|
issuer: { type: 'string' },
|
|
30
34
|
'no-verify': { type: 'boolean' },
|
|
@@ -98,7 +102,12 @@ export async function login(argv) {
|
|
|
98
102
|
return 1;
|
|
99
103
|
}
|
|
100
104
|
}
|
|
101
|
-
|
|
105
|
+
if (values.workspace && !UUID.test(values.workspace)) {
|
|
106
|
+
throw new UsageError(`--workspace wants a workspace id, not "${values.workspace}". Find it with: gigarag workspaces`);
|
|
107
|
+
}
|
|
108
|
+
const where = values.workspace
|
|
109
|
+
? writeSlot(workspaceSlot(values.workspace.toLowerCase()), { type: 'key', key })
|
|
110
|
+
: saveCredential({ type: 'key', key });
|
|
102
111
|
updateConfig(c => void (c.auth = 'key'));
|
|
103
112
|
out(`Connected. ${mask(key)} is stored in ${where}.`);
|
|
104
113
|
out('Next: gigarag connect');
|
|
@@ -107,19 +116,42 @@ export async function login(argv) {
|
|
|
107
116
|
async function browserLogin(issuer, mcpUrl) {
|
|
108
117
|
try {
|
|
109
118
|
const config = readConfig();
|
|
119
|
+
// The account scope: one sign-in that lists every workspace and is traded
|
|
120
|
+
// for a token per workspace, instead of a grant tied to one workspace. Its
|
|
121
|
+
// tokens are for /api/cli, and the app refuses them at /mcp.
|
|
110
122
|
const result = await runOAuthLogin({
|
|
111
123
|
issuer,
|
|
112
|
-
|
|
124
|
+
scope: 'account offline_access',
|
|
125
|
+
resource: `${issuer.replace(/\/$/, '')}/api/cli`,
|
|
113
126
|
clientId: config.issuer === issuer ? config.oauthClientId : undefined,
|
|
114
127
|
log: line => out(line),
|
|
115
128
|
});
|
|
116
|
-
|
|
129
|
+
// A new account sign-in never keeps the previous one's workspace tokens: each
|
|
130
|
+
// slot is keyed by workspace id, and a token minted for another person's
|
|
131
|
+
// account must not be presented as this one's, or a repository bound to a
|
|
132
|
+
// workspace id the old account reached would silently work again.
|
|
133
|
+
clearWorkspaceSlots();
|
|
134
|
+
const where = writeSlot(ACCOUNT_SLOT, result.credential);
|
|
117
135
|
updateConfig(c => {
|
|
118
136
|
c.auth = 'oauth';
|
|
119
137
|
c.issuer = issuer;
|
|
120
138
|
c.oauthClientId = result.clientId;
|
|
139
|
+
if (mcpUrl)
|
|
140
|
+
c.mcpUrl = mcpUrl;
|
|
121
141
|
});
|
|
122
|
-
out(`
|
|
142
|
+
out(`Signed in through your browser. The sign-in is stored in ${where}.`);
|
|
143
|
+
try {
|
|
144
|
+
const workspaces = await listAccountWorkspaces({ issuer });
|
|
145
|
+
const own = workspaces.find(w => w.access_via === 'owner' && w.is_default) ?? workspaces[0];
|
|
146
|
+
if (own && !readConfig().workspaceId)
|
|
147
|
+
updateConfig(c => void (c.workspaceId = own.id));
|
|
148
|
+
out(`You can reach ${workspaces.length} ${workspaces.length === 1 ? 'workspace' : 'workspaces'}.${own ? ` Folders with no link use ${own.name}.` : ''}`);
|
|
149
|
+
out('To link a repository to a workspace, run this in it: gigarag use <workspace>');
|
|
150
|
+
out('To see them all: gigarag workspaces');
|
|
151
|
+
}
|
|
152
|
+
catch (e) {
|
|
153
|
+
err(`Signed in, but could not list your workspaces: ${e.message}`);
|
|
154
|
+
}
|
|
123
155
|
out('Next: gigarag connect');
|
|
124
156
|
return 0;
|
|
125
157
|
}
|
|
@@ -135,7 +167,10 @@ export async function logout(argv) {
|
|
|
135
167
|
return 0;
|
|
136
168
|
}
|
|
137
169
|
clearCredential();
|
|
138
|
-
updateConfig(c =>
|
|
170
|
+
updateConfig(c => {
|
|
171
|
+
c.auth = undefined;
|
|
172
|
+
c.workspaceId = undefined;
|
|
173
|
+
});
|
|
139
174
|
out('Signed out. The stored credential is deleted.');
|
|
140
175
|
const written = Object.entries(readConfig().clients);
|
|
141
176
|
if (written.length > 0) {
|
package/cli/commands/mcp.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
|
+
import { resolveWorkspace, untrustedBinding, untrustedBindingNotice } from '../binding.js';
|
|
2
3
|
import { runBridge } from '../mcp/bridge.js';
|
|
3
|
-
import { createClient } from '../mcp/session.js';
|
|
4
|
+
import { createClient, workspaceNotice } from '../mcp/session.js';
|
|
4
5
|
import { err } from '../ui.js';
|
|
5
6
|
/**
|
|
6
7
|
* The stdio bridge. A client's config file runs this, so stdout belongs to the
|
|
@@ -13,6 +14,14 @@ export async function mcp(argv) {
|
|
|
13
14
|
url: values.url,
|
|
14
15
|
onRetry: (wait, attempt) => err(`gigarag: rate limited, waiting ${wait}s (retry ${attempt})`),
|
|
15
16
|
});
|
|
17
|
+
// An untrusted binding is never used silently: resolveWorkspace already
|
|
18
|
+
// skipped it in favour of the machine default, and this names why.
|
|
19
|
+
const untrusted = untrustedBinding();
|
|
20
|
+
if (untrusted)
|
|
21
|
+
err(`gigarag: ${untrustedBindingNotice(untrusted)}`);
|
|
22
|
+
const notice = workspaceNotice(resolveWorkspace());
|
|
23
|
+
if (notice && !untrusted)
|
|
24
|
+
err(`gigarag: ${notice}`);
|
|
16
25
|
await runBridge(client, {
|
|
17
26
|
input: process.stdin,
|
|
18
27
|
output: process.stdout,
|
package/cli/commands/repo.js
CHANGED
|
@@ -10,6 +10,7 @@ const HELP = `Usage: gigarag repo [path] [--slug]
|
|
|
10
10
|
Prints which GigaRAG bucket a directory belongs to: {slug, root, remote, indexedAt, dirty}.
|
|
11
11
|
The slug comes from the git remote when there is one, so two checkouts of one repository share
|
|
12
12
|
a bucket, and from the directory name plus a short hash of its path when there is not. It walks no files and costs nothing.
|
|
13
|
+
When .gigarag.json names a bucket, that is the slug, and "bound" is true.
|
|
13
14
|
|
|
14
15
|
--slug Print only the slug`;
|
|
15
16
|
export async function repo(argv) {
|
package/cli/commands/scan.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
|
+
import { untrustedBinding } from '../binding.js';
|
|
3
4
|
import { scanTree } from '../scan/scan.js';
|
|
4
5
|
import { State } from '../state.js';
|
|
5
6
|
import { out, table, UsageError } from '../ui.js';
|
|
7
|
+
/** Exit status that tells /gigaindex to stop and relay stdout, the same convention as `gigarag sync`. */
|
|
8
|
+
const STOP = 3;
|
|
9
|
+
/**
|
|
10
|
+
* The instructive refusal for a repository whose `.gigarag.json` has not been trusted. `scan`
|
|
11
|
+
* checks this itself, rather than silently falling back to the local slug or the default
|
|
12
|
+
* workspace: a binding an attacker committed can name another of the person's own buckets, and a
|
|
13
|
+
* scan that ran anyway (see `repo.ts`) is what let it report that bucket's files as deleted and
|
|
14
|
+
* offer to delete their memos. Refusing here means /gigaindex never gets that far.
|
|
15
|
+
*/
|
|
16
|
+
function untrustedScanMessage(untrusted) {
|
|
17
|
+
return (`${untrusted.root}'s .gigarag.json links this repository to workspace ${untrusted.workspace}, which has not been trusted, so the scan did not run. ` +
|
|
18
|
+
'Retrying will not help. If you placed or reviewed this file yourself, run: gigarag trust. To use a different workspace instead: gigarag use <workspace>.');
|
|
19
|
+
}
|
|
6
20
|
/** A whole number of at least 1. Number('abc') is NaN, which would have meant no limit at all. */
|
|
7
21
|
function parseLimit(raw) {
|
|
8
22
|
if (raw === undefined || raw === '')
|
|
@@ -37,9 +51,15 @@ export async function scan(argv) {
|
|
|
37
51
|
out(HELP);
|
|
38
52
|
return 0;
|
|
39
53
|
}
|
|
54
|
+
const start = resolve(positionals[0] ?? '.');
|
|
55
|
+
const untrusted = untrustedBinding(start);
|
|
56
|
+
if (untrusted) {
|
|
57
|
+
out(untrustedScanMessage(untrusted));
|
|
58
|
+
return STOP;
|
|
59
|
+
}
|
|
40
60
|
const state = new State();
|
|
41
61
|
try {
|
|
42
|
-
const manifest = scanTree(
|
|
62
|
+
const manifest = scanTree(start, {
|
|
43
63
|
state,
|
|
44
64
|
all: values.all,
|
|
45
65
|
verbose: values.verbose,
|