syndai 0.6.0 → 0.7.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/CHANGELOG.md +23 -0
- package/README.md +27 -0
- package/package.json +1 -1
- package/src/changes.js +32 -0
- package/src/main.js +26 -1
- package/src/projects.js +3 -2
- package/src/routes.js +6 -2
- package/src/settings.js +11 -5
- package/src/sync.js +389 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the `syndai` npm package. Versions follow semver.
|
|
4
4
|
|
|
5
|
+
## 0.7.0
|
|
6
|
+
|
|
7
|
+
Land your own branch, and keep a folder in a project's Knowledge.
|
|
8
|
+
|
|
9
|
+
- `land [branch] [--app A] [--no-wait]` lands your pushed `people/<you>/<name>` branch on a project
|
|
10
|
+
Syndai hosts: the same checks, review and landing slot as Syndai's own changes, then it watches
|
|
11
|
+
the landing. It prints any live work on the same files first. A `people/<you>/t12` branch
|
|
12
|
+
delivers task t12 when it goes live.
|
|
13
|
+
- `sync <dir> [--app A] [--watch]` keeps a folder (an Obsidian vault, any folder of notes) in
|
|
14
|
+
the project's Knowledge, where its runs and chat search it. Each sync sends every file's
|
|
15
|
+
sha256 and pushes only the new or changed ones: text files (`.md`, `.txt`, `.csv`, `.json`,
|
|
16
|
+
`.yaml` and the like) as their text, any other file by name only. A file deleted here is
|
|
17
|
+
removed there. Hidden files and folders (`.obsidian/`, `.trash/`, `.git/`), `node_modules/`,
|
|
18
|
+
symlinks, files over 10 MB and the patterns in `<dir>/.syndaiignore` stay out.
|
|
19
|
+
- `--watch` syncs again a second after the last change, one sync at a time, until Ctrl-C; an
|
|
20
|
+
edit is searchable within seconds. Only changed files are read again.
|
|
21
|
+
- A paused folder, or a folder of the same name someone else syncs, is refused (exit 1) with the
|
|
22
|
+
server's sentence. It needs `coding:knowledge:sync` (owners and admins); on a terminal the
|
|
23
|
+
first sync asks for it in the browser.
|
|
24
|
+
- Picking the project from the git remote now matches `github.com/<owner>/<repo>` built from the
|
|
25
|
+
project's name. The API no longer sends repo URLs. Versions up to 0.6.0 read those URLs, so
|
|
26
|
+
they skip this rule and fall through to the only project or the picker.
|
|
27
|
+
|
|
5
28
|
## 0.6.0
|
|
6
29
|
|
|
7
30
|
The steps that stay in the browser are one command away, and an agent can sign up.
|
package/README.md
CHANGED
|
@@ -44,9 +44,11 @@ README is for whoever works on the package.
|
|
|
44
44
|
| `answer <run> [--input FILE\|-]` | answer an open question (`--input`: `{"entries":[...]}` from a file or stdin) |
|
|
45
45
|
| `review <run> [--retry \| --close]` | see why a change is held, then approve it in the browser |
|
|
46
46
|
| `go-live`, `revert`, `cancel <run>` | land actions |
|
|
47
|
+
| `land [branch] [--app A] [--no-wait]` | on a project Syndai hosts, check, review and land your pushed `people/<you>/<name>` branch (default: the current one) |
|
|
47
48
|
| `publish [--app A]` | publish (or redeploy) the project as a hosted app and print its live URL (`coding:apps:publish`) |
|
|
48
49
|
| `connect <provider>` | print, and open on a terminal, the workspace page that connects it: `github` -> `settings#repositories`, `sentry` -> `sentry`, the plan logins, OpenRouter, Slack and social accounts -> `connections?connect=<provider>`. Finish in the browser; no key reads connection status |
|
|
49
50
|
| `billing [--open]`, `packs`, `buy <pack>`, `plan <tier> [--interval month\|year] [--country CC]`, `card`, `cancel` | plan, balance (packs + included left - holds, as the portal shows it) and the billing page; `buy`/`plan` print and open a checkout a person pays (nothing is charged until then; `--country` else the locale's region else US prices it); `card`/`cancel` and an existing plan go to the billing page |
|
|
51
|
+
| `sync <dir> [--app A] [--watch]` | keep a folder (an Obsidian vault) in the project's Knowledge (`coding:knowledge:sync`): see [Folder sync](#folder-sync) |
|
|
50
52
|
| `status [--app A]` | what is running, and everything that needs you |
|
|
51
53
|
| `settings [--app A] [get \| set k=v...]` | `autonomy`, `feature_requests`, `bug_scan`, `protected_paths` (`+=` adds) |
|
|
52
54
|
| `mcp install claude\|cursor\|codex` | add the Syndai MCP server to that tool's user config, with its own key |
|
|
@@ -79,6 +81,30 @@ Exit codes: 0 done, 1 failed/reverted/cancelled, 2 usage or no project, 3 needs
|
|
|
79
81
|
4 live but unverified, 5 auth/scope/plan, 6 `--timeout` reached, 7 service
|
|
80
82
|
unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
|
|
81
83
|
|
|
84
|
+
## Folder sync
|
|
85
|
+
|
|
86
|
+
`syndai sync <dir>` (`src/sync.js`, SPEC-knowledge-sources K6) walks `<dir>`, hashes every
|
|
87
|
+
file and sends the manifest (`compareKnowledgeFolder`, at most 20,000 `{path, sha256}`). The
|
|
88
|
+
server deletes the files missing from it and answers `need`, the paths new or changed; those go
|
|
89
|
+
up through `pushKnowledgeFolderFiles` in batches of at most 500 files and 8 MB, each read again
|
|
90
|
+
so the hash sent matches the bytes. Text suffixes (the server's `TEXT_SUFFIXES`) carry their
|
|
91
|
+
UTF-8 `content`; any other file goes with `content: null` (recorded by name). The folder's name
|
|
92
|
+
is the basename of `<dir>`; paths are `/`-separated and relative to it. Both calls are
|
|
93
|
+
idempotent (a manifest is a compare, a push upserts by path), so they retry like a GET.
|
|
94
|
+
|
|
95
|
+
Skipped: names starting with `.` (so `.obsidian/`, `.trash/`, `.git/`, `.syndaiignore` itself),
|
|
96
|
+
`node_modules/`, symlinks (never followed), files over 10 MB (left out of the manifest, listed
|
|
97
|
+
as skipped) and `<dir>/.syndaiignore` patterns: one a line, `#` comments, `dir/` for folders,
|
|
98
|
+
a pattern with a `/` anchored at the root, `*`, `?` and `**`; no `!`. A 409 (paused, or another
|
|
99
|
+
person's folder of that name) prints the detail and exits 1; per-file `failed` rows are printed
|
|
100
|
+
and do not change the exit code.
|
|
101
|
+
|
|
102
|
+
`--watch` keeps `{size, mtimeMs, sha256}` per file, so a re-sync re-reads only what moved (a
|
|
103
|
+
hash taken within 2 s of the mtime is redone, for coarse clocks). `io.watchDir` (`fs.watch`,
|
|
104
|
+
recursive) is the trigger, debounced 1 s, one sync at a time; a stat-only check every 10 s
|
|
105
|
+
catches missed events, and a watcher error falls back to it. An unreachable API is retried
|
|
106
|
+
after 30 s; any other error ends the watch. Ctrl-C exits 130.
|
|
107
|
+
|
|
82
108
|
## Layout
|
|
83
109
|
|
|
84
110
|
- `bin/syndai.js`: the entrypoint; wires SIGINT to an AbortSignal.
|
|
@@ -95,6 +121,7 @@ unavailable after retries, 130 interrupted (Ctrl-C detaches; it never cancels).
|
|
|
95
121
|
- `src/secrets.js`: `env`, `secrets` and `run` (environment choice, the production step-up retry).
|
|
96
122
|
- `src/dotenv.js`: the `.env` reader (`import`) and writer (`download`); errors name lines, never values.
|
|
97
123
|
- `src/portal.js`: `connect` and `billing`, the hand-offs to a workspace page.
|
|
124
|
+
- `src/sync.js`: `sync` (the walk, `.syndaiignore`, manifest and batched push, `--watch`).
|
|
98
125
|
- `src/changes.js`, `src/settings.js`, `src/mcp.js`: the other commands.
|
|
99
126
|
|
|
100
127
|
## Development
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "syndai",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "The Syndai owner CLI: say what to change, and Syndai builds it, checks it and puts it live. Also installs the Syndai MCP server into Claude Code, Cursor and Codex.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/changes.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// edit-text. Each command is a thin call to one route plus, where there is an outcome to
|
|
3
3
|
// wait for, `watchChange`.
|
|
4
4
|
|
|
5
|
+
import { execFileSync } from "node:child_process";
|
|
5
6
|
import { randomUUID } from "node:crypto";
|
|
6
7
|
import { readFileSync } from "node:fs";
|
|
7
8
|
import path from "node:path";
|
|
@@ -175,6 +176,37 @@ async function uploadImage(ctx, file) {
|
|
|
175
176
|
return { object_key: stored.object_key, content_type: contentType };
|
|
176
177
|
}
|
|
177
178
|
|
|
179
|
+
/** `syndai land [branch]`: validate, review and land your pushed people/<you>/<name> branch. */
|
|
180
|
+
export async function land(ctx, [branch]) {
|
|
181
|
+
const { flags, ui } = ctx;
|
|
182
|
+
const name = branch ?? currentBranch();
|
|
183
|
+
if (!name) throw new CliError("Which branch? syndai land people/<you>/<name>", EXIT.USAGE);
|
|
184
|
+
const project = await resolveProject(ctx, { app: flags.app });
|
|
185
|
+
const landing = await ctx.client.call("landBranch", {
|
|
186
|
+
params: { coding_repository_id: project.id },
|
|
187
|
+
body: { branch: name },
|
|
188
|
+
retry: true, // one landing per pushed head: a retry answers the same run
|
|
189
|
+
});
|
|
190
|
+
ui.progress(ui.dim(`Landing ${name} at ${landing.head_sha.slice(0, 12)}: change ${landing.run_id}`));
|
|
191
|
+
for (const warning of landing.overlap ?? []) ui.progress(`Heads up: ${warning}`);
|
|
192
|
+
if (flags["no-wait"]) {
|
|
193
|
+
return finish(ctx, await getChange(ctx, landing.run_id), {
|
|
194
|
+
exitCode: EXIT.OK,
|
|
195
|
+
next: `syndai watch ${landing.run_id}`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return watchChange(ctx, landing.run_id, { timeoutMs: timeoutOf(flags) });
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function currentBranch() {
|
|
202
|
+
try {
|
|
203
|
+
const out = execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8" }).trim();
|
|
204
|
+
return out && out !== "HEAD" ? out : null;
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
178
210
|
export async function watch(ctx, runId) {
|
|
179
211
|
return watchChange(ctx, needRun(runId, "watch"), {
|
|
180
212
|
timeoutMs: timeoutOf(ctx.flags),
|
package/src/main.js
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// process I/O as `io`, so tests drive it in-process with a fake server and fake clocks.
|
|
3
3
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
|
+
import { watch as watchFs } from "node:fs";
|
|
5
6
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
6
7
|
import { parseArgs } from "node:util";
|
|
7
8
|
import { createContext, login, logout, signup, whoami } from "./auth.js";
|
|
8
|
-
import { answer, cancel, change, editText, goLive, newProject, review, revert, status, watch } from "./changes.js";
|
|
9
|
+
import { answer, cancel, change, editText, goLive, land, newProject, review, revert, status, watch } from "./changes.js";
|
|
9
10
|
import { ApiError, CliError, EXIT, explain, PORTAL_PAGES } from "./errors.js";
|
|
10
11
|
import { VERSION } from "./http.js";
|
|
11
12
|
import { mcp } from "./mcp.js";
|
|
@@ -15,6 +16,7 @@ import { api } from "./api.js";
|
|
|
15
16
|
import { repeat, schedule } from "./schedules.js";
|
|
16
17
|
import { env, run, secrets } from "./secrets.js";
|
|
17
18
|
import { settings } from "./settings.js";
|
|
19
|
+
import { sync } from "./sync.js";
|
|
18
20
|
import { closeInput, openBrowser, openPage, page } from "./ui.js";
|
|
19
21
|
|
|
20
22
|
const str = { type: "string" };
|
|
@@ -49,6 +51,15 @@ const COMMANDS = {
|
|
|
49
51
|
run: (ctx, args) => newProject(ctx, args),
|
|
50
52
|
stepUp: false,
|
|
51
53
|
},
|
|
54
|
+
land: {
|
|
55
|
+
usage: "land [branch] [--app A] [--no-wait] [--timeout D]",
|
|
56
|
+
summary:
|
|
57
|
+
"On a project Syndai hosts: check, review and land your pushed branch (default: the current one, people/<you>/<name>).\n" +
|
|
58
|
+
" A branch named for a task (people/<you>/t12) delivers it when the landing goes live.",
|
|
59
|
+
options: { app: str, "no-wait": bool, timeout: str },
|
|
60
|
+
run: (ctx, args) => land(ctx, args),
|
|
61
|
+
stepUp: false,
|
|
62
|
+
},
|
|
52
63
|
watch: {
|
|
53
64
|
usage: "watch <run> [--timeout D] [--logs]",
|
|
54
65
|
summary: "Follow a change to its outcome (Ctrl-C detaches; the change keeps going).",
|
|
@@ -182,6 +193,14 @@ const COMMANDS = {
|
|
|
182
193
|
options: { app: str },
|
|
183
194
|
run: (ctx, args) => settings(ctx, args),
|
|
184
195
|
},
|
|
196
|
+
sync: {
|
|
197
|
+
usage: "sync <dir> [--app A] [--watch]",
|
|
198
|
+
summary:
|
|
199
|
+
"Sync a folder (an Obsidian vault) into a project's Knowledge: new and changed files go up, deleted ones are removed;\n" +
|
|
200
|
+
" hidden files, node_modules/ and <dir>/.syndaiignore patterns stay out. --watch: keep syncing as files change (Ctrl-C stops).",
|
|
201
|
+
options: { app: str, watch: bool },
|
|
202
|
+
run: (ctx, args) => sync(ctx, args),
|
|
203
|
+
},
|
|
185
204
|
mcp: {
|
|
186
205
|
usage: "mcp install claude|cursor|codex",
|
|
187
206
|
summary: "Add Syndai to an AI coding tool, with its own key.",
|
|
@@ -249,6 +268,12 @@ export function processIo(signal) {
|
|
|
249
268
|
sleep: (ms, abortSignal) => sleep(ms, undefined, { signal: abortSignal }),
|
|
250
269
|
openUrl: openBrowser,
|
|
251
270
|
page: (text) => page(process, text),
|
|
271
|
+
/** `syndai sync --watch`: `onChange(file)` for a change anywhere under `dir` (`file` relative, or null); returns `{close}`. */
|
|
272
|
+
watchDir: (dir, onChange, onError) => {
|
|
273
|
+
const watcher = watchFs(dir, { recursive: true }, (_event, file) => onChange(file ? String(file) : null));
|
|
274
|
+
watcher.on("error", onError);
|
|
275
|
+
return watcher;
|
|
276
|
+
},
|
|
252
277
|
/**
|
|
253
278
|
* `syndai run`: the child shares the terminal; resolves `{code, signal}` (ENOENT rejects).
|
|
254
279
|
* While it runs the parent never exits first (that would orphan a child holding the
|
package/src/projects.js
CHANGED
|
@@ -28,7 +28,8 @@ export async function listProjects(ctx) {
|
|
|
28
28
|
full_name: repo.full_name,
|
|
29
29
|
live_url: app?.url ?? null,
|
|
30
30
|
slug: app?.publication?.slug ?? null,
|
|
31
|
-
|
|
31
|
+
// A local clone can only be of a GitHub repo (Syndai-hosted ones are never cloned).
|
|
32
|
+
remote: repo.provider === "github" ? normalizeRemote(`github.com/${repo.full_name}`) : null,
|
|
32
33
|
protected_paths: repo.protected_paths ?? [],
|
|
33
34
|
customer_id: customerId,
|
|
34
35
|
};
|
|
@@ -84,7 +85,7 @@ export async function resolveProject(ctx, { app, required = true, picker = true,
|
|
|
84
85
|
}
|
|
85
86
|
}
|
|
86
87
|
const remotes = gitRemotes(io.cwd);
|
|
87
|
-
const byRemote = projects.filter((p) => p.
|
|
88
|
+
const byRemote = projects.filter((p) => p.remote && remotes.has(p.remote));
|
|
88
89
|
if (byRemote.length === 1) return byRemote[0];
|
|
89
90
|
if (projects.length === 1) return projects[0];
|
|
90
91
|
if (picker && ui.interactive && projects.length > 1) return pickInteractively(ctx, projects);
|
package/src/routes.js
CHANGED
|
@@ -28,8 +28,9 @@ export const ROUTES = Object.freeze({
|
|
|
28
28
|
packs: ["GET", `${EXT}/purchases/packs`],
|
|
29
29
|
packCheckout: ["POST", `${EXT}/purchases/pack-checkout`],
|
|
30
30
|
planCheckout: ["POST", `${EXT}/purchases/subscription-checkout`],
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
listScans: ["GET", `${EXT}/repositories/{repository_id}/scans`],
|
|
32
|
+
setScanRoutine: ["PUT", `${EXT}/repositories/{repository_id}/scans`],
|
|
33
|
+
runScanNow: ["POST", `${EXT}/repositories/{repository_id}/scans/{kind}/run`],
|
|
33
34
|
getPolicy: ["GET", `${EXT}/policy/defaults`],
|
|
34
35
|
putPolicy: ["PUT", `${EXT}/policy/defaults`],
|
|
35
36
|
imageIngest: ["POST", `${EXT}/runs/image-ingest`],
|
|
@@ -44,7 +45,10 @@ export const ROUTES = Object.freeze({
|
|
|
44
45
|
landRetry: ["POST", `${EXT}/runs/{run_id}/land/retry`],
|
|
45
46
|
landClose: ["POST", `${EXT}/runs/{run_id}/land/close`],
|
|
46
47
|
goLive: ["POST", `${EXT}/runs/{run_id}/land/go-live`],
|
|
48
|
+
landBranch: ["POST", `${EXT}/repos/{coding_repository_id}/land`],
|
|
47
49
|
heldDiff: ["GET", `${EXT}/runs/{run_id}/land/held-diff`],
|
|
50
|
+
compareKnowledgeFolder: ["POST", `${EXT}/repos/{coding_repository_id}/knowledge/folders/{name}/manifest`],
|
|
51
|
+
pushKnowledgeFolderFiles: ["POST", `${EXT}/repos/{coding_repository_id}/knowledge/folders/{name}/files`],
|
|
48
52
|
listSchedules: ["GET", `${EXT}/schedules`],
|
|
49
53
|
createSchedule: ["POST", `${EXT}/schedules`],
|
|
50
54
|
getSchedule: ["GET", `${EXT}/schedules/{schedule_id}`],
|
package/src/settings.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Settings live in four places server-side:
|
|
4
4
|
// autonomy repo-scoped policy default GET/PUT policy/defaults
|
|
5
5
|
// feature_requests the app's publication GET/PATCH .../publication
|
|
6
|
-
// bug_scan the repo's
|
|
6
|
+
// bug_scan the repo's weekly Code bugs check GET/PUT repositories/{id}/scans
|
|
7
7
|
// protected_paths the repo row PATCH repos/{id} (a key may only tighten)
|
|
8
8
|
|
|
9
9
|
import { ApiError, CliError, EXIT, explain } from "./errors.js";
|
|
@@ -66,9 +66,9 @@ async function apply(ctx, project, { key, op, value }) {
|
|
|
66
66
|
body: { feature_requests: value },
|
|
67
67
|
});
|
|
68
68
|
} else if (key === "bug_scan") {
|
|
69
|
-
await client.call("
|
|
69
|
+
await client.call("setScanRoutine", {
|
|
70
70
|
params: { repository_id: project.id },
|
|
71
|
-
body: {
|
|
71
|
+
body: { kind: "code", cadence: value === "on" ? "weekly" : "off" },
|
|
72
72
|
});
|
|
73
73
|
} else {
|
|
74
74
|
const added = value
|
|
@@ -91,7 +91,7 @@ async function read(ctx, project) {
|
|
|
91
91
|
const [policy, publication, bugScan] = await Promise.all([
|
|
92
92
|
settle(client.call("getPolicy", { query: { coding_repository_id: project.id } })),
|
|
93
93
|
settle(client.call("getPublication", { params: ids })),
|
|
94
|
-
settle(client.call("
|
|
94
|
+
settle(client.call("listScans", { params: { repository_id: project.id } })),
|
|
95
95
|
]);
|
|
96
96
|
const unavailable = {};
|
|
97
97
|
for (const [key, got] of [
|
|
@@ -104,7 +104,7 @@ async function read(ctx, project) {
|
|
|
104
104
|
return {
|
|
105
105
|
autonomy: policy.value?.config?.autonomy ?? null,
|
|
106
106
|
feature_requests: publication.value?.feature_requests ?? null,
|
|
107
|
-
bug_scan: bugScan.value ? (bugScan.value
|
|
107
|
+
bug_scan: bugScan.value ? codeScan(bugScan.value) : null,
|
|
108
108
|
protected_paths: project.protected_paths,
|
|
109
109
|
unavailable,
|
|
110
110
|
};
|
|
@@ -128,3 +128,9 @@ async function orNull(promise) {
|
|
|
128
128
|
throw err;
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
+
|
|
132
|
+
// The Code bugs routine is "on" while it runs on a cadence with its spend agreed.
|
|
133
|
+
function codeScan(scans) {
|
|
134
|
+
const code = (scans.kinds ?? []).find((k) => k.kind === "code");
|
|
135
|
+
return code && code.cadence !== "off" && !code.needs_consent ? "on" : "off";
|
|
136
|
+
}
|
package/src/sync.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// `syndai sync <dir> [--app A] [--watch]`: keep a local folder (an Obsidian vault, any folder of
|
|
2
|
+
// notes) in a project's Knowledge (SPEC-knowledge-sources K6; docs: syndai.ai/docs/cli, "Sync a
|
|
3
|
+
// folder into Knowledge").
|
|
4
|
+
//
|
|
5
|
+
// One sync: walk the folder, hash every file, send the manifest. The server deletes the files no
|
|
6
|
+
// longer here and answers the paths that are new or changed; those are pushed in batches of at
|
|
7
|
+
// most 500 files and 8 MB. A text file goes as its UTF-8 text, any other file by name only. The
|
|
8
|
+
// folder's name in Knowledge is the directory's basename.
|
|
9
|
+
//
|
|
10
|
+
// Skipped: hidden files and folders (a name starting with `.`, so `.obsidian/`, `.trash/` and
|
|
11
|
+
// `.git/`), `node_modules/`, symlinks (never followed: one may point out of the folder, or
|
|
12
|
+
// loop), files over 10 MB, and the patterns in `<dir>/.syndaiignore`.
|
|
13
|
+
//
|
|
14
|
+
// `--watch` re-runs the whole sync once the folder has been quiet for a second, one sync at a
|
|
15
|
+
// time; Ctrl-C stops it. A file is hashed again only when its size or mtime moved (or it was
|
|
16
|
+
// hashed within 2 s of its mtime, which a coarse clock could hide), so a re-sync costs the
|
|
17
|
+
// changed files, not the vault. File events (`fs.watch`) are the trigger; a stat-only
|
|
18
|
+
// check every 10 s catches what they miss (a network drive, a dropped event), so an edit is in
|
|
19
|
+
// Knowledge within seconds either way.
|
|
20
|
+
//
|
|
21
|
+
// File I/O is synchronous: one file at a time through promises is ~20x slower on a big vault,
|
|
22
|
+
// and nothing else runs meanwhile.
|
|
23
|
+
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
25
|
+
import fs from "node:fs";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { CliError, EXIT, UnavailableError, explain } from "./errors.js";
|
|
28
|
+
import { interrupted } from "./http.js";
|
|
29
|
+
import { projectLabel, resolveProject } from "./projects.js";
|
|
30
|
+
|
|
31
|
+
/** Sent as text; the server's `TEXT_SUFFIXES` (backend/src/features/project_sources/ingest.py). */
|
|
32
|
+
export const TEXT_SUFFIXES = new Set([
|
|
33
|
+
".md",
|
|
34
|
+
".markdown",
|
|
35
|
+
".mdx",
|
|
36
|
+
".txt",
|
|
37
|
+
".text",
|
|
38
|
+
".csv",
|
|
39
|
+
".tsv",
|
|
40
|
+
".json",
|
|
41
|
+
".yaml",
|
|
42
|
+
".yml",
|
|
43
|
+
".org",
|
|
44
|
+
".rst",
|
|
45
|
+
".log",
|
|
46
|
+
]);
|
|
47
|
+
export const FILE_MAX_BYTES = 10 * 1024 * 1024;
|
|
48
|
+
export const MANIFEST_MAX = 20_000;
|
|
49
|
+
export const BATCH_FILES = 500;
|
|
50
|
+
export const BATCH_BYTES = 8 * 1024 * 1024;
|
|
51
|
+
export const DEBOUNCE_MS = 1000;
|
|
52
|
+
const POLL_MS = 10_000;
|
|
53
|
+
const RETRY_MS = 30_000;
|
|
54
|
+
const RACY_MS = 2000; // the coarsest common mtime clock (FAT, exFAT)
|
|
55
|
+
const TOO_BIG = "over 10 MB";
|
|
56
|
+
const IGNORE_FILE = ".syndaiignore";
|
|
57
|
+
const DEFAULT_IGNORES = "node_modules/\n";
|
|
58
|
+
|
|
59
|
+
export async function sync(ctx, args) {
|
|
60
|
+
const [dir, ...extra] = args;
|
|
61
|
+
if (!dir || extra.length) throw new CliError(EXIT.USAGE, "Usage: syndai sync <dir> [--app A] [--watch]");
|
|
62
|
+
const root = path.resolve(ctx.io.cwd, dir);
|
|
63
|
+
if (!fs.statSync(root, { throwIfNoEntry: false })?.isDirectory()) {
|
|
64
|
+
throw new CliError(EXIT.USAGE, `${dir} is not a folder.`);
|
|
65
|
+
}
|
|
66
|
+
const name = path.basename(root);
|
|
67
|
+
if (!name) throw new CliError(EXIT.USAGE, "Sync a folder with a name, not the filesystem root.");
|
|
68
|
+
const project = await resolveProject(ctx, { app: ctx.flags.app });
|
|
69
|
+
// `known`: path -> {size, mtimeMs, hashedAt, sha256 (null: skipped)} as of the last scan.
|
|
70
|
+
const target = { root, name, project, rules: [], known: new Map() };
|
|
71
|
+
show(ctx, target, await syncOnce(ctx, target));
|
|
72
|
+
return ctx.flags.watch ? watchFolder(ctx, target) : EXIT.OK;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// --- one sync ----------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
async function syncOnce(ctx, target) {
|
|
78
|
+
const { client } = ctx;
|
|
79
|
+
const params = { coding_repository_id: target.project.id, name: target.name };
|
|
80
|
+
scan(target);
|
|
81
|
+
const files = [...target.known].filter(([, f]) => f.sha256).map(([rel, f]) => ({ path: rel, sha256: f.sha256 }));
|
|
82
|
+
if (files.length > MANIFEST_MAX) {
|
|
83
|
+
throw new CliError(
|
|
84
|
+
EXIT.USAGE,
|
|
85
|
+
`${target.name} has ${files.length} files; a synced folder holds up to ${MANIFEST_MAX}.`,
|
|
86
|
+
{ next: `list what to leave out in ${path.join(target.root, IGNORE_FILE)}` },
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const manifest = await client.call("compareKnowledgeFolder", {
|
|
90
|
+
params,
|
|
91
|
+
body: { files },
|
|
92
|
+
retry: true, // the same manifest twice is the same answer
|
|
93
|
+
timeoutMs: 120_000,
|
|
94
|
+
});
|
|
95
|
+
const need = manifest.need.filter((rel) => target.known.get(rel)?.sha256);
|
|
96
|
+
const totals = { indexed: 0, linked: 0, failed: [] };
|
|
97
|
+
const progress = ctx.io.stderr.isTTY && need.length > BATCH_FILES;
|
|
98
|
+
let sent = 0;
|
|
99
|
+
let batch = [];
|
|
100
|
+
let size = 0;
|
|
101
|
+
const flush = async () => {
|
|
102
|
+
const pushed = await client.call("pushKnowledgeFolderFiles", {
|
|
103
|
+
params,
|
|
104
|
+
body: { files: batch },
|
|
105
|
+
retry: true, // a push upserts by path
|
|
106
|
+
timeoutMs: 120_000,
|
|
107
|
+
});
|
|
108
|
+
totals.indexed += pushed.indexed;
|
|
109
|
+
totals.linked += pushed.linked;
|
|
110
|
+
totals.failed.push(...pushed.failed);
|
|
111
|
+
sent += batch.length;
|
|
112
|
+
if (progress) ctx.ui.progress(ctx.ui.dim(`indexed ${sent} of ${need.length}`));
|
|
113
|
+
batch = [];
|
|
114
|
+
size = 0;
|
|
115
|
+
};
|
|
116
|
+
for (const rel of need) {
|
|
117
|
+
// Read again: a file edited since the scan goes with its new hash, so the next manifest
|
|
118
|
+
// matches it. One gone since is left to the next manifest, which removes it.
|
|
119
|
+
const file = readFile(target, rel);
|
|
120
|
+
if (!file?.sha256) continue;
|
|
121
|
+
const entry = {
|
|
122
|
+
path: rel,
|
|
123
|
+
sha256: file.sha256,
|
|
124
|
+
bytes: file.bytes.length,
|
|
125
|
+
modified_at: new Date(file.mtimeMs).toISOString(),
|
|
126
|
+
content: TEXT_SUFFIXES.has(path.extname(rel).toLowerCase()) ? file.bytes.toString("utf8") : null,
|
|
127
|
+
};
|
|
128
|
+
const bytes = Buffer.byteLength(JSON.stringify(entry)) + 1;
|
|
129
|
+
if (batch.length && (batch.length >= BATCH_FILES || size + bytes > BATCH_BYTES)) await flush();
|
|
130
|
+
batch.push(entry);
|
|
131
|
+
size += bytes;
|
|
132
|
+
}
|
|
133
|
+
if (batch.length) await flush();
|
|
134
|
+
return {
|
|
135
|
+
source_id: manifest.source_id,
|
|
136
|
+
files: files.length,
|
|
137
|
+
unchanged: files.length - need.length,
|
|
138
|
+
removed: manifest.removed,
|
|
139
|
+
...totals,
|
|
140
|
+
skipped: [...target.known].filter(([, f]) => !f.sha256).map(([rel]) => ({ path: rel, reason: TOO_BIG })),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Walk the folder into `target.known`, hashing only a file whose size or mtime moved. */
|
|
145
|
+
function scan(target) {
|
|
146
|
+
target.rules = loadRules(target.root);
|
|
147
|
+
const before = target.known;
|
|
148
|
+
target.known = new Map();
|
|
149
|
+
for (const rel of walk(target.root, target.rules)) {
|
|
150
|
+
const stat = statFile(target, rel);
|
|
151
|
+
const last = before.get(rel);
|
|
152
|
+
if (stat && same(last, stat) && trusted(last)) target.known.set(rel, last);
|
|
153
|
+
else if (stat) readFile(target, rel);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const same = (last, stat) => Boolean(last) && last.size === stat.size && last.mtimeMs === stat.mtimeMs;
|
|
158
|
+
|
|
159
|
+
/** A hash taken soon after the mtime may predate a same-size edit in that clock tick: redo it. */
|
|
160
|
+
const trusted = (last) => !last.sha256 || last.mtimeMs < last.hashedAt - RACY_MS;
|
|
161
|
+
|
|
162
|
+
/** Whether a stat-only walk differs from the last scan (a file added, removed or touched). */
|
|
163
|
+
function changedOnDisk(target) {
|
|
164
|
+
const rels = walk(target.root, loadRules(target.root));
|
|
165
|
+
if (rels.length !== target.known.size) return true;
|
|
166
|
+
return rels.some((rel) => {
|
|
167
|
+
const stat = statFile(target, rel);
|
|
168
|
+
const last = target.known.get(rel);
|
|
169
|
+
if (!stat || !same(last, stat)) return true;
|
|
170
|
+
return !trusted(last) && readFile(target, rel)?.sha256 !== last.sha256;
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function statFile(target, rel) {
|
|
175
|
+
try {
|
|
176
|
+
const stat = fs.lstatSync(path.join(target.root, ...rel.split("/")));
|
|
177
|
+
return stat.isFile() ? stat : null;
|
|
178
|
+
} catch (err) {
|
|
179
|
+
if (["ENOENT", "EACCES", "EPERM", "ENOTDIR"].includes(err.code)) return null;
|
|
180
|
+
throw err;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Read and hash one file into `target.known` and return it with its `bytes`; one over 10 MB is
|
|
186
|
+
* recorded with `sha256: null` (skipped) and never read. Null when it is gone or unreadable.
|
|
187
|
+
*/
|
|
188
|
+
function readFile(target, rel) {
|
|
189
|
+
const full = path.join(target.root, ...rel.split("/"));
|
|
190
|
+
let stat;
|
|
191
|
+
let bytes;
|
|
192
|
+
try {
|
|
193
|
+
stat = fs.lstatSync(full);
|
|
194
|
+
if (!stat.isFile()) return null;
|
|
195
|
+
if (stat.size <= FILE_MAX_BYTES) bytes = fs.readFileSync(full);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
if (["ENOENT", "EACCES", "EPERM", "EISDIR", "ENOTDIR"].includes(err.code)) {
|
|
198
|
+
target.known.delete(rel);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
throw err;
|
|
202
|
+
}
|
|
203
|
+
const small = bytes && bytes.length <= FILE_MAX_BYTES;
|
|
204
|
+
const file = {
|
|
205
|
+
size: small ? bytes.length : stat.size,
|
|
206
|
+
mtimeMs: stat.mtimeMs,
|
|
207
|
+
hashedAt: Date.now(),
|
|
208
|
+
sha256: small ? createHash("sha256").update(bytes).digest("hex") : null,
|
|
209
|
+
};
|
|
210
|
+
target.known.set(rel, file);
|
|
211
|
+
return { ...file, bytes };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function show(ctx, target, s) {
|
|
215
|
+
const lines = [
|
|
216
|
+
`Synced ${target.name} to ${projectLabel(target.project)}: ` +
|
|
217
|
+
`${s.indexed} indexed, ${s.linked} linked, ${s.failed.length} failed`,
|
|
218
|
+
];
|
|
219
|
+
if (s.removed) lines.push(` removed ${s.removed} deleted here`);
|
|
220
|
+
for (const f of s.failed) lines.push(` failed ${f.path}: ${f.error}`);
|
|
221
|
+
for (const f of s.skipped) lines.push(` skipped ${f.path}: ${f.reason}`);
|
|
222
|
+
ctx.ui.result({ folder: target.name, dir: target.root, project_id: target.project.id, ...s }, lines.join("\n"));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- what is skipped ---------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
function loadRules(root) {
|
|
228
|
+
let own = "";
|
|
229
|
+
try {
|
|
230
|
+
own = fs.readFileSync(path.join(root, IGNORE_FILE), "utf8");
|
|
231
|
+
} catch {
|
|
232
|
+
// No .syndaiignore: the defaults alone.
|
|
233
|
+
}
|
|
234
|
+
return parseIgnore(DEFAULT_IGNORES + own);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* `.syndaiignore`, gitignore-like and kept simple: one pattern a line, `#` comments, `dir/` for
|
|
239
|
+
* folders only, a pattern with a `/` (or a leading one) anchored at the folder's root and any
|
|
240
|
+
* other matched against a name at any depth, `*` and `?` within a name, `**` across folders.
|
|
241
|
+
* No `!` negation.
|
|
242
|
+
*/
|
|
243
|
+
export function parseIgnore(text) {
|
|
244
|
+
const rules = [];
|
|
245
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
246
|
+
let line = raw.trim();
|
|
247
|
+
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
|
|
248
|
+
const dirOnly = line.endsWith("/");
|
|
249
|
+
line = line.replace(/\/+$/, "");
|
|
250
|
+
const anchored = line.includes("/");
|
|
251
|
+
line = line.replace(/^\/+/, "");
|
|
252
|
+
if (line) rules.push({ dirOnly, anchored, regex: globRegex(line) });
|
|
253
|
+
}
|
|
254
|
+
return rules;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function globRegex(glob) {
|
|
258
|
+
let source = "";
|
|
259
|
+
for (let i = 0; i < glob.length; i++) {
|
|
260
|
+
const ch = glob[i];
|
|
261
|
+
if (ch === "*" && glob[i + 1] === "*") {
|
|
262
|
+
const slash = glob[i + 2] === "/";
|
|
263
|
+
source += slash ? "(?:.*/)?" : ".*";
|
|
264
|
+
i += slash ? 2 : 1;
|
|
265
|
+
} else if (ch === "*") source += "[^/]*";
|
|
266
|
+
else if (ch === "?") source += "[^/]";
|
|
267
|
+
else source += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
268
|
+
}
|
|
269
|
+
return new RegExp(`^${source}$`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Whether the entry at `rel` (`/`-separated; `isDir` for a folder) is skipped. */
|
|
273
|
+
export function ignored(rules, rel, isDir) {
|
|
274
|
+
const name = rel.slice(rel.lastIndexOf("/") + 1);
|
|
275
|
+
if (name.startsWith(".")) return true;
|
|
276
|
+
return rules.some((r) => (!r.dirOnly || isDir) && r.regex.test(r.anchored ? rel : name));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The files to sync, sorted; a skipped folder is never entered. */
|
|
280
|
+
function walk(root, rules) {
|
|
281
|
+
const files = [];
|
|
282
|
+
const visit = (dir, prefix) => {
|
|
283
|
+
let entries;
|
|
284
|
+
try {
|
|
285
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
286
|
+
} catch (err) {
|
|
287
|
+
if (prefix && ["ENOENT", "EACCES", "EPERM", "ENOTDIR"].includes(err.code)) return;
|
|
288
|
+
throw err;
|
|
289
|
+
}
|
|
290
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
293
|
+
// A Dirent describes the link itself: a symlink is neither, so it is never followed.
|
|
294
|
+
if (entry.isDirectory()) {
|
|
295
|
+
if (!ignored(rules, rel, true)) visit(path.join(dir, entry.name), rel);
|
|
296
|
+
} else if (entry.isFile() && !ignored(rules, rel, false)) {
|
|
297
|
+
files.push(rel);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
visit(root, "");
|
|
302
|
+
return files;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** A change event for `rel` matters unless it is in a skipped folder or is a skipped file. */
|
|
306
|
+
function relevant(rules, rel) {
|
|
307
|
+
if (rel === IGNORE_FILE) return true;
|
|
308
|
+
const parts = rel.split("/");
|
|
309
|
+
for (let i = 1; i < parts.length; i++) {
|
|
310
|
+
if (ignored(rules, parts.slice(0, i).join("/"), true)) return false;
|
|
311
|
+
}
|
|
312
|
+
return !ignored(rules, rel, false);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- --watch -----------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
async function watchFolder(ctx, target) {
|
|
318
|
+
const { io, ui } = ctx;
|
|
319
|
+
const state = { changed: false, wake: null };
|
|
320
|
+
let watcher = null;
|
|
321
|
+
const eventsLost = (err) => {
|
|
322
|
+
watcher?.close();
|
|
323
|
+
watcher = null;
|
|
324
|
+
ui.progress(`${ui.yellow("warning:")} file events stopped (${err.code ?? err.message}); checking every 10 s instead.`);
|
|
325
|
+
};
|
|
326
|
+
try {
|
|
327
|
+
watcher = io.watchDir(
|
|
328
|
+
target.root,
|
|
329
|
+
(file) => {
|
|
330
|
+
if (file && !relevant(target.rules, String(file).split(path.sep).join("/"))) return;
|
|
331
|
+
state.changed = true;
|
|
332
|
+
state.wake?.();
|
|
333
|
+
},
|
|
334
|
+
eventsLost,
|
|
335
|
+
);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
eventsLost(err);
|
|
338
|
+
}
|
|
339
|
+
ui.progress(ui.dim(`Watching ${target.root} for changes (Ctrl-C stops).`));
|
|
340
|
+
try {
|
|
341
|
+
for (;;) {
|
|
342
|
+
if (!state.changed && (await nextEvent(io, state)) === "poll" && !changedOnDisk(target)) continue;
|
|
343
|
+
// Wait until a second passes with no change, so a burst of saves is one sync.
|
|
344
|
+
do {
|
|
345
|
+
state.changed = false;
|
|
346
|
+
await pause(io, DEBOUNCE_MS);
|
|
347
|
+
} while (state.changed);
|
|
348
|
+
try {
|
|
349
|
+
const s = await syncOnce(ctx, target);
|
|
350
|
+
if (s.indexed || s.linked || s.failed.length || s.removed) show(ctx, target, s);
|
|
351
|
+
} catch (err) {
|
|
352
|
+
if (!(err instanceof UnavailableError)) throw err;
|
|
353
|
+
ui.progress(`${ui.yellow("warning:")} ${explain(err).message} Trying again in 30 s.`);
|
|
354
|
+
await pause(io, RETRY_MS);
|
|
355
|
+
state.changed = true;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
} finally {
|
|
359
|
+
watcher?.close();
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** "event" on a relevant file event, "poll" after 10 s without one; Ctrl-C interrupts. */
|
|
364
|
+
function nextEvent(io, state) {
|
|
365
|
+
return new Promise((resolve, reject) => {
|
|
366
|
+
if (io.signal.aborted) return reject(interrupted());
|
|
367
|
+
const done = (outcome) => {
|
|
368
|
+
clearTimeout(timer);
|
|
369
|
+
io.signal.removeEventListener("abort", onAbort);
|
|
370
|
+
state.wake = null;
|
|
371
|
+
if (outcome instanceof Error) reject(outcome);
|
|
372
|
+
else resolve(outcome);
|
|
373
|
+
};
|
|
374
|
+
const onAbort = () => done(interrupted());
|
|
375
|
+
const timer = setTimeout(() => done("poll"), POLL_MS);
|
|
376
|
+
io.signal.addEventListener("abort", onAbort, { once: true });
|
|
377
|
+
state.wake = () => done("event");
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function pause(io, ms) {
|
|
382
|
+
if (io.signal.aborted) throw interrupted();
|
|
383
|
+
try {
|
|
384
|
+
await io.sleep(ms, io.signal);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
if (io.signal.aborted) throw interrupted();
|
|
387
|
+
throw err;
|
|
388
|
+
}
|
|
389
|
+
}
|