gigarag-claude-code 0.1.1 → 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/.claude-plugin/plugin.json +1 -1
- package/agents/gigarag-indexer.md +6 -3
- 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/commands/gigaindex.md +7 -1
- package/commands/gigasync.md +5 -3
- package/package.json +1 -1
package/cli/commands/status.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseArgs } from 'node:util';
|
|
2
2
|
import { readConfig } from '../config.js';
|
|
3
|
-
import { credentialSource, loadCredential, mask } from '../auth/credentials.js';
|
|
3
|
+
import { ACCOUNT_SLOT, credentialSource, loadAccountCredential, loadCredential, LEGACY_SLOT, mask, workspaceSlot } from '../auth/credentials.js';
|
|
4
|
+
import { resolveWorkspace } from '../binding.js';
|
|
4
5
|
import { launcherIsStale } from '../clients/inspect.js';
|
|
5
6
|
import { detect, hereNow, loadRegistry } from '../clients/registry.js';
|
|
6
7
|
import { createClient, resolveUrl } from '../mcp/session.js';
|
|
@@ -21,28 +22,38 @@ export async function status(argv) {
|
|
|
21
22
|
return 0;
|
|
22
23
|
}
|
|
23
24
|
const config = readConfig();
|
|
24
|
-
const
|
|
25
|
+
const resolved = resolveWorkspace();
|
|
26
|
+
const account = loadAccountCredential() !== undefined;
|
|
27
|
+
const credential = loadCredential(process.env, resolved?.id) ?? (resolved ? undefined : loadCredential());
|
|
25
28
|
const endpoint = resolveUrl();
|
|
26
29
|
const report = {
|
|
27
|
-
signedIn: credential !== undefined,
|
|
30
|
+
signedIn: credential !== undefined || account,
|
|
28
31
|
endpoint,
|
|
29
32
|
clients: [],
|
|
30
33
|
repos: [],
|
|
34
|
+
account,
|
|
35
|
+
...(resolved ? { workspace: { id: resolved.id, name: config.workspaceNames?.[resolved.id], source: resolved.source } } : {}),
|
|
31
36
|
};
|
|
32
37
|
if (credential) {
|
|
33
38
|
report.credential = credential.type === 'key' ? `key ${mask(credential.key)}` : 'browser sign-in';
|
|
34
|
-
report.storedIn = credentialSource();
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
39
|
+
report.storedIn = credentialSource(process.env, resolved ? workspaceSlot(resolved.id) : LEGACY_SLOT);
|
|
40
|
+
}
|
|
41
|
+
else if (account) {
|
|
42
|
+
report.credential = 'account sign-in, no workspace token yet';
|
|
43
|
+
report.storedIn = credentialSource(process.env, ACCOUNT_SLOT);
|
|
44
|
+
}
|
|
45
|
+
if ((credential || account) && !values.offline) {
|
|
46
|
+
// Resolving by workspaceId, never by passing the credential directly, so a refreshed token is
|
|
47
|
+
// written back to that workspace's own slot and never to the legacy one.
|
|
48
|
+
try {
|
|
49
|
+
const tools = await createClient({ workspaceId: resolved?.id ?? null }).rpc('tools/list');
|
|
50
|
+
report.reachable = { ok: true, detail: `accepted, ${tools.tools.length} tools` };
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
report.reachable = {
|
|
54
|
+
ok: false,
|
|
55
|
+
detail: e instanceof McpHttpError ? e.message : `could not reach ${endpoint}: ${e.message}`,
|
|
56
|
+
};
|
|
46
57
|
}
|
|
47
58
|
}
|
|
48
59
|
const registry = loadRegistry();
|
|
@@ -90,6 +101,13 @@ function detectedButUnwritten(registry, written, where) {
|
|
|
90
101
|
}
|
|
91
102
|
function print(r, missing) {
|
|
92
103
|
out(r.signedIn ? `Signed in with ${r.credential}, stored in ${r.storedIn}.` : 'Not signed in. Run: gigarag login');
|
|
104
|
+
if (r.workspace) {
|
|
105
|
+
const how = { env: 'from GIGARAG_WORKSPACE', binding: 'from .gigarag.json', default: 'your default, since this folder has no link' }[r.workspace.source];
|
|
106
|
+
out(`Workspace here: ${r.workspace.name ?? r.workspace.id}, ${how}.`);
|
|
107
|
+
}
|
|
108
|
+
else if (r.account) {
|
|
109
|
+
out('Workspace here: none. Run: gigarag use <workspace>');
|
|
110
|
+
}
|
|
93
111
|
out(`Endpoint: ${r.endpoint}`);
|
|
94
112
|
if (r.reachable)
|
|
95
113
|
out(`Key check: ${r.reachable.ok ? r.reachable.detail : `FAILED. ${r.reachable.detail}`}`);
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { State } from '../state.js';
|
|
4
|
+
import { out } from '../ui.js';
|
|
5
|
+
import { HooksOutsideRepoError, hookStatus, installHooks, refreshHooks } from './gitHooks.js';
|
|
6
|
+
import { INTERACTIVE_LOCK_MS, bucketCacheKey, clearInteractiveRun, finishSync, holderName, markInteractiveRun, pushUnpushed, runStage } from '../sync/stage.js';
|
|
7
|
+
import { RepoSyncRemote, findBucketId } from '../sync/remote.js';
|
|
8
|
+
import { maxRewriteFiles, readNote, renderNote } from '../sync/notes.js';
|
|
9
|
+
import { syncTarget } from '../sync/target.js';
|
|
10
|
+
/**
|
|
11
|
+
* Clears the interactive marker and, if a hook-mode sync deferred behind it (a `deferred` note),
|
|
12
|
+
* starts the background worker again right away, instead of leaving that commit waiting for
|
|
13
|
+
* another one to arrive. Every caller of `clearInteractiveRun` in this file goes through this
|
|
14
|
+
* instead, since any of them can be the one that frees a sync a hook queued behind it.
|
|
15
|
+
*/
|
|
16
|
+
async function releaseInteractive(state, target) {
|
|
17
|
+
clearInteractiveRun(state, target.slug);
|
|
18
|
+
if (target.git && readNote(state, target.slug)?.kind === 'deferred') {
|
|
19
|
+
(await import('../sync/worker.js')).spawnWorker(target.root);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
const HELP = `Usage: gigarag sync begin|end|status [path]
|
|
23
|
+
|
|
24
|
+
Keeps a repository's memos in step with its git history. /gigasync runs begin, then the
|
|
25
|
+
indexer, then end. The git hooks run the same steps in the background after a commit.
|
|
26
|
+
|
|
27
|
+
begin [--force] Claim this repository's sync lease, apply renames and deletions that need
|
|
28
|
+
no model, and mark the memos of changed files stale. Exit 3 means stop and
|
|
29
|
+
tell the person what was printed. --force syncs another branch, a checkout
|
|
30
|
+
behind the last synced commit, or a mass deletion
|
|
31
|
+
end [--init] Save what the indexer recorded, move the last synced commit when nothing is
|
|
32
|
+
left, and release the lease. --init also records this branch as the one to
|
|
33
|
+
sync and installs the git hooks (/gigaindex does this)
|
|
34
|
+
status [--json] The bucket, branch, last synced commit, lease, hooks and last note`;
|
|
35
|
+
/** Exit status that tells a slash command to stop and relay stdout. */
|
|
36
|
+
const STOP = 3;
|
|
37
|
+
/**
|
|
38
|
+
* The instructive refusal for a repository whose `.gigarag.json` has not been trusted. `begin` and
|
|
39
|
+
* `end` print this and stop rather than let `runStage` (which `begin` alone goes through) be the
|
|
40
|
+
* only place that catches it: `end` never calls `runStage`, so it needs its own check, for a person
|
|
41
|
+
* running it directly and for the worker-mode fast path a stray `GIGARAG_SYNC_WORKER=1 gigarag sync
|
|
42
|
+
* end` would otherwise reach.
|
|
43
|
+
*/
|
|
44
|
+
function untrustedSyncMessage(target) {
|
|
45
|
+
return (`${target.root}'s .gigarag.json links this repository to workspace ${target.untrustedWorkspace}, which has not been trusted, so sync did not run and will not until it is. ` +
|
|
46
|
+
'Retrying will not help. If you placed or reviewed this file yourself, run: gigarag trust. To use a different workspace instead: gigarag use <workspace>.');
|
|
47
|
+
}
|
|
48
|
+
export async function sync(argv) {
|
|
49
|
+
const { values, positionals } = parseArgs({
|
|
50
|
+
args: argv,
|
|
51
|
+
allowPositionals: true,
|
|
52
|
+
options: {
|
|
53
|
+
force: { type: 'boolean' },
|
|
54
|
+
init: { type: 'boolean' },
|
|
55
|
+
json: { type: 'boolean' },
|
|
56
|
+
hook: { type: 'string' },
|
|
57
|
+
help: { type: 'boolean', short: 'h' },
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
if (values.hook !== undefined)
|
|
61
|
+
return (await import('../sync/worker.js')).hookEntry(values.hook);
|
|
62
|
+
const [action, path] = positionals;
|
|
63
|
+
if (values.help || !action) {
|
|
64
|
+
out(HELP);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
if (action === 'worker')
|
|
68
|
+
return (await import('../sync/worker.js')).runWorker(resolve(path ?? '.'));
|
|
69
|
+
const target = syncTarget(resolve(path ?? '.'));
|
|
70
|
+
const inWorker = Boolean(process.env['GIGARAG_SYNC_WORKER']);
|
|
71
|
+
const state = new State();
|
|
72
|
+
try {
|
|
73
|
+
switch (action) {
|
|
74
|
+
case 'begin': {
|
|
75
|
+
if (inWorker) {
|
|
76
|
+
out('The background sync holds this repository and has already applied renames, deletions and stale marks. Continue with the scan.');
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (target.git)
|
|
80
|
+
refreshHooks(target.root, state);
|
|
81
|
+
// Held until `end` clears it, so the hook-mode worker steps aside for the whole of
|
|
82
|
+
// /gigasync, including the indexer run in between, not just this deterministic stage.
|
|
83
|
+
markInteractiveRun(state, target.slug);
|
|
84
|
+
let stage;
|
|
85
|
+
try {
|
|
86
|
+
stage = await runStage(state, target, { mode: 'interactive', force: values.force });
|
|
87
|
+
}
|
|
88
|
+
catch (e) {
|
|
89
|
+
// A crash here must not leave the marker standing for its full hour: nothing else
|
|
90
|
+
// clears it, so every commit's hook would silently defer behind a session that no
|
|
91
|
+
// longer exists.
|
|
92
|
+
await releaseInteractive(state, target);
|
|
93
|
+
throw e;
|
|
94
|
+
}
|
|
95
|
+
if (stage.kind === 'stop')
|
|
96
|
+
await releaseInteractive(state, target);
|
|
97
|
+
out(stage.message);
|
|
98
|
+
return stage.kind === 'stop' ? STOP : 0;
|
|
99
|
+
}
|
|
100
|
+
case 'end': {
|
|
101
|
+
if (target.untrustedWorkspace) {
|
|
102
|
+
out(untrustedSyncMessage(target));
|
|
103
|
+
if (!inWorker)
|
|
104
|
+
await releaseInteractive(state, target);
|
|
105
|
+
return STOP;
|
|
106
|
+
}
|
|
107
|
+
const bucketId = state.getMeta(bucketCacheKey(target)) || (await findBucketId(target.rag(), target.slug));
|
|
108
|
+
if (!bucketId) {
|
|
109
|
+
out(`GigaRAG has no bucket for ${target.slug}, so there is nothing to save. The indexer creates it; run /gigaindex.`);
|
|
110
|
+
if (!inWorker)
|
|
111
|
+
await releaseInteractive(state, target);
|
|
112
|
+
return STOP;
|
|
113
|
+
}
|
|
114
|
+
state.setMeta(bucketCacheKey(target), bucketId);
|
|
115
|
+
const remote = new RepoSyncRemote(target.rag(), bucketId);
|
|
116
|
+
if (inWorker) {
|
|
117
|
+
await pushUnpushed(state, target.slug, remote);
|
|
118
|
+
out('Saved. The background sync finishes the rest.');
|
|
119
|
+
return 0;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
// The lease is 600s and the indexer can run longer than that, so `end` always
|
|
123
|
+
// renews here rather than trusting the token `begin` cached: a live lease
|
|
124
|
+
// renews, a lapsed one is free and re-granted to us, and only another
|
|
125
|
+
// machine's live lease is refused. Sending the cached token straight to
|
|
126
|
+
// advance instead would read as a lease this machine never lost.
|
|
127
|
+
const cached = state.getMeta(`lease:${target.slug}`) || undefined;
|
|
128
|
+
const claim = await remote.claim(holderName(state), cached);
|
|
129
|
+
if (!claim.granted) {
|
|
130
|
+
out(claim.message);
|
|
131
|
+
return STOP;
|
|
132
|
+
}
|
|
133
|
+
const token = claim.token;
|
|
134
|
+
const fin = await finishSync(state, target, { bucketId, token, init: values.init, mode: 'interactive' });
|
|
135
|
+
out(fin.message);
|
|
136
|
+
if (values.init && target.git) {
|
|
137
|
+
try {
|
|
138
|
+
const installed = installHooks(target.root, false, state).filter(r => r.result !== 'foreign' && r.result !== 'tracked').length;
|
|
139
|
+
out(`Installed ${installed} git hooks, so GigaRAG syncs after each commit on this branch. gigarag hooks uninstall removes them.`);
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (!(e instanceof HooksOutsideRepoError))
|
|
143
|
+
throw e;
|
|
144
|
+
out(e.message);
|
|
145
|
+
out('The rest of the sync is saved; only the automatic hooks are missing.');
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
// /gigasync (begin, indexer, end) is over either way: the hook-mode worker can run again.
|
|
152
|
+
await releaseInteractive(state, target);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
case 'status': {
|
|
156
|
+
// An untrusted binding is never followed for sync, so status never resolves or queries a
|
|
157
|
+
// bucket for it either: doing so would report on whatever the default workspace happens to
|
|
158
|
+
// hold for this repository's unbound slug, which reads as this binding having been used.
|
|
159
|
+
const untrusted = target.untrustedWorkspace;
|
|
160
|
+
const bucketId = untrusted ? undefined : state.getMeta(bucketCacheKey(target)) || (await findBucketId(target.rag(), target.slug));
|
|
161
|
+
const s = bucketId ? await new RepoSyncRemote(target.rag(), bucketId).state() : undefined;
|
|
162
|
+
const note = untrusted ? undefined : readNote(state, target.slug);
|
|
163
|
+
// A hooks path outside both .git and the work tree (a machine-wide core.hooksPath)
|
|
164
|
+
// makes hookStatus refuse, the same way install and uninstall do. Status is a read: it
|
|
165
|
+
// reports that and moves on, rather than crashing on something install already explains.
|
|
166
|
+
let hooks = 0;
|
|
167
|
+
let hooksError;
|
|
168
|
+
if (target.git) {
|
|
169
|
+
try {
|
|
170
|
+
hooks = hookStatus(target.root).filter(h => h.result === 'installed').length;
|
|
171
|
+
}
|
|
172
|
+
catch (e) {
|
|
173
|
+
if (!(e instanceof HooksOutsideRepoError))
|
|
174
|
+
throw e;
|
|
175
|
+
hooks = null;
|
|
176
|
+
hooksError = e.message;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// A live marker blocks every hook-mode sync on this machine for up to an hour, silently
|
|
180
|
+
// if the interactive session that set it crashed or was abandoned before `end`. Status
|
|
181
|
+
// says so and how to clear it, since nothing else here will.
|
|
182
|
+
const interactiveSince = Number(state.getMeta(`sync_interactive:${target.slug}`) ?? 0);
|
|
183
|
+
const interactiveUntil = interactiveSince > 0 && Date.now() - interactiveSince < INTERACTIVE_LOCK_MS ? new Date(interactiveSince + INTERACTIVE_LOCK_MS).toISOString() : null;
|
|
184
|
+
const report = {
|
|
185
|
+
bucket: target.slug,
|
|
186
|
+
untrusted_workspace: untrusted ?? null,
|
|
187
|
+
initialised: s?.initialised ?? false,
|
|
188
|
+
branch: s?.branch ?? null,
|
|
189
|
+
last_commit: s?.last_commit ?? null,
|
|
190
|
+
lease: s?.lease ?? null,
|
|
191
|
+
hooks_installed: hooks,
|
|
192
|
+
hooks_error: hooksError ?? null,
|
|
193
|
+
interactive_until: interactiveUntil,
|
|
194
|
+
note: note && note.kind ? renderNote(note, target.slug, maxRewriteFiles()) : null,
|
|
195
|
+
};
|
|
196
|
+
if (values.json)
|
|
197
|
+
out(JSON.stringify(report, null, 2));
|
|
198
|
+
else {
|
|
199
|
+
if (untrusted)
|
|
200
|
+
out(`${target.root}'s .gigarag.json links this repository to workspace ${untrusted}, which has not been trusted, so it is not used for sync. Run gigarag trust to use it, or gigarag use <workspace> to pick another.`);
|
|
201
|
+
out(`${report.bucket}: ${report.initialised ? `syncs ${report.branch}, last synced ${report.last_commit?.slice(0, 7) ?? 'never'}` : 'not set up for automatic sync (run /gigaindex)'}`);
|
|
202
|
+
if (hooksError)
|
|
203
|
+
out(`hooks: unavailable (${hooksError})`);
|
|
204
|
+
else
|
|
205
|
+
out(`Git hooks installed: ${hooks} of 4`);
|
|
206
|
+
if (report.lease)
|
|
207
|
+
out(`Lease held by ${report.lease.holder} until ${report.lease.expires_at}`);
|
|
208
|
+
if (interactiveUntil)
|
|
209
|
+
out(`An interactive /gigasync marker is live on this machine until ${interactiveUntil}, so the background sync steps aside until then. If that session already ended, gigarag sync end clears it.`);
|
|
210
|
+
if (report.note)
|
|
211
|
+
out(report.note);
|
|
212
|
+
}
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
default:
|
|
216
|
+
out(HELP);
|
|
217
|
+
return 2;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
state.close();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { readBinding, trustBinding } from '../binding.js';
|
|
4
|
+
import { out, UsageError } from '../ui.js';
|
|
5
|
+
const HELP = `Usage: gigarag trust [path]
|
|
6
|
+
|
|
7
|
+
Trusts this repository's .gigarag.json, the way running gigarag use here would,
|
|
8
|
+
without rewriting the file. Run it once after a checkout inherits a binding
|
|
9
|
+
someone else committed and you have looked at which workspace it names.
|
|
10
|
+
|
|
11
|
+
Until a binding is trusted, GigaRAG ignores it and falls back to your machine's
|
|
12
|
+
default workspace, and says so.`;
|
|
13
|
+
export async function trust(argv) {
|
|
14
|
+
const { values, positionals } = parseArgs({
|
|
15
|
+
args: argv,
|
|
16
|
+
allowPositionals: true,
|
|
17
|
+
options: { help: { type: 'boolean', short: 'h' } },
|
|
18
|
+
});
|
|
19
|
+
if (values.help) {
|
|
20
|
+
out(HELP);
|
|
21
|
+
return 0;
|
|
22
|
+
}
|
|
23
|
+
const cwd = resolve(positionals[0] ?? '.');
|
|
24
|
+
const found = readBinding(cwd);
|
|
25
|
+
if (!found) {
|
|
26
|
+
throw new UsageError(`No .gigarag.json binding was found above ${cwd}. To create one: gigarag use <workspace>`);
|
|
27
|
+
}
|
|
28
|
+
trustBinding(found.root, found.binding.workspace);
|
|
29
|
+
out(`Trusted. ${found.root} now uses ${found.binding.workspaceName ?? found.binding.workspace}${found.binding.bucket ? `, bucket ${found.binding.bucket}` : ''}.`);
|
|
30
|
+
return 0;
|
|
31
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { exchangeForWorkspace, listAccountWorkspaces } from '../auth/account.js';
|
|
4
|
+
import { trustBinding, writeBinding } from '../binding.js';
|
|
5
|
+
import { updateConfig } from '../config.js';
|
|
6
|
+
import { resolveUrl } from '../mcp/session.js';
|
|
7
|
+
import { findRepoRoot } from '../scan/repo.js';
|
|
8
|
+
import { GigaRag } from '../sdk.js';
|
|
9
|
+
import { out, UsageError } from '../ui.js';
|
|
10
|
+
const HELP = `Usage: gigarag use <workspace> [--bucket <slug>] [--default] [path]
|
|
11
|
+
|
|
12
|
+
Links this repository to a GigaRAG workspace by writing .gigarag.json at its
|
|
13
|
+
root. The file holds no secret, so it can be committed. <workspace> is a name or
|
|
14
|
+
an id from: gigarag workspaces
|
|
15
|
+
|
|
16
|
+
--bucket <slug> The bucket this repository is indexed into. Required where you
|
|
17
|
+
are a guest, because a guest cannot create buckets.
|
|
18
|
+
--default Make it the workspace for every folder with no link, instead
|
|
19
|
+
of linking this repository`;
|
|
20
|
+
export function pickWorkspace(list, wanted) {
|
|
21
|
+
const byId = list.find(w => w.id === wanted.toLowerCase());
|
|
22
|
+
if (byId)
|
|
23
|
+
return byId;
|
|
24
|
+
const byName = list.filter(w => w.name.toLowerCase() === wanted.toLowerCase());
|
|
25
|
+
if (byName.length === 1)
|
|
26
|
+
return byName[0];
|
|
27
|
+
if (byName.length > 1) {
|
|
28
|
+
throw new UsageError(`More than one workspace is called ${wanted}. Use its id instead:\n${byName.map(w => ` ${w.id} ${w.name}, owned by ${w.owner_name ?? 'you'}`).join('\n')}`);
|
|
29
|
+
}
|
|
30
|
+
throw new UsageError(`Your account cannot reach a workspace called ${wanted}. To see the ones it can: gigarag workspaces`);
|
|
31
|
+
}
|
|
32
|
+
/** Slugs of the buckets a guest may write to, read with the workspace's own token. */
|
|
33
|
+
export async function writableSlugs(rag, grants) {
|
|
34
|
+
const writable = new Set(grants.filter(g => g.permission === 'write').map(g => g.bucket_id));
|
|
35
|
+
const page = await rag.listBuckets({ limit: 200 });
|
|
36
|
+
return (page.items ?? [])
|
|
37
|
+
.filter(b => writable.has(String(b['id'])))
|
|
38
|
+
.map(b => String(b['slug']));
|
|
39
|
+
}
|
|
40
|
+
export async function use(argv) {
|
|
41
|
+
const { values, positionals } = parseArgs({
|
|
42
|
+
args: argv,
|
|
43
|
+
allowPositionals: true,
|
|
44
|
+
options: { bucket: { type: 'string' }, default: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } },
|
|
45
|
+
});
|
|
46
|
+
if (values.help || positionals.length === 0) {
|
|
47
|
+
out(HELP);
|
|
48
|
+
return values.help ? 0 : 2;
|
|
49
|
+
}
|
|
50
|
+
const target = pickWorkspace(await listAccountWorkspaces(), positionals[0]);
|
|
51
|
+
// Exchanged now, so a refusal reaches the person here, in a terminal, rather
|
|
52
|
+
// than later inside an editor that shows it as a failed tool call.
|
|
53
|
+
await exchangeForWorkspace(target.id, { mcpUrl: resolveUrl() });
|
|
54
|
+
if (target.access_via === 'bucket') {
|
|
55
|
+
const slugs = await writableSlugs(new GigaRag({ workspaceId: target.id }), target.buckets ?? []);
|
|
56
|
+
if (!values.bucket || !slugs.includes(values.bucket)) {
|
|
57
|
+
throw new UsageError(slugs.length === 0
|
|
58
|
+
? `You are a guest in ${target.name} and cannot write to any of its buckets, so this repository cannot be indexed there. Ask ${target.owner_name ?? 'the owner'} for write access to a bucket at gigarag.com/teams.`
|
|
59
|
+
: `You are a guest in ${target.name}, and guests cannot create buckets. Name one you can write to with --bucket: ${slugs.join(', ')}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (values.default) {
|
|
63
|
+
updateConfig(c => void (c.workspaceId = target.id));
|
|
64
|
+
out(`Folders with no link now use ${target.name}.`);
|
|
65
|
+
return 0;
|
|
66
|
+
}
|
|
67
|
+
const { root } = findRepoRoot(resolve(positionals[1] ?? '.'));
|
|
68
|
+
const path = writeBinding(root, {
|
|
69
|
+
workspace: target.id,
|
|
70
|
+
workspaceName: target.name,
|
|
71
|
+
...(values.bucket ? { bucket: values.bucket } : {}),
|
|
72
|
+
});
|
|
73
|
+
// Running `use` is the trust: the person named this workspace themselves, so
|
|
74
|
+
// the binding it just wrote is followed from here on without another prompt.
|
|
75
|
+
// A teammate whose checkout inherits the committed file has not done that,
|
|
76
|
+
// and needs `gigarag trust` once they have looked at it.
|
|
77
|
+
trustBinding(root, target.id);
|
|
78
|
+
out(`Linked ${root} to ${target.name}${values.bucket ? `, bucket ${values.bucket}` : ''}. Wrote ${path}.`);
|
|
79
|
+
out('It holds no secret, so you can commit it and teammates reach the same workspace, after they run: gigarag trust');
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { parseArgs } from 'node:util';
|
|
2
|
+
import { listAccountWorkspaces } from '../auth/account.js';
|
|
3
|
+
import { resolveWorkspace } from '../binding.js';
|
|
4
|
+
import { out, table } from '../ui.js';
|
|
5
|
+
const HELP = `Usage: gigarag workspaces [--json]
|
|
6
|
+
|
|
7
|
+
Lists every workspace your account can reach: your own, ones shared with you,
|
|
8
|
+
ones reached through someone's account, and ones where you are a guest in some
|
|
9
|
+
buckets. The one this folder uses is marked with *.
|
|
10
|
+
|
|
11
|
+
To link this repository to one: gigarag use <workspace>`;
|
|
12
|
+
const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
13
|
+
export function describeAccess(w) {
|
|
14
|
+
const owner = w.owner_name ?? 'someone';
|
|
15
|
+
if (w.access_via === 'owner')
|
|
16
|
+
return 'Owner';
|
|
17
|
+
if (w.access_via === 'account')
|
|
18
|
+
return `${cap(w.role)} in all of ${owner}'s workspaces`;
|
|
19
|
+
if (w.access_via === 'bucket') {
|
|
20
|
+
const n = w.buckets?.length ?? 0;
|
|
21
|
+
return `Guest, ${n} ${n === 1 ? 'bucket' : 'buckets'} from ${owner}`;
|
|
22
|
+
}
|
|
23
|
+
return `${cap(w.role)}, shared by ${owner}`;
|
|
24
|
+
}
|
|
25
|
+
export async function workspaces(argv) {
|
|
26
|
+
const { values } = parseArgs({ args: argv, options: { json: { type: 'boolean' }, help: { type: 'boolean', short: 'h' } } });
|
|
27
|
+
if (values.help) {
|
|
28
|
+
out(HELP);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
const list = await listAccountWorkspaces();
|
|
32
|
+
const current = resolveWorkspace()?.id;
|
|
33
|
+
if (values.json) {
|
|
34
|
+
out(JSON.stringify(list.map(w => ({ ...w, current: w.id === current })), null, 2));
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
for (const line of table(list.map(w => [w.id === current ? '*' : ' ', w.name, describeAccess(w), w.id])))
|
|
38
|
+
out(line);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
package/cli/git/git.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { realpathSync } from 'node:fs';
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { HOOKS } from './hookScript.js';
|
|
5
|
+
/**
|
|
6
|
+
* The process runner behind every `git()` call, swappable only by tests so they can stub what a
|
|
7
|
+
* git binary (an old version's quirks included) would have answered, without needing that version
|
|
8
|
+
* installed or fighting a real OS's PATH resolution to shadow it.
|
|
9
|
+
*/
|
|
10
|
+
let runGitProcess = spawnSync;
|
|
11
|
+
/** @internal test seam only. `undefined` restores the real git binary. */
|
|
12
|
+
export function __setGitProcessRunnerForTests(fn) {
|
|
13
|
+
runGitProcess = fn ?? spawnSync;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Every question the sync asks git, in one place. Runs the git binary, which a
|
|
17
|
+
* repository with hooks already has, and never prompts: GIT_TERMINAL_PROMPT=0,
|
|
18
|
+
* no stdin, and a timeout, because these run from a detached worker nobody is
|
|
19
|
+
* watching.
|
|
20
|
+
*/
|
|
21
|
+
export function git(root, args) {
|
|
22
|
+
const r = runGitProcess('git', args, {
|
|
23
|
+
cwd: root,
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
timeout: 15_000,
|
|
27
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
28
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
29
|
+
});
|
|
30
|
+
return { ok: !r.error && r.status === 0, status: r.error ? null : r.status, out: (r.stdout ?? '').trim() };
|
|
31
|
+
}
|
|
32
|
+
export function isGitRepo(root) {
|
|
33
|
+
return git(root, ['rev-parse', '--is-inside-work-tree']).out === 'true';
|
|
34
|
+
}
|
|
35
|
+
export function headCommit(root) {
|
|
36
|
+
const r = git(root, ['rev-parse', '--verify', '--quiet', 'HEAD']);
|
|
37
|
+
return r.ok && /^[0-9a-f]{40,64}$/.test(r.out) ? r.out : undefined;
|
|
38
|
+
}
|
|
39
|
+
/** The checked out branch, or undefined on a detached HEAD, which is what every step of a rebase is. */
|
|
40
|
+
export function currentBranch(root) {
|
|
41
|
+
const r = git(root, ['symbolic-ref', '--quiet', '--short', 'HEAD']);
|
|
42
|
+
return r.ok && r.out ? r.out : undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether `ancestor` is an ancestor of `descendant` (HEAD by default). True or false when git can
|
|
46
|
+
* tell, undefined when either commit is unknown here (not fetched, or garbage collected).
|
|
47
|
+
*/
|
|
48
|
+
export function isAncestor(root, ancestor, descendant = 'HEAD') {
|
|
49
|
+
const r = git(root, ['merge-base', '--is-ancestor', ancestor, descendant]);
|
|
50
|
+
if (r.status === 0)
|
|
51
|
+
return true;
|
|
52
|
+
if (r.status === 1)
|
|
53
|
+
return false;
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Our own hook files, as pathspecs relative to `root`, when installing put them inside the work
|
|
58
|
+
* tree (`.husky`, or a repo-relative `core.hooksPath`). `.git/hooks` itself is never in the work
|
|
59
|
+
* tree, so this is empty for a plain repository.
|
|
60
|
+
*/
|
|
61
|
+
function ownHookPathspecs(root) {
|
|
62
|
+
const loc = hooksDir(root);
|
|
63
|
+
if (!loc || !loc.insideWorkTree)
|
|
64
|
+
return [];
|
|
65
|
+
return HOOKS.map(h => `:(exclude)${relative(root, resolve(loc.dir, h)).split(sep).join('/')}`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* No staged, unstaged or untracked changes. A background rewrite reads files from disk, so it
|
|
69
|
+
* needs this. Our own hook files are excluded: when they live inside the work tree, installing or
|
|
70
|
+
* updating them would otherwise make every repository look permanently dirty and the rewrite would
|
|
71
|
+
* never run.
|
|
72
|
+
*/
|
|
73
|
+
export function isClean(root) {
|
|
74
|
+
const excludes = ownHookPathspecs(root);
|
|
75
|
+
const r = git(root, ['status', '--porcelain', '--', '.', ...excludes]);
|
|
76
|
+
return r.ok && r.out === '';
|
|
77
|
+
}
|
|
78
|
+
/** Every file in HEAD's tree. A path gone from disk but still here is an uncommitted deletion. */
|
|
79
|
+
export function pathsAtHead(root) {
|
|
80
|
+
const r = spawnSync('git', ['ls-tree', '-r', '--name-only', '-z', 'HEAD'], {
|
|
81
|
+
cwd: root,
|
|
82
|
+
encoding: 'utf8',
|
|
83
|
+
windowsHide: true,
|
|
84
|
+
timeout: 15_000,
|
|
85
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
86
|
+
});
|
|
87
|
+
if (r.error || r.status !== 0)
|
|
88
|
+
return new Set();
|
|
89
|
+
return new Set((r.stdout ?? '').split('\0').filter(Boolean));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Turns a `rev-parse` answer into an absolute path, or undefined when it doesn't look like one.
|
|
93
|
+
* `--path-format=absolute` is never sent: a git older than 2.31 doesn't know that flag, and rather
|
|
94
|
+
* than failing, `rev-parse` echoes it back as an extra output line and answers the rest of the
|
|
95
|
+
* command as if it had never been given, in its only format, which is relative to the cwd the
|
|
96
|
+
* command ran with. Resolving against `root` (always that cwd) is therefore correct on every git
|
|
97
|
+
* version, once the flag itself is gone. What's left guards against exactly that echo, or any
|
|
98
|
+
* other output that isn't a single path: something starting with `--` (an option, echoed or
|
|
99
|
+
* otherwise) or carrying more than one line.
|
|
100
|
+
*/
|
|
101
|
+
function resolveGitPath(root, out) {
|
|
102
|
+
if (!out || out.startsWith('--') || out.includes('\n'))
|
|
103
|
+
return undefined;
|
|
104
|
+
return resolve(root, out);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The repository's own git directory (the common one, so a worktree answers with the main
|
|
108
|
+
* checkout's) and work tree top level, both absolute. Undefined where git can't say, such as a
|
|
109
|
+
* bare repository for the work tree.
|
|
110
|
+
*/
|
|
111
|
+
function repoPaths(root) {
|
|
112
|
+
const gd = git(root, ['rev-parse', '--git-common-dir']);
|
|
113
|
+
const wt = git(root, ['rev-parse', '--show-toplevel']);
|
|
114
|
+
return {
|
|
115
|
+
gitDir: gd.ok ? resolveGitPath(root, gd.out) : undefined,
|
|
116
|
+
workTree: wt.ok && wt.out ? resolve(wt.out) : undefined,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The path git itself would see: symlinks and junctions followed, and (on Windows) a `subst`
|
|
121
|
+
* drive resolved to the real one. Falls back to the input when nothing exists there yet, such as
|
|
122
|
+
* a `core.hooksPath` directory install hasn't created. Comparing this, not the raw string, is what
|
|
123
|
+
* keeps `insideGitDir` and `insideWorkTree` from reading as false on a path that only differs from
|
|
124
|
+
* git's own answer by an alias.
|
|
125
|
+
*/
|
|
126
|
+
function canonical(path) {
|
|
127
|
+
// A hooks folder that does not exist yet cannot be resolved itself, so resolve its nearest
|
|
128
|
+
// existing parent and put the rest back. Returning the raw path instead leaves a Windows short
|
|
129
|
+
// name (RUNNER~1) or a symlinked parent unexpanded, and the folder then reads as outside the repo.
|
|
130
|
+
let head = resolve(path);
|
|
131
|
+
const rest = [];
|
|
132
|
+
for (;;) {
|
|
133
|
+
try {
|
|
134
|
+
return join(realpathSync.native(head), ...rest);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
const parent = dirname(head);
|
|
138
|
+
if (parent === head)
|
|
139
|
+
return path;
|
|
140
|
+
rest.unshift(basename(head));
|
|
141
|
+
head = parent;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/** Case-insensitive on Windows, where two paths that differ only in a drive letter's case, or in
|
|
146
|
+
* a directory's case, name the same file. */
|
|
147
|
+
function comparable(path) {
|
|
148
|
+
const c = canonical(path);
|
|
149
|
+
return process.platform === 'win32' ? c.toLowerCase() : c;
|
|
150
|
+
}
|
|
151
|
+
function isWithin(dir, parent) {
|
|
152
|
+
if (!parent)
|
|
153
|
+
return false;
|
|
154
|
+
const d = comparable(dir);
|
|
155
|
+
const p = comparable(parent);
|
|
156
|
+
return d === p || d.startsWith(p + sep);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Where hooks go. `--git-path hooks` answers for worktrees and core.hooksPath.
|
|
160
|
+
* Husky 9 points core.hooksPath at `.husky/_` and regenerates that folder on
|
|
161
|
+
* every install, so our lines go into `.husky/<hook>`, which husky's own
|
|
162
|
+
* stubs call. Neither inside the git dir nor the work tree means a hooks
|
|
163
|
+
* path pointed somewhere else entirely, such as a global core.hooksPath
|
|
164
|
+
* shared by every repository on the machine: callers refuse to install there.
|
|
165
|
+
*/
|
|
166
|
+
export function hooksDir(root) {
|
|
167
|
+
const r = git(root, ['rev-parse', '--git-path', 'hooks']);
|
|
168
|
+
if (!r.ok)
|
|
169
|
+
return undefined;
|
|
170
|
+
const rawDir = resolveGitPath(root, r.out);
|
|
171
|
+
if (!rawDir)
|
|
172
|
+
return undefined;
|
|
173
|
+
const husky = basename(rawDir) === '_' && basename(dirname(rawDir)) === '.husky';
|
|
174
|
+
const dir = husky ? dirname(rawDir) : rawDir;
|
|
175
|
+
const { gitDir, workTree } = repoPaths(root);
|
|
176
|
+
const insideGitDir = isWithin(dir, gitDir);
|
|
177
|
+
const insideWorkTree = !insideGitDir && isWithin(dir, workTree);
|
|
178
|
+
return { dir, husky, insideGitDir, insideWorkTree };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* `<git-common-dir>/info/exclude`, the per-repository ignore list nothing commits, for hook files
|
|
182
|
+
* that live inside the work tree. Undefined where the common git dir can't be found.
|
|
183
|
+
*/
|
|
184
|
+
export function gitInfoExcludeFile(root) {
|
|
185
|
+
const { gitDir } = repoPaths(root);
|
|
186
|
+
return gitDir ? join(gitDir, 'info', 'exclude') : undefined;
|
|
187
|
+
}
|