gigarag-claude-code 0.1.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/.claude-plugin/plugin.json +10 -0
- package/.mcp.json +8 -0
- package/README.md +34 -0
- package/agents/gigarag-indexer.md +72 -0
- package/bin/gigarag +4 -0
- package/cli/auth/credentials.js +106 -0
- package/cli/auth/oauth.js +203 -0
- package/cli/auth/page.js +68 -0
- package/cli/bin.js +8 -0
- package/cli/cli.js +90 -0
- package/cli/clients/commands.js +160 -0
- package/cli/clients/connect.js +217 -0
- package/cli/clients/inspect.js +74 -0
- package/cli/clients/json.js +135 -0
- package/cli/clients/launcher.js +71 -0
- package/cli/clients/registry.js +40 -0
- package/cli/clients/toml.js +169 -0
- package/cli/clients/tomlarray.js +121 -0
- package/cli/clients/yaml.js +146 -0
- package/cli/clients.json +1226 -0
- package/cli/commands/authHeader.js +22 -0
- package/cli/commands/connect.js +285 -0
- package/cli/commands/indexSync.js +46 -0
- package/cli/commands/login.js +129 -0
- package/cli/commands/mcp.js +22 -0
- package/cli/commands/record.js +72 -0
- package/cli/commands/repo.js +48 -0
- package/cli/commands/scan.js +72 -0
- package/cli/commands/status.js +115 -0
- package/cli/config.js +69 -0
- package/cli/connect.js +8 -0
- package/cli/constants.js +24 -0
- package/cli/hooks.js +151 -0
- package/cli/index.js +3 -0
- package/cli/mcp/bridge.js +123 -0
- package/cli/mcp/client.js +156 -0
- package/cli/mcp/session.js +79 -0
- package/cli/package.json +5 -0
- package/cli/paths.js +34 -0
- package/cli/prompts.generated.js +44 -0
- package/cli/prompts.js +48 -0
- package/cli/scan/chunk.js +43 -0
- package/cli/scan/ignore.js +117 -0
- package/cli/scan/repo.js +99 -0
- package/cli/scan/scan.js +262 -0
- package/cli/scan.js +5 -0
- package/cli/sdk.js +130 -0
- package/cli/secrets.js +192 -0
- package/cli/secureUrl.js +18 -0
- package/cli/state.js +210 -0
- package/cli/ui.js +66 -0
- package/commands/gigadocs.md +19 -0
- package/commands/gigaindex.md +10 -0
- package/commands/gigarecall.md +15 -0
- package/commands/gigasave.md +24 -0
- package/commands/gigasync.md +9 -0
- package/hooks/hooks.json +38 -0
- package/package.json +26 -0
- package/scripts/run.mjs +64 -0
package/cli/scan/scan.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstatSync, openSync, readSync, closeSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { extname, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { planChunks } from './chunk.js';
|
|
5
|
+
import { Ignore } from './ignore.js';
|
|
6
|
+
import { repoInfo } from './repo.js';
|
|
7
|
+
const ALWAYS_SKIP_DIRS = new Set([
|
|
8
|
+
'.git',
|
|
9
|
+
'node_modules',
|
|
10
|
+
'.venv',
|
|
11
|
+
'venv',
|
|
12
|
+
'__pycache__',
|
|
13
|
+
'.next',
|
|
14
|
+
'.nuxt',
|
|
15
|
+
'.turbo',
|
|
16
|
+
'.gigarag',
|
|
17
|
+
'.idea',
|
|
18
|
+
'.gradle',
|
|
19
|
+
'target',
|
|
20
|
+
'Pods',
|
|
21
|
+
]);
|
|
22
|
+
const SKIP_FILES = new Set([
|
|
23
|
+
'package-lock.json',
|
|
24
|
+
'yarn.lock',
|
|
25
|
+
'pnpm-lock.yaml',
|
|
26
|
+
'bun.lockb',
|
|
27
|
+
'Cargo.lock',
|
|
28
|
+
'poetry.lock',
|
|
29
|
+
'composer.lock',
|
|
30
|
+
'Gemfile.lock',
|
|
31
|
+
'go.sum',
|
|
32
|
+
'.DS_Store',
|
|
33
|
+
'.gitignore',
|
|
34
|
+
'.gitattributes',
|
|
35
|
+
'.gigaragignore',
|
|
36
|
+
'Thumbs.db',
|
|
37
|
+
]);
|
|
38
|
+
/**
|
|
39
|
+
* Files that hold credentials. A scan feeds file contents to an agent that writes them into a shared
|
|
40
|
+
* workspace, so these are left out however the ignore files read.
|
|
41
|
+
*/
|
|
42
|
+
const SECRET_NAME = /^(\.env(\..*)?|\.npmrc|\.pypirc|\.netrc|\.git-credentials|id_(rsa|dsa|ecdsa|ed25519)|credentials(\.json)?|secrets?\.(json|ya?ml|toml)|.*\.(pem|key|p12|pfx|jks|keystore|kdbx|tfstate))$/i;
|
|
43
|
+
const ENV_EXAMPLE = /^\.env\.(example|sample|template|dist)$/i;
|
|
44
|
+
const BINARY_EXT = new Set([
|
|
45
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff', '.pdf', '.zip', '.gz', '.tar', '.tgz', '.7z',
|
|
46
|
+
'.rar', '.jar', '.war', '.exe', '.dll', '.so', '.dylib', '.bin', '.o', '.a', '.class', '.pyc', '.woff', '.woff2',
|
|
47
|
+
'.ttf', '.otf', '.eot', '.mp3', '.mp4', '.mov', '.avi', '.wav', '.ogg', '.webm', '.sqlite', '.db', '.wasm',
|
|
48
|
+
'.psd', '.ai', '.map',
|
|
49
|
+
]);
|
|
50
|
+
const LANGUAGES = {
|
|
51
|
+
'.ts': 'typescript', '.tsx': 'typescript', '.js': 'javascript', '.jsx': 'javascript', '.mjs': 'javascript',
|
|
52
|
+
'.cjs': 'javascript', '.py': 'python', '.rs': 'rust', '.go': 'go', '.java': 'java', '.kt': 'kotlin',
|
|
53
|
+
'.rb': 'ruby', '.php': 'php', '.cs': 'csharp', '.cpp': 'cpp', '.c': 'c', '.h': 'c', '.swift': 'swift',
|
|
54
|
+
'.md': 'markdown', '.mdx': 'markdown', '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.toml': 'toml',
|
|
55
|
+
'.sql': 'sql', '.sh': 'shell', '.ps1': 'powershell', '.html': 'html', '.css': 'css', '.scss': 'css',
|
|
56
|
+
'.vue': 'vue', '.svelte': 'svelte', '.txt': 'text',
|
|
57
|
+
};
|
|
58
|
+
/** Lines as an editor counts them: a trailing newline ends the last line rather than starting another. */
|
|
59
|
+
export function countLines(text) {
|
|
60
|
+
if (text === '')
|
|
61
|
+
return 0;
|
|
62
|
+
const n = text.split('\n').length;
|
|
63
|
+
return text.endsWith('\n') ? n - 1 : n;
|
|
64
|
+
}
|
|
65
|
+
/** True when the first 8 KB contains a NUL byte, the usual tell for a binary file. */
|
|
66
|
+
function looksBinary(path) {
|
|
67
|
+
const fd = openSync(path, 'r');
|
|
68
|
+
try {
|
|
69
|
+
const buf = Buffer.alloc(8192);
|
|
70
|
+
const n = readSync(fd, buf, 0, 8192, 0);
|
|
71
|
+
return buf.subarray(0, n).includes(0);
|
|
72
|
+
}
|
|
73
|
+
finally {
|
|
74
|
+
closeSync(fd);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
const readIgnore = (path) => {
|
|
78
|
+
try {
|
|
79
|
+
const ig = new Ignore(readFileSync(path, 'utf8'));
|
|
80
|
+
return ig.empty ? undefined : ig;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
/** The verdict of the nearest ignore file that speaks to `abs`, checking outward from the deepest. */
|
|
87
|
+
function ignored(scopes, abs, isDir) {
|
|
88
|
+
for (let i = scopes.length - 1; i >= 0; i--) {
|
|
89
|
+
const scope = scopes[i];
|
|
90
|
+
const rel = relative(scope.base, abs).split(sep).join('/');
|
|
91
|
+
const verdict = scope.ignore.test(rel, isDir);
|
|
92
|
+
if (verdict !== undefined)
|
|
93
|
+
return verdict;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Walks a tree the way git would, hashing what it finds and comparing against
|
|
99
|
+
* what was indexed before. The judgement about what a file means stays with the
|
|
100
|
+
* agent; this only answers which files it needs to read.
|
|
101
|
+
*/
|
|
102
|
+
export function scanTree(start, options = {}) {
|
|
103
|
+
const repo = repoInfo(resolve(start));
|
|
104
|
+
const root = resolve(start);
|
|
105
|
+
const maxFiles = options.maxFiles ?? 5000;
|
|
106
|
+
const maxBytes = options.maxBytes ?? 512 * 1024;
|
|
107
|
+
const known = options.state?.files(repo.slug) ?? new Map();
|
|
108
|
+
const files = [];
|
|
109
|
+
const skipped = [];
|
|
110
|
+
const seen = new Set();
|
|
111
|
+
let truncated = false;
|
|
112
|
+
let skippedCount = 0;
|
|
113
|
+
const skip = (path, reason) => {
|
|
114
|
+
skippedCount++;
|
|
115
|
+
if (options.verbose)
|
|
116
|
+
skipped.push({ path, reason });
|
|
117
|
+
};
|
|
118
|
+
const walk = (dir, scopes) => {
|
|
119
|
+
if (truncated)
|
|
120
|
+
return;
|
|
121
|
+
const local = [...scopes];
|
|
122
|
+
for (const name of ['.gitignore', '.gigaragignore']) {
|
|
123
|
+
const ig = readIgnore(join(dir, name));
|
|
124
|
+
if (ig)
|
|
125
|
+
local.push({ base: dir, ignore: ig });
|
|
126
|
+
}
|
|
127
|
+
let entries;
|
|
128
|
+
try {
|
|
129
|
+
entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
for (const entry of entries) {
|
|
135
|
+
if (truncated)
|
|
136
|
+
return;
|
|
137
|
+
const abs = join(dir, entry.name);
|
|
138
|
+
const rel = relative(repo.root, abs).split(sep).join('/');
|
|
139
|
+
if (entry.isSymbolicLink())
|
|
140
|
+
continue;
|
|
141
|
+
if (entry.isDirectory()) {
|
|
142
|
+
if (ALWAYS_SKIP_DIRS.has(entry.name) || ignored(local, abs, true))
|
|
143
|
+
continue;
|
|
144
|
+
walk(abs, local);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (!entry.isFile())
|
|
148
|
+
continue;
|
|
149
|
+
if (SKIP_FILES.has(entry.name)) {
|
|
150
|
+
skip(rel, 'lockfile or system file');
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (ignored(local, abs, false))
|
|
154
|
+
continue;
|
|
155
|
+
if (SECRET_NAME.test(entry.name) && !ENV_EXAMPLE.test(entry.name)) {
|
|
156
|
+
skip(rel, 'may hold credentials');
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
const ext = extname(entry.name).toLowerCase();
|
|
160
|
+
if (BINARY_EXT.has(ext) || /\.min\.(js|css)$/.test(entry.name)) {
|
|
161
|
+
skip(rel, 'binary or minified');
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
let stat;
|
|
165
|
+
try {
|
|
166
|
+
stat = lstatSync(abs);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (stat.size > maxBytes) {
|
|
172
|
+
skip(rel, `larger than ${Math.round(maxBytes / 1024)} KB`);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
if (stat.size > 0 && looksBinary(abs)) {
|
|
177
|
+
skip(rel, 'binary');
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (files.length >= maxFiles) {
|
|
185
|
+
truncated = true;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
let buf;
|
|
189
|
+
try {
|
|
190
|
+
buf = readFileSync(abs);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
// The file can grow between the size check and the read.
|
|
196
|
+
if (buf.length > maxBytes) {
|
|
197
|
+
skip(rel, `larger than ${Math.round(maxBytes / 1024)} KB`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const hash = createHash('sha256').update(buf).digest('hex').slice(0, 32);
|
|
201
|
+
const text = buf.toString('utf8');
|
|
202
|
+
const prior = known.get(rel);
|
|
203
|
+
// A file the hook marked dirty whose bytes match the index was saved without changing. Nothing to redo.
|
|
204
|
+
const status = !prior ? 'new' : prior.hash === hash ? 'unchanged' : 'changed';
|
|
205
|
+
seen.add(rel);
|
|
206
|
+
// Nothing will call record for an unchanged file, so this is the only place its flag can be cleared.
|
|
207
|
+
if (prior && prior.dirty === 1 && status === 'unchanged')
|
|
208
|
+
options.state?.clearDirty(repo.slug, rel);
|
|
209
|
+
const chunks = planChunks(text);
|
|
210
|
+
files.push({
|
|
211
|
+
path: rel,
|
|
212
|
+
status,
|
|
213
|
+
hash,
|
|
214
|
+
bytes: buf.length,
|
|
215
|
+
lines: countLines(text),
|
|
216
|
+
language: LANGUAGES[ext] ?? 'text',
|
|
217
|
+
...(chunks.length > 0 ? { chunks } : {}),
|
|
218
|
+
nodeIds: options.state?.nodesOf(repo.slug, rel) ?? [],
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
// Ignore files between the repository root and the scanned directory still apply to it.
|
|
223
|
+
const inherited = [];
|
|
224
|
+
const between = relative(repo.root, root).split(sep).filter(Boolean);
|
|
225
|
+
const ancestors = between.length === 0 ? [] : [repo.root];
|
|
226
|
+
for (let i = 0; i < between.length - 1; i++)
|
|
227
|
+
ancestors.push(join(repo.root, ...between.slice(0, i + 1)));
|
|
228
|
+
for (const dir of ancestors) {
|
|
229
|
+
for (const name of ['.gitignore', '.gigaragignore']) {
|
|
230
|
+
const ig = readIgnore(join(dir, name));
|
|
231
|
+
if (ig)
|
|
232
|
+
inherited.push({ base: dir, ignore: ig });
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
walk(root, inherited);
|
|
236
|
+
// Only files under the scanned directory can be reported gone. A scan of one folder says nothing about the rest.
|
|
237
|
+
const prefix = between.join('/');
|
|
238
|
+
const inScope = (p) => prefix === '' || p === prefix || p.startsWith(`${prefix}/`);
|
|
239
|
+
const gone = truncated ? [] : [...known.keys()].filter(p => inScope(p) && !seen.has(p));
|
|
240
|
+
const orphans = options.state?.orphansIfRemoved(repo.slug, gone) ?? new Map();
|
|
241
|
+
const removed = gone.map(path => ({
|
|
242
|
+
path,
|
|
243
|
+
nodeIds: options.state?.nodesOf(repo.slug, path) ?? [],
|
|
244
|
+
deleteNodeIds: orphans.get(path) ?? [],
|
|
245
|
+
}));
|
|
246
|
+
const count = (s) => files.filter(f => f.status === s).length;
|
|
247
|
+
return {
|
|
248
|
+
repo,
|
|
249
|
+
scannedAt: new Date().toISOString(),
|
|
250
|
+
summary: {
|
|
251
|
+
new: count('new'),
|
|
252
|
+
changed: count('changed'),
|
|
253
|
+
unchanged: count('unchanged'),
|
|
254
|
+
removed: removed.length,
|
|
255
|
+
skipped: skippedCount,
|
|
256
|
+
},
|
|
257
|
+
files: options.all ? files : files.filter(f => f.status !== 'unchanged'),
|
|
258
|
+
removed,
|
|
259
|
+
...(options.verbose ? { skipped } : {}),
|
|
260
|
+
truncated,
|
|
261
|
+
};
|
|
262
|
+
}
|
package/cli/scan.js
ADDED
package/cli/sdk.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { McpHttpClient, McpHttpError } from './mcp/client.js';
|
|
2
|
+
import { createClient } from './mcp/session.js';
|
|
3
|
+
import { version } from './constants.js';
|
|
4
|
+
export { McpHttpError };
|
|
5
|
+
/**
|
|
6
|
+
* GigaRAG from your own code. Everything goes through the same client the
|
|
7
|
+
* stdio bridge uses, so batches are split under the server's ceiling and a 429
|
|
8
|
+
* is waited out rather than thrown.
|
|
9
|
+
*/
|
|
10
|
+
export class GigaRag {
|
|
11
|
+
client;
|
|
12
|
+
actor;
|
|
13
|
+
initialised;
|
|
14
|
+
constructor(options = {}) {
|
|
15
|
+
this.client = createClient(options);
|
|
16
|
+
this.actor = options.actor;
|
|
17
|
+
}
|
|
18
|
+
init() {
|
|
19
|
+
this.initialised ??= this.client
|
|
20
|
+
.rpc('initialize', {
|
|
21
|
+
protocolVersion: '2025-03-26',
|
|
22
|
+
capabilities: {},
|
|
23
|
+
clientInfo: { name: 'gigarag-sdk', version: version() },
|
|
24
|
+
})
|
|
25
|
+
.then(() => this.client.notify('notifications/initialized'))
|
|
26
|
+
.catch(err => {
|
|
27
|
+
this.initialised = undefined;
|
|
28
|
+
throw err;
|
|
29
|
+
});
|
|
30
|
+
return this.initialised;
|
|
31
|
+
}
|
|
32
|
+
async listTools() {
|
|
33
|
+
await this.init();
|
|
34
|
+
const result = await this.client.rpc('tools/list');
|
|
35
|
+
return result.tools;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Calls any tool the server offers and returns its parsed result. A tool added
|
|
39
|
+
* to core works here the day it ships.
|
|
40
|
+
*/
|
|
41
|
+
async callTool(name, args = {}) {
|
|
42
|
+
await this.init();
|
|
43
|
+
const result = await this.client.rpc('tools/call', { name, arguments: args });
|
|
44
|
+
const text = result.content?.find(part => part.type === 'text')?.text ?? '';
|
|
45
|
+
if (result.isError)
|
|
46
|
+
throw new McpHttpError(text || `Tool ${name} failed.`, 200);
|
|
47
|
+
try {
|
|
48
|
+
return JSON.parse(text);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return text;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
withActor(args) {
|
|
55
|
+
return this.actor ? { ...args, actor: this.actor } : args;
|
|
56
|
+
}
|
|
57
|
+
async search(query, options = {}) {
|
|
58
|
+
const out = await this.callTool('search_nodes', {
|
|
59
|
+
query,
|
|
60
|
+
limit: options.limit,
|
|
61
|
+
bucket_id: options.bucketId,
|
|
62
|
+
thread_id: options.threadId,
|
|
63
|
+
node_type: options.nodeType,
|
|
64
|
+
});
|
|
65
|
+
return out.results;
|
|
66
|
+
}
|
|
67
|
+
/** One document in full: body, sections, links and near neighbours. */
|
|
68
|
+
fetch(nodeId, options = {}) {
|
|
69
|
+
return this.callTool('fetch_node', { node_id: nodeId, ...options });
|
|
70
|
+
}
|
|
71
|
+
listBuckets(options = {}) {
|
|
72
|
+
return this.callTool('list_buckets', options);
|
|
73
|
+
}
|
|
74
|
+
listThreads(options = {}) {
|
|
75
|
+
return this.callTool('list_threads', {
|
|
76
|
+
bucket_id: options.bucketId,
|
|
77
|
+
limit: options.limit,
|
|
78
|
+
after: options.after,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
listNodes(options = {}) {
|
|
82
|
+
return this.callTool('list_nodes', {
|
|
83
|
+
bucket_id: options.bucketId,
|
|
84
|
+
thread_id: options.threadId,
|
|
85
|
+
node_type: options.nodeType,
|
|
86
|
+
limit: options.limit,
|
|
87
|
+
after: options.after,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Every document, following the cursor. Stops at `max` so a large workspace cannot run away. */
|
|
91
|
+
async *iterateNodes(options = {}) {
|
|
92
|
+
let after;
|
|
93
|
+
let seen = 0;
|
|
94
|
+
const max = options.max ?? Infinity;
|
|
95
|
+
do {
|
|
96
|
+
const page = await this.listNodes({ ...options, limit: 200, after });
|
|
97
|
+
for (const item of page.items) {
|
|
98
|
+
if (seen++ >= max)
|
|
99
|
+
return;
|
|
100
|
+
yield item;
|
|
101
|
+
}
|
|
102
|
+
after = page.next_cursor ?? undefined;
|
|
103
|
+
} while (after);
|
|
104
|
+
}
|
|
105
|
+
createNode(input) {
|
|
106
|
+
return this.callTool('create_node', this.withActor({
|
|
107
|
+
thread_id: input.threadId,
|
|
108
|
+
slug: input.slug,
|
|
109
|
+
title: input.title,
|
|
110
|
+
summary: input.summary,
|
|
111
|
+
content: input.content,
|
|
112
|
+
node_type: input.nodeType,
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
updateNode(nodeId, input) {
|
|
116
|
+
return this.callTool('update_node', this.withActor({
|
|
117
|
+
node_id: nodeId,
|
|
118
|
+
title: input.title,
|
|
119
|
+
summary: input.summary,
|
|
120
|
+
content: input.content,
|
|
121
|
+
thread_id: input.threadId,
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
async deleteNode(nodeId) {
|
|
125
|
+
await this.callTool('delete_node', { node_id: nodeId });
|
|
126
|
+
}
|
|
127
|
+
usage() {
|
|
128
|
+
return this.callTool('usage');
|
|
129
|
+
}
|
|
130
|
+
}
|
package/cli/secrets.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { ensureStateDir } from './config.js';
|
|
5
|
+
import { stateDir } from './paths.js';
|
|
6
|
+
const SERVICE = 'gigarag';
|
|
7
|
+
function run(cmd, args, opts = {}) {
|
|
8
|
+
return spawnSync(cmd, args, {
|
|
9
|
+
encoding: 'utf8',
|
|
10
|
+
input: opts.input,
|
|
11
|
+
env: { ...process.env, ...opts.env },
|
|
12
|
+
timeout: 15_000,
|
|
13
|
+
windowsHide: true,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** Windows Credential Locker through Windows PowerShell 5.1, which ships with every install. */
|
|
17
|
+
class WindowsStore {
|
|
18
|
+
name = 'Windows Credential Manager';
|
|
19
|
+
script(body) {
|
|
20
|
+
return [
|
|
21
|
+
"$ErrorActionPreference='Stop'",
|
|
22
|
+
'[void][Windows.Security.Credentials.PasswordVault,Windows.Security.Credentials,ContentType=WindowsRuntime]',
|
|
23
|
+
'$v = New-Object Windows.Security.Credentials.PasswordVault',
|
|
24
|
+
body,
|
|
25
|
+
].join('\n');
|
|
26
|
+
}
|
|
27
|
+
ps(body, secret) {
|
|
28
|
+
return run('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', this.script(body)], { env: secret === undefined ? {} : { GIGARAG_SECRET: secret } });
|
|
29
|
+
}
|
|
30
|
+
get(account) {
|
|
31
|
+
const r = this.ps(`try { $c = $v.Retrieve('${SERVICE}','${account}'); $c.RetrievePassword(); [Console]::Out.Write($c.Password) } catch { exit 3 }`);
|
|
32
|
+
return r.status === 0 && r.stdout ? r.stdout : undefined;
|
|
33
|
+
}
|
|
34
|
+
set(account, value) {
|
|
35
|
+
this.delete(account);
|
|
36
|
+
const r = this.ps(`$c = New-Object Windows.Security.Credentials.PasswordCredential -ArgumentList '${SERVICE}','${account}',$env:GIGARAG_SECRET; $v.Add($c)`, value);
|
|
37
|
+
return r.status === 0;
|
|
38
|
+
}
|
|
39
|
+
delete(account) {
|
|
40
|
+
this.ps(`try { $c = $v.Retrieve('${SERVICE}','${account}'); $v.Remove($c) } catch { }`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
class MacStore {
|
|
44
|
+
name = 'macOS Keychain';
|
|
45
|
+
get(account) {
|
|
46
|
+
const r = run('security', ['find-generic-password', '-s', SERVICE, '-a', account, '-w']);
|
|
47
|
+
return r.status === 0 ? r.stdout.replace(/\n$/, '') : undefined;
|
|
48
|
+
}
|
|
49
|
+
set(account, value) {
|
|
50
|
+
// -U updates in place. The value is on the command line for the length of one
|
|
51
|
+
// call, which `security` offers no stdin form to avoid.
|
|
52
|
+
return run('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', account, '-w', value]).status === 0;
|
|
53
|
+
}
|
|
54
|
+
delete(account) {
|
|
55
|
+
run('security', ['delete-generic-password', '-s', SERVICE, '-a', account]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
class LinuxStore {
|
|
59
|
+
name = 'the system keyring (libsecret)';
|
|
60
|
+
get(account) {
|
|
61
|
+
const r = run('secret-tool', ['lookup', 'service', SERVICE, 'account', account]);
|
|
62
|
+
return r.status === 0 && r.stdout ? r.stdout : undefined;
|
|
63
|
+
}
|
|
64
|
+
set(account, value) {
|
|
65
|
+
return (run('secret-tool', ['store', '--label=GigaRAG', 'service', SERVICE, 'account', account], { input: value })
|
|
66
|
+
.status === 0);
|
|
67
|
+
}
|
|
68
|
+
delete(account) {
|
|
69
|
+
run('secret-tool', ['clear', 'service', SERVICE, 'account', account]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Windows refuses a rename over a file that another process has open for a moment, so it is tried a few times. */
|
|
73
|
+
function renameWithRetry(from, to) {
|
|
74
|
+
for (let attempt = 0;; attempt++) {
|
|
75
|
+
try {
|
|
76
|
+
renameSync(from, to);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const code = err.code;
|
|
81
|
+
if (attempt >= 5 || (code !== 'EPERM' && code !== 'EBUSY' && code !== 'EACCES'))
|
|
82
|
+
throw err;
|
|
83
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20 * (attempt + 1));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** A file only the owner can read. The fallback, and the test backend. */
|
|
88
|
+
export class FileStore {
|
|
89
|
+
dir;
|
|
90
|
+
name;
|
|
91
|
+
constructor(dir = stateDir) {
|
|
92
|
+
this.dir = dir;
|
|
93
|
+
this.name = 'a private file in ~/.gigarag';
|
|
94
|
+
}
|
|
95
|
+
file(account) {
|
|
96
|
+
return join(this.dir(), `${account}.secret`);
|
|
97
|
+
}
|
|
98
|
+
get(account) {
|
|
99
|
+
try {
|
|
100
|
+
return readFileSync(this.file(account), 'utf8') || undefined;
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
set(account, value) {
|
|
107
|
+
try {
|
|
108
|
+
ensureStateDir();
|
|
109
|
+
mkdirSync(this.dir(), { recursive: true, mode: 0o700 });
|
|
110
|
+
// Written beside the target and renamed over it: a crash leaves the old secret rather than half a
|
|
111
|
+
// new one, and a bridge reading while another refreshes never sees a torn file. The temp file is
|
|
112
|
+
// private from the moment it exists.
|
|
113
|
+
const target = this.file(account);
|
|
114
|
+
const temp = `${target}.${process.pid}.tmp`;
|
|
115
|
+
try {
|
|
116
|
+
writeFileSync(temp, value, { mode: 0o600 });
|
|
117
|
+
chmodSync(temp, 0o600);
|
|
118
|
+
renameWithRetry(temp, target);
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
try {
|
|
122
|
+
unlinkSync(temp);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
/* never created */
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
delete(account) {
|
|
136
|
+
try {
|
|
137
|
+
if (existsSync(this.file(account)))
|
|
138
|
+
unlinkSync(this.file(account));
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
/* already gone */
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The OS store, where there is one worth using.
|
|
147
|
+
*
|
|
148
|
+
* Not on Windows unless asked for with GIGARAG_KEYCHAIN=native. The Credential Locker has no
|
|
149
|
+
* command line, so reading it means starting PowerShell to call the vault API, and a program that
|
|
150
|
+
* starts PowerShell to read stored credentials is what behavioural antivirus engines are built to
|
|
151
|
+
* catch. Kaspersky flagged this very build as a credential-dumping tool for it. A key in a file under
|
|
152
|
+
* the user's profile, which Windows already restricts to that user, is what the AWS and npm CLIs use.
|
|
153
|
+
*/
|
|
154
|
+
function nativeStore() {
|
|
155
|
+
if (process.env['GIGARAG_KEYCHAIN'] === 'file')
|
|
156
|
+
return undefined;
|
|
157
|
+
if (process.platform === 'win32')
|
|
158
|
+
return process.env['GIGARAG_KEYCHAIN'] === 'native' ? new WindowsStore() : undefined;
|
|
159
|
+
if (process.platform === 'darwin')
|
|
160
|
+
return new MacStore();
|
|
161
|
+
const probe = spawnSync('secret-tool', ['--version'], { encoding: 'utf8', windowsHide: true });
|
|
162
|
+
return probe.error ? undefined : new LinuxStore();
|
|
163
|
+
}
|
|
164
|
+
const file = new FileStore();
|
|
165
|
+
/**
|
|
166
|
+
* Reads the file first, then the OS store. The file costs nothing to read, and it holds the
|
|
167
|
+
* secret whenever the OS store was unavailable or refused it at write time. Trying it first also
|
|
168
|
+
* means a bridge start spawns no process at all in the common case.
|
|
169
|
+
*/
|
|
170
|
+
export function getSecret(account) {
|
|
171
|
+
const fileValue = file.get(account);
|
|
172
|
+
if (fileValue !== undefined)
|
|
173
|
+
return { value: fileValue, store: file.name };
|
|
174
|
+
const native = nativeStore();
|
|
175
|
+
const nativeValue = native?.get(account);
|
|
176
|
+
return native && nativeValue !== undefined ? { value: nativeValue, store: native.name } : undefined;
|
|
177
|
+
}
|
|
178
|
+
/** Writes to the OS store, or to the private file when there is none. Returns where it landed. */
|
|
179
|
+
export function setSecret(account, value) {
|
|
180
|
+
const native = nativeStore();
|
|
181
|
+
if (native?.set(account, value)) {
|
|
182
|
+
file.delete(account);
|
|
183
|
+
return native.name;
|
|
184
|
+
}
|
|
185
|
+
if (!file.set(account, value))
|
|
186
|
+
throw new Error('Could not store the credential anywhere on this machine.');
|
|
187
|
+
return file.name;
|
|
188
|
+
}
|
|
189
|
+
export function deleteSecret(account) {
|
|
190
|
+
nativeStore()?.delete(account);
|
|
191
|
+
file.delete(account);
|
|
192
|
+
}
|
package/cli/secureUrl.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The endpoint must be https, except a loopback address for local testing. The key is sent to
|
|
3
|
+
* whatever this names, so a config file, an environment variable or a flag that could point it
|
|
4
|
+
* at plain http or another host would hand the key to anyone on the path.
|
|
5
|
+
*/
|
|
6
|
+
export function assertSecureUrl(url, what = 'The GigaRAG endpoint') {
|
|
7
|
+
let parsed;
|
|
8
|
+
try {
|
|
9
|
+
parsed = new URL(url);
|
|
10
|
+
}
|
|
11
|
+
catch {
|
|
12
|
+
throw new Error(`${what} is not a valid URL: ${url}`);
|
|
13
|
+
}
|
|
14
|
+
const loopback = ['127.0.0.1', 'localhost', '[::1]', '::1'].includes(parsed.hostname);
|
|
15
|
+
if (parsed.protocol === 'https:' || (parsed.protocol === 'http:' && loopback))
|
|
16
|
+
return url;
|
|
17
|
+
throw new Error(`${what} must use https (http is only allowed for localhost): ${url}`);
|
|
18
|
+
}
|