@skyphusion/search-mcp 0.1.2

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.
@@ -0,0 +1,141 @@
1
+ // AI Search ingest planning: which git paths need a .txt suffix on the R2 key.
2
+ //
3
+ // Cloudflare AI Search keys off the object name extension. Source we already remap
4
+ // (.ts, .jsx, ...) plus extensionless text (Dockerfile) and text files whose
5
+ // extension is not in the supported list (.service, .example, ...) upload as
6
+ // <path>.txt so the indexer treats them as plain text.
7
+
8
+ import { extname, basename } from "node:path";
9
+
10
+ /** Extensions AI Search ingests natively (plain + rich). See CF data-source docs. */
11
+ export const AI_SEARCH_NATIVE_EXT = new Set([
12
+ ".txt", ".rst",
13
+ ".log",
14
+ ".ini", ".conf", ".properties", ".toml",
15
+ ".markdown", ".md", ".mdx", ".mdoc",
16
+ ".tex", ".latex",
17
+ ".sh", ".bat", ".ps1",
18
+ ".sgml",
19
+ ".json",
20
+ ".sql",
21
+ ".yaml", ".yml",
22
+ ".css",
23
+ ".js",
24
+ ".php",
25
+ ".py",
26
+ ".rb",
27
+ ".java",
28
+ ".c", ".cpp", ".cxx", ".h", ".hpp",
29
+ ".go",
30
+ ".rs",
31
+ ".swift",
32
+ ".dart",
33
+ ".el",
34
+ ".pdf",
35
+ ".html", ".htm",
36
+ ".xml",
37
+ ".xlsx", ".xlsm", ".xlsb", ".xls", ".et", ".docx",
38
+ ".ods", ".odt",
39
+ ".csv",
40
+ ".numbers",
41
+ ]);
42
+
43
+ /** Basenames with no extname() that AI Search lists as native config types. */
44
+ export const AI_SEARCH_NATIVE_BASENAMES = new Set([
45
+ ".gitignore",
46
+ ".editorconfig",
47
+ ".dockerignore",
48
+ ".env",
49
+ ]);
50
+
51
+ /** Always append .txt regardless of content sniff (TS/JSX + common infra/config suffixes). */
52
+ export const REMAP_TO_TXT_EXPLICIT = new Set([
53
+ ".ts", ".tsx", ".mts", ".cts", ".jsx", ".astro", ".vue", ".svelte",
54
+ ".mjs", ".cjs",
55
+ ".jsonc",
56
+ ".example", ".sample", ".template", ".tmpl", ".local",
57
+ ".webmanifest", ".manifest",
58
+ ".service", ".socket", ".timer", ".mount", ".path", ".target", ".slice", ".automount",
59
+ ".v4", ".v6",
60
+ ]);
61
+
62
+ export const DOC_EXT = new Set([".md", ".markdown", ".mdx", ".mdoc", ".rst", ".txt", ".pdf"]);
63
+
64
+ export const CONTENT_TYPE = {
65
+ ".txt": "text/plain", ".rst": "text/plain",
66
+ ".md": "text/markdown", ".markdown": "text/markdown", ".mdx": "text/markdown",
67
+ ".json": "application/json", ".yaml": "application/x-yaml", ".yml": "application/x-yaml",
68
+ ".py": "text/x-python", ".go": "text/x-go", ".rs": "text/rust",
69
+ ".js": "application/javascript", ".mjs": "application/javascript", ".cjs": "application/javascript",
70
+ ".sh": "application/x-sh", ".bash": "application/x-sh",
71
+ ".sql": "application/sql", ".toml": "text/plain", ".ini": "text/plain", ".conf": "text/plain",
72
+ ".html": "text/html", ".htm": "text/html", ".xml": "application/xml",
73
+ ".css": "text/css", ".csv": "text/csv", ".pdf": "application/pdf",
74
+ };
75
+
76
+ export function fileExt(relPath) {
77
+ return extname(basename(relPath)).toLowerCase();
78
+ }
79
+
80
+ /** True when the first bytes look like text (no NUL, mostly valid UTF-8). */
81
+ export function isLikelyText(buf) {
82
+ if (!buf || buf.length === 0) return true;
83
+ if (buf.includes(0)) return false;
84
+ const s = buf.toString("utf8");
85
+ const bad = (s.match(/\uFFFD/g) || []).length;
86
+ return bad <= Math.max(1, Math.floor(buf.length * 0.02));
87
+ }
88
+
89
+ export function isNativeIngestPath(relPath) {
90
+ const base = basename(relPath);
91
+ const ext = fileExt(relPath);
92
+ return AI_SEARCH_NATIVE_EXT.has(ext) || AI_SEARCH_NATIVE_BASENAMES.has(base);
93
+ }
94
+
95
+ /**
96
+ * Should the R2 object key get a trailing .txt?
97
+ * Native paths: false. Explicit remap extensions: true. Otherwise: true when sample is text.
98
+ */
99
+ export function shouldRemapToTxt(relPath, sample) {
100
+ if (isNativeIngestPath(relPath)) return false;
101
+ const ext = fileExt(relPath);
102
+ if (REMAP_TO_TXT_EXPLICIT.has(ext)) return true;
103
+ return isLikelyText(sample);
104
+ }
105
+
106
+ /** True when the file should be uploaded (native as-is, or remapped text). */
107
+ export function isIngestible(relPath, sample) {
108
+ if (isNativeIngestPath(relPath)) return true;
109
+ return shouldRemapToTxt(relPath, sample);
110
+ }
111
+
112
+ export function ingestObjectKey(repo, relPath, remapped) {
113
+ return `${repo}/${relPath}${remapped ? ".txt" : ""}`;
114
+ }
115
+
116
+ export function ingestContentType(relPath, remapped) {
117
+ const ext = remapped ? ".txt" : fileExt(relPath);
118
+ return CONTENT_TYPE[ext] || "text/plain";
119
+ }
120
+
121
+ export function ingestKind(relPath) {
122
+ const ext = fileExt(relPath);
123
+ return DOC_EXT.has(ext) || basename(relPath).startsWith("README") ? "doc" : "code";
124
+ }
125
+
126
+ /**
127
+ * Per-repo corpus exclusion (targets.json `excludePaths`). An entry is a repo-relative
128
+ * path: with a trailing "/" it excludes the whole subtree; without, it excludes that
129
+ * exact file or the subtree rooted at that name.
130
+ */
131
+ export function isExcludedPath(relPath, prefixes) {
132
+ for (const p of prefixes || []) {
133
+ if (!p) continue;
134
+ if (p.endsWith("/")) {
135
+ if (relPath.startsWith(p)) return true;
136
+ } else if (relPath === p || relPath.startsWith(p + "/")) {
137
+ return true;
138
+ }
139
+ }
140
+ return false;
141
+ }
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env node
2
+ // Corpus sync runner: isolated clone root -> scripts/sync.mjs -> optional reindex.
3
+ //
4
+ // Usage:
5
+ // node scripts/sync-runner.mjs # all targets in targets.json
6
+ // node scripts/sync-runner.mjs corpus # one target
7
+ // node scripts/sync-runner.mjs --dry-run
8
+ // node scripts/sync-runner.mjs --no-reindex
9
+ // node scripts/sync-runner.mjs --no-github-verify
10
+ //
11
+ // Env:
12
+ // CORPUS_ROOT, CORPUS_GIT_ORG (required for clone)
13
+ // GITHUB_TOKEN | GH_TOKEN
14
+ // R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_ACCOUNT_ID | CLOUDFLARE_ACCOUNT_ID
15
+ // CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID (reindex)
16
+
17
+ import { execFileSync } from "node:child_process";
18
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
19
+ import { join, dirname } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+ import {
22
+ resolveTargetsPath,
23
+ targetsHelp,
24
+ } from "./config-paths.mjs";
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url));
27
+ const REPO = join(HERE, "..");
28
+
29
+ /** Parse argv into a normalized options object (pure). */
30
+ export function parseArgs(argv, allTargetNames = []) {
31
+ const flags = new Set(argv.filter((a) => a.startsWith("--")));
32
+ const positional = argv.filter((a) => !a.startsWith("--"));
33
+ const requested = positional[0];
34
+ return {
35
+ targets: requested ? [requested] : [...allTargetNames],
36
+ dryRun: flags.has("--dry-run"),
37
+ reindex: !flags.has("--no-reindex") && !flags.has("--dry-run"),
38
+ passThrough: [...flags].filter((f) => f === "--dry-run" || f === "--no-github-verify"),
39
+ };
40
+ }
41
+
42
+ export function reposForTargets(cfg, targets) {
43
+ const seen = new Set();
44
+ const repos = [];
45
+ for (const t of targets) {
46
+ const target = cfg?.targets?.[t];
47
+ if (!target) throw new Error(`unknown target ${t}`);
48
+ for (const r of target.repos || []) {
49
+ if (!seen.has(r)) {
50
+ seen.add(r);
51
+ repos.push(r);
52
+ }
53
+ }
54
+ }
55
+ return repos;
56
+ }
57
+
58
+ export function instancesForTargets(cfg, targets) {
59
+ return targets.map((t) => cfg.targets[t].instance);
60
+ }
61
+
62
+ export function gitAuthArgs(token) {
63
+ if (!token) return [];
64
+ const basic = Buffer.from(`x-access-token:${token}`).toString("base64");
65
+ return ["-c", `http.https://github.com/.extraheader=AUTHORIZATION: basic ${basic}`];
66
+ }
67
+
68
+ export function scrubSecrets(s) {
69
+ return String(s)
70
+ .replace(/AUTHORIZATION:\s*basic\s+\S+/gi, "AUTHORIZATION: basic [REDACTED]")
71
+ .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]");
72
+ }
73
+
74
+ export function describeExit(err) {
75
+ const status = err && (err.status ?? err.code);
76
+ return scrubSecrets(`exit ${status ?? "unknown"}`);
77
+ }
78
+
79
+ function repoUrl(org, repo) {
80
+ return `https://github.com/${org}/${repo}.git`;
81
+ }
82
+
83
+ function git(args, opts = {}) {
84
+ return execFileSync("git", args, { stdio: ["ignore", "pipe", "inherit"], ...opts })
85
+ .toString()
86
+ .trim();
87
+ }
88
+
89
+ function syncRepoTree(org, repo, root, authArgs) {
90
+ const dir = join(root, repo);
91
+ const url = repoUrl(org, repo);
92
+ if (!existsSync(join(dir, ".git"))) {
93
+ git([...authArgs, "clone", "--quiet", url, dir]);
94
+ } else {
95
+ git([...authArgs, "-C", dir, "remote", "set-url", "origin", url]);
96
+ git([...authArgs, "-C", dir, "fetch", "--prune", "--quiet", "origin"]);
97
+ }
98
+ git([...authArgs, "-C", dir, "remote", "set-head", "origin", "--auto"]);
99
+ let head;
100
+ try {
101
+ head = git(["-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
102
+ } catch {
103
+ head = "origin/main";
104
+ }
105
+ git(["-C", dir, "reset", "--hard", "--quiet", head]);
106
+ git(["-C", dir, "clean", "-fdq"]);
107
+ }
108
+
109
+ function reindexInstance(instance) {
110
+ execFileSync("npx", ["wrangler", "ai-search", "jobs", "create", instance, "--json"], {
111
+ stdio: "inherit",
112
+ cwd: REPO,
113
+ });
114
+ }
115
+
116
+ export function run(plan, deps) {
117
+ const { repos, targets, instances, reindex } = plan;
118
+ const { cloneTree, runSync, runReindex, log = console.log, error = console.error } = deps;
119
+
120
+ log(`Refreshing ${repos.length} repo tree(s) ...`);
121
+ const failedClones = [];
122
+ for (const repo of repos) {
123
+ try {
124
+ cloneTree(repo);
125
+ log(` ok ${repo}`);
126
+ } catch (err) {
127
+ failedClones.push(repo);
128
+ error(` !! ${repo}: git failed (${describeExit(err)})`);
129
+ }
130
+ }
131
+
132
+ if (failedClones.length) {
133
+ error(
134
+ `BLOCKER: ${failedClones.length} clone/fetch failure(s) [${failedClones.join(", ")}]; ` +
135
+ "aborting before sync so no repo corpus is pruned or synced stale.",
136
+ );
137
+ return { exitCode: 1, synced: false };
138
+ }
139
+
140
+ for (const target of targets) {
141
+ log(`\n=== sync ${target} ===`);
142
+ runSync(target);
143
+ }
144
+
145
+ const failedReindex = [];
146
+ if (reindex) {
147
+ for (const instance of instances) {
148
+ log(`\n=== reindex ${instance} ===`);
149
+ try {
150
+ runReindex(instance);
151
+ } catch (err) {
152
+ failedReindex.push(instance);
153
+ error(` !! reindex failed for ${instance} (${describeExit(err)})`);
154
+ }
155
+ }
156
+ } else {
157
+ log("\nReindex skipped; AI Search will pick up R2 changes on its next schedule.");
158
+ }
159
+
160
+ if (failedReindex.length) {
161
+ error(`BLOCKER: ${failedReindex.length} reindex failure(s) [${failedReindex.join(", ")}].`);
162
+ return { exitCode: 1, synced: true };
163
+ }
164
+ log("\nCorpus sync complete.");
165
+ return { exitCode: 0, synced: true };
166
+ }
167
+
168
+ function loadConfig() {
169
+ const path = resolveTargetsPath(HERE);
170
+ if (!path) {
171
+ console.error(targetsHelp(HERE));
172
+ process.exit(2);
173
+ }
174
+ return JSON.parse(readFileSync(path, "utf8"));
175
+ }
176
+
177
+ function main() {
178
+ const cfg = loadConfig();
179
+ const allTargets = Object.keys(cfg.targets || {});
180
+ const opts = parseArgs(process.argv.slice(2), allTargets);
181
+ const repos = reposForTargets(cfg, opts.targets);
182
+ const instances = instancesForTargets(cfg, opts.targets);
183
+ const root = process.env.CORPUS_ROOT || join(REPO, ".corpus");
184
+ const org = process.env.CORPUS_GIT_ORG || "";
185
+ if (!org) {
186
+ console.error("Set CORPUS_GIT_ORG to the GitHub org that owns the repos in targets.json.");
187
+ process.exit(2);
188
+ }
189
+ const token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN || "";
190
+ const authArgs = gitAuthArgs(token);
191
+
192
+ mkdirSync(root, { recursive: true });
193
+ console.log(`Corpus root: ${root}`);
194
+ console.log(`Org: ${org}`);
195
+
196
+ const result = run(
197
+ { repos, targets: opts.targets, instances, reindex: opts.reindex },
198
+ {
199
+ cloneTree: (repo) => syncRepoTree(org, repo, root, authArgs),
200
+ runSync: (target) =>
201
+ execFileSync("node", [join(HERE, "sync.mjs"), target, ...opts.passThrough], {
202
+ stdio: "inherit",
203
+ cwd: REPO,
204
+ env: { ...process.env, SYNC_REPO_ROOT: root },
205
+ }),
206
+ runReindex: reindexInstance,
207
+ },
208
+ );
209
+ process.exit(result.exitCode);
210
+ }
211
+
212
+ const invokedDirectly = process.argv[1] && process.argv[1] === fileURLToPath(import.meta.url);
213
+ if (invokedDirectly) main();
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ // Git-to-R2 sync for Cloudflare AI Search.
3
+ //
4
+ // Walks git-tracked files for each repo in a target, filters binaries / secrets /
5
+ // build noise, remaps source AI Search cannot natively index to .txt, attaches
6
+ // metadata, uploads to the target R2 bucket, and prunes stale objects (mirror).
7
+ //
8
+ // Usage:
9
+ // node scripts/sync.mjs corpus
10
+ // node scripts/sync.mjs corpus --dry-run
11
+ // node scripts/sync.mjs public --no-github-verify
12
+ //
13
+ // Env:
14
+ // R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY (required unless --dry-run)
15
+ // R2_ACCOUNT_ID or CLOUDFLARE_ACCOUNT_ID (to build the S3 endpoint)
16
+ // R2_S3_ENDPOINT (optional override)
17
+ // SEARCH_MCP_TARGETS (optional path to targets.json)
18
+ // SYNC_REPO_ROOT (optional clone root)
19
+ // GITHUB_TOKEN or GH_TOKEN (optional; live visibility check for public target)
20
+ // CORPUS_GIT_ORG (required for GitHub visibility check)
21
+ // SYNC_SKIP_GITHUB_VERIFY=1 (optional)
22
+
23
+ import { execFileSync } from "node:child_process";
24
+ import { readFileSync, existsSync, statSync, openSync, readSync, closeSync } from "node:fs";
25
+ import { join, dirname, basename } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import {
28
+ assertPublicCorpusBoundary,
29
+ assertPublicGithubVisibility,
30
+ } from "./corpus-boundary.mjs";
31
+ import {
32
+ isIngestible,
33
+ shouldRemapToTxt,
34
+ isNativeIngestPath,
35
+ ingestObjectKey,
36
+ ingestContentType,
37
+ ingestKind,
38
+ fileExt,
39
+ isExcludedPath,
40
+ } from "./sync-ingest.mjs";
41
+ import {
42
+ resolveTargetsPath,
43
+ defaultRepoRoot,
44
+ targetsHelp,
45
+ } from "./config-paths.mjs";
46
+
47
+ const HERE = dirname(fileURLToPath(import.meta.url));
48
+ const TARGETS_PATH = resolveTargetsPath(HERE);
49
+ const REPO_ROOT = defaultRepoRoot(HERE, TARGETS_PATH);
50
+ const MAX_BYTES = 4 * 1024 * 1024;
51
+
52
+ const args = process.argv.slice(2);
53
+ const DRY = args.includes("--dry-run");
54
+ const SKIP_GITHUB = args.includes("--no-github-verify") || process.env.SYNC_SKIP_GITHUB_VERIFY === "1";
55
+ const targetName = args.find((a) => !a.startsWith("--"));
56
+
57
+ const SKIP_EXT = new Set([
58
+ ".woff", ".woff2", ".ttf", ".otf", ".eot", ".ico", ".icns",
59
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".tiff", ".psd",
60
+ ".mp4", ".mov", ".webm", ".mkv", ".avi", ".mp3", ".wav", ".flac", ".ogg",
61
+ ".zip", ".gz", ".tgz", ".tar", ".bz2", ".xz", ".7z", ".rar",
62
+ ".bin", ".exe", ".dll", ".so", ".dylib", ".wasm", ".o", ".a",
63
+ ".pt", ".pth", ".safetensors", ".onnx", ".gguf", ".ckpt", ".npy", ".npz", ".parquet",
64
+ ".age", ".gpg", ".enc", ".pem", ".crt", ".p12", ".pfx", ".jks",
65
+ ".map", ".lock",
66
+ ]);
67
+
68
+ const SKIP_BASENAME = new Set([
69
+ "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
70
+ "poetry.lock", "Cargo.lock", "uv.lock", "bun.lockb",
71
+ ]);
72
+
73
+ const SKIP_DIR_SEGMENT = new Set([
74
+ "node_modules", "dist", "build", ".wrangler", "coverage", ".git",
75
+ "vendor", ".venv", "venv", "target", "__pycache__", ".next", ".turbo", ".cache",
76
+ ]);
77
+
78
+ function isEnvSecret(base) {
79
+ if (!/^\.env($|\.)/.test(base) && !/\.env$/.test(base)) return false;
80
+ return !/(example|sample|template)/i.test(base);
81
+ }
82
+
83
+ function shouldSkip(relPath) {
84
+ const segs = relPath.split("/");
85
+ if (segs.some((s) => SKIP_DIR_SEGMENT.has(s))) return true;
86
+ const base = basename(relPath);
87
+ if (SKIP_BASENAME.has(base)) return true;
88
+ if (isEnvSecret(base)) return true;
89
+ const ext = fileExt(relPath);
90
+ if (SKIP_EXT.has(ext)) return true;
91
+ if (base.endsWith(".min.js")) return true;
92
+ return false;
93
+ }
94
+
95
+ function trackedFiles(repoDir) {
96
+ const out = execFileSync("git", ["ls-files", "-z"], {
97
+ cwd: repoDir,
98
+ maxBuffer: 256 * 1024 * 1024,
99
+ });
100
+ return out.toString("utf8").split("\0").filter(Boolean);
101
+ }
102
+
103
+ const TEXT_SAMPLE_BYTES = 8192;
104
+
105
+ function readTextSample(abs, size) {
106
+ const len = Math.min(TEXT_SAMPLE_BYTES, size);
107
+ if (len === 0) return Buffer.alloc(0);
108
+ const fd = openSync(abs, "r");
109
+ try {
110
+ const buf = Buffer.alloc(len);
111
+ readSync(fd, buf, 0, len, 0);
112
+ return buf;
113
+ } finally {
114
+ closeSync(fd);
115
+ }
116
+ }
117
+
118
+ function loadConfig() {
119
+ if (!TARGETS_PATH) {
120
+ console.error(targetsHelp(HERE));
121
+ process.exit(2);
122
+ }
123
+ return JSON.parse(readFileSync(TARGETS_PATH, "utf8"));
124
+ }
125
+
126
+ function planRepo(repo, excludePrefixes = []) {
127
+ const repoDir = join(REPO_ROOT, repo);
128
+ if (!existsSync(repoDir)) {
129
+ console.warn(` ! skip ${repo}: not cloned at ${repoDir}`);
130
+ return [];
131
+ }
132
+ const items = [];
133
+ for (const rel of trackedFiles(repoDir)) {
134
+ if (shouldSkip(rel)) continue;
135
+ if (isExcludedPath(rel, excludePrefixes)) continue;
136
+ const abs = join(repoDir, rel);
137
+ let size;
138
+ try {
139
+ size = statSync(abs).size;
140
+ } catch {
141
+ continue;
142
+ }
143
+ if (size > MAX_BYTES) {
144
+ console.warn(` ! skip ${repo}/${rel}: ${(size / 1048576).toFixed(1)} MB over 4 MB`);
145
+ continue;
146
+ }
147
+ const sample = readTextSample(abs, size);
148
+ if (!isIngestible(rel, sample)) continue;
149
+ const remapped = !isNativeIngestPath(rel) && shouldRemapToTxt(rel, sample);
150
+ const origExt = fileExt(rel);
151
+ const key = ingestObjectKey(repo, rel, remapped);
152
+ items.push({
153
+ abs,
154
+ key,
155
+ contentType: ingestContentType(rel, remapped),
156
+ metadata: {
157
+ repo,
158
+ path: rel.slice(0, 500),
159
+ lang: (origExt.replace(".", "") || "text").slice(0, 60),
160
+ kind: ingestKind(rel),
161
+ },
162
+ });
163
+ }
164
+ return items;
165
+ }
166
+
167
+ async function main() {
168
+ if (!targetName) {
169
+ console.error("usage: node scripts/sync.mjs <target> [--dry-run] [--no-github-verify]");
170
+ process.exit(2);
171
+ }
172
+ const cfg = loadConfig();
173
+ const target = cfg.targets[targetName];
174
+ if (!target) {
175
+ console.error(`unknown target '${targetName}'. known: ${Object.keys(cfg.targets).join(", ")}`);
176
+ process.exit(2);
177
+ }
178
+
179
+ assertPublicCorpusBoundary(cfg, targetName);
180
+ if (targetName === "public" && !SKIP_GITHUB) {
181
+ const gh = await assertPublicGithubVisibility(cfg);
182
+ if (gh.skipped) {
183
+ console.warn(
184
+ " ! GITHUB_TOKEN / GH_TOKEN unset: skipping live GitHub visibility check " +
185
+ "(overlap check passed). Set a token or pass --no-github-verify to silence.",
186
+ );
187
+ } else {
188
+ console.log(` GitHub visibility ok for ${gh.checked.length} public-target repos.`);
189
+ }
190
+ } else if (targetName === "public" && SKIP_GITHUB) {
191
+ console.warn(" ! --no-github-verify: live GitHub visibility check skipped.");
192
+ }
193
+
194
+ console.log(`Target '${targetName}' -> bucket ${target.bucket} (instance ${target.instance})`);
195
+ console.log(`Repo root: ${REPO_ROOT}${DRY ? " [DRY RUN]" : ""}`);
196
+
197
+ const plan = [];
198
+ for (const repo of target.repos) {
199
+ const items = planRepo(repo, cfg.excludePaths?.[repo]);
200
+ if (items.length) console.log(` + ${repo}: ${items.length} files`);
201
+ plan.push(...items);
202
+ }
203
+ console.log(`Planned ${plan.length} objects for ${target.bucket}.`);
204
+
205
+ if (DRY) {
206
+ for (const it of plan.slice(0, 25)) console.log(` ${it.key}`);
207
+ if (plan.length > 25) console.log(` ... and ${plan.length - 25} more`);
208
+ console.log("Dry run: no uploads, no prune.");
209
+ return;
210
+ }
211
+
212
+ const { S3Client, PutObjectCommand, ListObjectsV2Command, DeleteObjectsCommand } =
213
+ await import("@aws-sdk/client-s3");
214
+
215
+ const accountId = process.env.R2_ACCOUNT_ID || process.env.CLOUDFLARE_ACCOUNT_ID;
216
+ const endpoint =
217
+ process.env.R2_S3_ENDPOINT ||
218
+ (accountId ? `https://${accountId}.r2.cloudflarestorage.com` : undefined);
219
+ if (!endpoint) {
220
+ console.error("Set R2_S3_ENDPOINT or R2_ACCOUNT_ID / CLOUDFLARE_ACCOUNT_ID.");
221
+ process.exit(2);
222
+ }
223
+ if (!process.env.R2_ACCESS_KEY_ID || !process.env.R2_SECRET_ACCESS_KEY) {
224
+ console.error("Set R2_ACCESS_KEY_ID and R2_SECRET_ACCESS_KEY.");
225
+ process.exit(2);
226
+ }
227
+
228
+ const s3 = new S3Client({
229
+ region: "auto",
230
+ endpoint,
231
+ credentials: {
232
+ accessKeyId: process.env.R2_ACCESS_KEY_ID,
233
+ secretAccessKey: process.env.R2_SECRET_ACCESS_KEY,
234
+ },
235
+ });
236
+ const Bucket = target.bucket;
237
+
238
+ const seen = new Set();
239
+ let up = 0;
240
+ let idx = 0;
241
+ const concurrency = Math.max(1, Number(process.env.UPLOAD_CONCURRENCY || 12));
242
+ async function uploadWorker() {
243
+ while (idx < plan.length) {
244
+ const it = plan[idx++];
245
+ await s3.send(
246
+ new PutObjectCommand({
247
+ Bucket,
248
+ Key: it.key,
249
+ Body: readFileSync(it.abs),
250
+ ContentType: it.contentType,
251
+ Metadata: it.metadata,
252
+ }),
253
+ );
254
+ seen.add(it.key);
255
+ if (++up % 200 === 0) console.log(` uploaded ${up}/${plan.length}`);
256
+ }
257
+ }
258
+ await Promise.all(
259
+ Array.from({ length: Math.min(concurrency, plan.length) }, uploadWorker),
260
+ );
261
+ console.log(`Uploaded ${up} objects.`);
262
+
263
+ const stale = [];
264
+ let ContinuationToken;
265
+ do {
266
+ const res = await s3.send(
267
+ new ListObjectsV2Command({ Bucket, ContinuationToken }),
268
+ );
269
+ for (const obj of res.Contents || []) {
270
+ if (obj.Key && !seen.has(obj.Key)) stale.push({ Key: obj.Key });
271
+ }
272
+ ContinuationToken = res.IsTruncated ? res.NextContinuationToken : undefined;
273
+ } while (ContinuationToken);
274
+
275
+ for (let i = 0; i < stale.length; i += 1000) {
276
+ const batch = stale.slice(i, i + 1000);
277
+ await s3.send(
278
+ new DeleteObjectsCommand({ Bucket, Delete: { Objects: batch } }),
279
+ );
280
+ }
281
+ console.log(`Pruned ${stale.length} stale objects.`);
282
+ console.log("Done. AI Search will re-index on its next sync.");
283
+ }
284
+
285
+ main().catch((err) => {
286
+ console.error(err);
287
+ process.exit(1);
288
+ });
@@ -0,0 +1,14 @@
1
+ {
2
+ "_comment": "Copy to targets.json (./targets.json for npm CLI, or scripts/targets.json in a git clone). repos are directory names under SYNC_REPO_ROOT (default: cwd for npm, parent of this repo for dev clone). Only git-tracked files are uploaded.",
3
+ "excludePaths": {
4
+ "example-repo": ["vendor/private-notes/"]
5
+ },
6
+ "restrictedRepos": [],
7
+ "targets": {
8
+ "corpus": {
9
+ "instance": "my-ai-search-instance",
10
+ "bucket": "my-search-corpus",
11
+ "repos": ["example-repo"]
12
+ }
13
+ }
14
+ }