@jive-sh/skill-sync 0.4.2 → 0.4.3-latest-darwin-arm64
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/multi-call-binary +4 -0
- package/package.json +11 -39
- package/README.md +0 -60
- package/bin/package.json +0 -1
- package/bin/skill-sync +0 -571
- package/bin/skill-sync-daemon +0 -571
- package/dist/daemons/skill-sync-daemon.js +0 -374
- package/dist/daemons/skill-sync-daemon.js.map +0 -1
- package/dist/entrypoints/skill-sync.js +0 -133
- package/dist/entrypoints/skill-sync.js.map +0 -1
- package/dist/index.js +0 -10
- package/dist/index.js.map +0 -1
- package/dist/internal/config.js +0 -130
- package/dist/internal/config.js.map +0 -1
- package/dist/internal/control.js +0 -101
- package/dist/internal/control.js.map +0 -1
- package/dist/internal/folders.js +0 -48
- package/dist/internal/folders.js.map +0 -1
- package/dist/internal/path.js +0 -36
- package/dist/internal/path.js.map +0 -1
- package/dist/internal/sync.js +0 -160
- package/dist/internal/sync.js.map +0 -1
- package/dist/internal/wire.js +0 -13
- package/dist/internal/wire.js.map +0 -1
- package/install.cjs +0 -571
- package/src/daemons/skill-sync-daemon.ts +0 -368
- package/src/entrypoints/skill-sync.ts +0 -135
- package/src/index.ts +0 -15
- package/src/internal/config.ts +0 -168
- package/src/internal/control.ts +0 -127
- package/src/internal/folders.ts +0 -52
- package/src/internal/path.ts +0 -32
- package/src/internal/sync.ts +0 -196
- package/src/internal/wire.ts +0 -154
package/src/internal/config.ts
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
-
import { homedir, hostname, userInfo } from "node:os";
|
|
4
|
-
import { join, relative, sep } from "node:path";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Per-machine settings and sync state, both under `~/.jive/`. The sync token is not here: it lives only in
|
|
8
|
-
* the running daemon's memory.
|
|
9
|
-
*
|
|
10
|
-
* A file rather than environment variables: the endpoint and which machine this is differ per computer
|
|
11
|
-
* and have to survive a login, which is exactly what a config file is for and exactly what an environment
|
|
12
|
-
* variable is not. Which folders to sync is not in here: they are detected, and the choice to leave one
|
|
13
|
-
* out is kept on the server so the dashboard can change it and a reinstalled daemon still remembers it.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
const JIVE_HOME = join(homedir(), ".jive");
|
|
17
|
-
export const CONFIG_PATH = join(JIVE_HOME, "skill-sync.json");
|
|
18
|
-
const STATE_PATH = join(JIVE_HOME, "skill-sync-state.json");
|
|
19
|
-
/**
|
|
20
|
-
* Where versions before 0.3 kept the sync token. The token now lives only in the running daemon's memory,
|
|
21
|
-
* so the daemon deletes this file whenever it finds one: a credential left on disk is one anything that
|
|
22
|
-
* can read the home directory can copy.
|
|
23
|
-
*/
|
|
24
|
-
export const LEGACY_TOKEN_PATH = join(homedir(), ".skill-sync-token.json");
|
|
25
|
-
/**
|
|
26
|
-
* How the CLI reaches the running daemon: a Unix socket in `~/.jive/`, or a named pipe on Windows. Only
|
|
27
|
-
* this user can open either, which is what lets the CLI hand the daemon work that involves the token.
|
|
28
|
-
*/
|
|
29
|
-
export const CONTROL_SOCKET = process.platform === "win32"
|
|
30
|
-
? `\\\\.\\pipe\\jive-skill-sync-${userInfo().username}`
|
|
31
|
-
: join(JIVE_HOME, "skill-sync.sock");
|
|
32
|
-
|
|
33
|
-
/** Managed by Claude's own cloud sync; see `Settings.exclude`. */
|
|
34
|
-
const DEFAULT_EXCLUDE = ["synced"] as const;
|
|
35
|
-
|
|
36
|
-
/** What `~/.jive/skill-sync.json` holds: nothing secret, so it can live in a file. */
|
|
37
|
-
export interface Settings {
|
|
38
|
-
readonly endpoint: string;
|
|
39
|
-
/**
|
|
40
|
-
* Top-level directory names under each synced folder to leave alone.
|
|
41
|
-
*
|
|
42
|
-
* `synced` holds the skills Claude already pulls from the cloud itself. Syncing them here would mean
|
|
43
|
-
* two systems writing the same files, each undoing the other, and none of them are yours to edit.
|
|
44
|
-
* The list applies to every detected folder, so in Codex's `~/.agents/skills` it keeps a skill named
|
|
45
|
-
* `synced` from syncing.
|
|
46
|
-
*/
|
|
47
|
-
readonly exclude: ReadonlyArray<string>;
|
|
48
|
-
/** Names this machine on the server: on the dashboard, and as the machine each edit came from. */
|
|
49
|
-
readonly machine: string;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/** Settings plus the token, which exists only in the daemon's memory after a sign-in. */
|
|
53
|
-
export interface Config extends Settings {
|
|
54
|
-
readonly token: string;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/** Sync progress for one folder. Each folder syncs with the server on its own, so each has its own. */
|
|
58
|
-
export interface State {
|
|
59
|
-
/** Highest server sequence number already applied. */
|
|
60
|
-
cursor: number;
|
|
61
|
-
/** Last hash this machine agreed with the server on, per relative path. Absent means never synced. */
|
|
62
|
-
known: Record<string, string>;
|
|
63
|
-
/** The synced list as of last cycle, to notice a skill being added to it. */
|
|
64
|
-
skills?: string[];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Every folder's `State`, keyed by the folder's absolute path. */
|
|
68
|
-
export type States = Record<string, State>;
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* The settings, writing a template on first run. Undefined when the file lacks an endpoint or a machine
|
|
72
|
-
* name, which only happens if someone edited it.
|
|
73
|
-
*/
|
|
74
|
-
export function loadSettings(): Settings | undefined {
|
|
75
|
-
if (!existsSync(CONFIG_PATH)) {
|
|
76
|
-
mkdirSync(JIVE_HOME, { recursive: true });
|
|
77
|
-
writeFileSync(CONFIG_PATH, `${JSON.stringify({
|
|
78
|
-
endpoint: "https://skill-sync.jive.sh",
|
|
79
|
-
machine: machineName(),
|
|
80
|
-
exclude: DEFAULT_EXCLUDE,
|
|
81
|
-
}, null, 2)}\n`);
|
|
82
|
-
}
|
|
83
|
-
const settings = readJson<Partial<Settings>>(CONFIG_PATH);
|
|
84
|
-
if (!settings.endpoint || !settings.machine) return undefined;
|
|
85
|
-
return { endpoint: settings.endpoint, machine: settings.machine, exclude: settings.exclude ?? DEFAULT_EXCLUDE };
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function readJson<T>(path: string): T {
|
|
89
|
-
try {
|
|
90
|
-
return JSON.parse(readFileSync(path, "utf-8")) as T;
|
|
91
|
-
} catch {
|
|
92
|
-
return {} as T;
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Every folder's sync state.
|
|
98
|
-
*
|
|
99
|
-
* Before folders were detected the file held one folder's `{ cursor, known }`, for the folder then named
|
|
100
|
-
* by `skillsDir` in the settings. That state is carried over to that folder: dropping it would make the
|
|
101
|
-
* folder look never-synced, and every file in it that differs from the server would become a conflict.
|
|
102
|
-
*/
|
|
103
|
-
export function loadStates(): States {
|
|
104
|
-
const stored = readJson<Partial<State> & { folders?: States }>(STATE_PATH);
|
|
105
|
-
if (stored.folders !== undefined) return stored.folders;
|
|
106
|
-
const legacyFolder = readJson<{ skillsDir?: string }>(CONFIG_PATH).skillsDir;
|
|
107
|
-
if (legacyFolder !== undefined && stored.cursor !== undefined && stored.known !== undefined) {
|
|
108
|
-
return { [legacyFolder]: { cursor: stored.cursor, known: stored.known } };
|
|
109
|
-
}
|
|
110
|
-
return {};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function stateFor(states: States, folder: string): State {
|
|
114
|
-
states[folder] ??= { cursor: 0, known: {} };
|
|
115
|
-
return states[folder];
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function saveStates(states: States): void {
|
|
119
|
-
mkdirSync(JIVE_HOME, { recursive: true });
|
|
120
|
-
writeFileSync(STATE_PATH, `${JSON.stringify({ folders: states }, null, 2)}\n`);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
/** `HOSTNAME` is not set on macOS, so the template was writing "unknown" on exactly this machine. */
|
|
124
|
-
function machineName(): string {
|
|
125
|
-
return hostname().replace(/\.local$/, "").replace(/[^A-Za-z0-9-]+/g, "-") || "unknown";
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* The `skills` folder of every agent home on this machine: Claude's `~/.claude` and each
|
|
130
|
-
* `~/.claude-<name>`, and Codex's `~/.agents`, whose `skills` folder is where Codex reads a user's skills.
|
|
131
|
-
*
|
|
132
|
-
* Every home is a candidate, whether or not it has a `skills` folder yet, so a Claude account added
|
|
133
|
-
* later joins without anyone listing it. Which of them actually sync is the user's choice on the
|
|
134
|
-
* dashboard; a folder that is synced and missing is created.
|
|
135
|
-
*/
|
|
136
|
-
export function detectFolders(): string[] {
|
|
137
|
-
return readdirSync(homedir())
|
|
138
|
-
.filter((entry) => entry === ".claude" || entry.startsWith(".claude-") || entry === ".agents")
|
|
139
|
-
.filter((entry) => statSync(join(homedir(), entry)).isDirectory())
|
|
140
|
-
.map((entry) => join(homedir(), entry, "skills"))
|
|
141
|
-
.sort();
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function hashBytes(bytes: Uint8Array): string {
|
|
145
|
-
return createHash("sha256").update(bytes).digest("hex");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* Every file under a synced folder, as paths relative to it with forward slashes.
|
|
150
|
-
*
|
|
151
|
-
* Normalised because the same skill synced from Windows and macOS must be one row, not two.
|
|
152
|
-
*/
|
|
153
|
-
export function listLocalFiles(folder: string, exclude: ReadonlyArray<string> = DEFAULT_EXCLUDE): string[] {
|
|
154
|
-
if (!existsSync(folder)) return [];
|
|
155
|
-
|
|
156
|
-
const found: string[] = [];
|
|
157
|
-
const walk = (dir: string, top: boolean): void => {
|
|
158
|
-
for (const entry of readdirSync(dir)) {
|
|
159
|
-
if (entry.startsWith(".")) continue;
|
|
160
|
-
if (top && exclude.includes(entry)) continue;
|
|
161
|
-
const full = join(dir, entry);
|
|
162
|
-
if (statSync(full).isDirectory()) walk(full, false);
|
|
163
|
-
else found.push(relative(folder, full).split(sep).join("/"));
|
|
164
|
-
}
|
|
165
|
-
};
|
|
166
|
-
walk(folder, true);
|
|
167
|
-
return found.sort();
|
|
168
|
-
}
|
package/src/internal/control.ts
DELETED
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
import { createConnection, createServer, type Server, type Socket } from "node:net";
|
|
2
|
-
import { chmodSync, rmSync } from "node:fs";
|
|
3
|
-
import { CONTROL_SOCKET } from "./config";
|
|
4
|
-
import type { ListedSkill } from "./wire";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* The channel between the CLI and the running daemon.
|
|
8
|
-
*
|
|
9
|
-
* The daemon owns the sync token: it receives it from the browser and keeps it only in memory, so it is
|
|
10
|
-
* never in a file and never passes through the CLI. The CLI's `login`, `logout` and `list` are requests to
|
|
11
|
-
* the daemon over this channel. Messages are newline-delimited JSON; each request gets events until one that
|
|
12
|
-
* ends it.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
export type ControlRequest =
|
|
16
|
-
| { readonly type: "login" }
|
|
17
|
-
| { readonly type: "logout" }
|
|
18
|
-
/** The synced list, which the daemon fetches from the server with its token. */
|
|
19
|
-
| { readonly type: "list" };
|
|
20
|
-
|
|
21
|
-
export type ControlEvent =
|
|
22
|
-
/** Sign-in has started: the CLI opens this URL in the browser, where the passkey is verified. */
|
|
23
|
-
| { readonly type: "open"; readonly url: string }
|
|
24
|
-
| { readonly type: "signedIn"; readonly machine: string }
|
|
25
|
-
| { readonly type: "signedOut"; readonly revoked: boolean }
|
|
26
|
-
| { readonly type: "list"; readonly skills: ReadonlyArray<ListedSkill> }
|
|
27
|
-
| { readonly type: "error"; readonly message: string };
|
|
28
|
-
|
|
29
|
-
const isFinal = (event: ControlEvent): boolean => event.type !== "open";
|
|
30
|
-
|
|
31
|
-
/** The daemon is not running, as opposed to running and refusing the request. */
|
|
32
|
-
export class DaemonUnreachable extends Error {}
|
|
33
|
-
|
|
34
|
-
/** Sends one request and calls `onEvent` for each event until the final one, which it resolves with. */
|
|
35
|
-
export function request(message: ControlRequest, onEvent: (event: ControlEvent) => void): Promise<ControlEvent> {
|
|
36
|
-
return new Promise((resolve, reject) => {
|
|
37
|
-
let settled = false;
|
|
38
|
-
const socket = createConnection(CONTROL_SOCKET);
|
|
39
|
-
socket.on("connect", () => socket.write(`${JSON.stringify(message)}\n`));
|
|
40
|
-
socket.on("error", (cause: NodeJS.ErrnoException) => {
|
|
41
|
-
if (settled) return;
|
|
42
|
-
settled = true;
|
|
43
|
-
reject(cause.code === "ENOENT" || cause.code === "ECONNREFUSED"
|
|
44
|
-
? new DaemonUnreachable(`the skill-sync daemon is not running (${cause.code} on ${CONTROL_SOCKET})`)
|
|
45
|
-
: cause);
|
|
46
|
-
});
|
|
47
|
-
readLines(socket, (line) => {
|
|
48
|
-
const event = JSON.parse(line) as ControlEvent;
|
|
49
|
-
onEvent(event);
|
|
50
|
-
if (isFinal(event) && !settled) {
|
|
51
|
-
settled = true;
|
|
52
|
-
socket.end();
|
|
53
|
-
resolve(event);
|
|
54
|
-
}
|
|
55
|
-
});
|
|
56
|
-
socket.on("close", () => {
|
|
57
|
-
if (!settled) {
|
|
58
|
-
settled = true;
|
|
59
|
-
reject(new Error("the daemon closed the connection without answering"));
|
|
60
|
-
}
|
|
61
|
-
});
|
|
62
|
-
});
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** One request being answered: send events, and end the conversation with a final one. */
|
|
66
|
-
export interface Reply {
|
|
67
|
-
readonly send: (event: ControlEvent) => void;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Listens on the control socket. Resolves false, without listening, when another live daemon already
|
|
72
|
-
* holds it: two installs of skill-sync mean two daemons, and the second stands down rather than fight.
|
|
73
|
-
*/
|
|
74
|
-
export async function serve(handle: (message: ControlRequest, reply: Reply) => void): Promise<Server | false> {
|
|
75
|
-
if (await answers()) return false;
|
|
76
|
-
// Nothing answered, so a socket file left here is from a daemon that died; it would block the listen.
|
|
77
|
-
if (process.platform !== "win32") rmSync(CONTROL_SOCKET, { force: true });
|
|
78
|
-
|
|
79
|
-
const server = createServer((socket) => {
|
|
80
|
-
const reply: Reply = {
|
|
81
|
-
send: (event) => {
|
|
82
|
-
if (socket.writable) socket.write(`${JSON.stringify(event)}\n`);
|
|
83
|
-
if (isFinal(event)) socket.end();
|
|
84
|
-
},
|
|
85
|
-
};
|
|
86
|
-
readLines(socket, (line) => {
|
|
87
|
-
try {
|
|
88
|
-
handle(JSON.parse(line) as ControlRequest, reply);
|
|
89
|
-
} catch (cause) {
|
|
90
|
-
reply.send({ type: "error", message: cause instanceof Error ? cause.message : String(cause) });
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
socket.on("error", () => undefined); // A CLI that went away mid-request; nothing to tell it.
|
|
94
|
-
});
|
|
95
|
-
await new Promise<void>((resolve, reject) => {
|
|
96
|
-
server.once("error", reject);
|
|
97
|
-
server.listen(CONTROL_SOCKET, () => resolve());
|
|
98
|
-
});
|
|
99
|
-
// Only this user may talk to the daemon that holds their token.
|
|
100
|
-
if (process.platform !== "win32") chmodSync(CONTROL_SOCKET, 0o600);
|
|
101
|
-
return server;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** Whether a live daemon is listening on the control socket. */
|
|
105
|
-
function answers(): Promise<boolean> {
|
|
106
|
-
return new Promise((resolve) => {
|
|
107
|
-
const socket = createConnection(CONTROL_SOCKET);
|
|
108
|
-
socket.on("connect", () => {
|
|
109
|
-
socket.end();
|
|
110
|
-
resolve(true);
|
|
111
|
-
});
|
|
112
|
-
socket.on("error", () => resolve(false));
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function readLines(socket: Socket, onLine: (line: string) => void): void {
|
|
117
|
-
let buffer = "";
|
|
118
|
-
socket.on("data", (chunk) => {
|
|
119
|
-
buffer += chunk.toString();
|
|
120
|
-
let newline: number;
|
|
121
|
-
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
122
|
-
const line = buffer.slice(0, newline).trim();
|
|
123
|
-
buffer = buffer.slice(newline + 1);
|
|
124
|
-
if (line !== "") onLine(line);
|
|
125
|
-
}
|
|
126
|
-
});
|
|
127
|
-
}
|
package/src/internal/folders.ts
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { detectFolders, hashBytes, listLocalFiles, type Config } from "./config";
|
|
4
|
-
import {
|
|
5
|
-
skillOf, type FileHash, type FolderPrefs, type FoldersRequest, type FoldersResponse, type LocalSkill,
|
|
6
|
-
} from "./wire";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* What this machine tells the service about its folders, and what it hears back.
|
|
10
|
-
*
|
|
11
|
-
* The daemon and the CLI both register, so this lives in one place: they must describe a folder the same
|
|
12
|
-
* way, or the dashboard would show whichever of them spoke last.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* The skills on disk in one folder, with each file's hash. Hashing every file on each check is cheap at
|
|
17
|
-
* skills' size, and it is what lets the dashboard show whether this folder's copy is the synced one.
|
|
18
|
-
*/
|
|
19
|
-
export function localSkills(folder: string, config: Config): LocalSkill[] {
|
|
20
|
-
const byName = new Map<string, FileHash[]>();
|
|
21
|
-
for (const path of listLocalFiles(folder, config.exclude)) {
|
|
22
|
-
const name = skillOf(path);
|
|
23
|
-
let hash: string;
|
|
24
|
-
try {
|
|
25
|
-
hash = hashBytes(readFileSync(join(folder, path)));
|
|
26
|
-
} catch {
|
|
27
|
-
continue; // Deleted between listing and reading; the next check reports the folder as it is.
|
|
28
|
-
}
|
|
29
|
-
byName.set(name, [...(byName.get(name) ?? []), { path, hash }]);
|
|
30
|
-
}
|
|
31
|
-
return [...byName].map(([name, files]) => ({ name, files }));
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Everything registration sends, so a caller can tell when it has changed and is worth re-sending. */
|
|
35
|
-
export function describe(config: Config): FoldersRequest {
|
|
36
|
-
return { folders: detectFolders().map((path) => ({ path, skills: localSkills(path, config) })) };
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Registers this machine's folders and skills; returns the folders to sync and the skills in each. */
|
|
40
|
-
export async function register(config: Config, request: FoldersRequest): Promise<ReadonlyArray<FolderPrefs>> {
|
|
41
|
-
const response = await fetch(`${config.endpoint}/folders`, {
|
|
42
|
-
method: "POST",
|
|
43
|
-
headers: authHeaders(config, { "content-type": "application/json" }),
|
|
44
|
-
body: JSON.stringify(request),
|
|
45
|
-
});
|
|
46
|
-
if (!response.ok) throw new Error(`POST /folders: ${response.status} ${await response.text()}`);
|
|
47
|
-
return (await response.json() as FoldersResponse).folders;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function authHeaders(config: Config, extra: Record<string, string>): Record<string, string> {
|
|
51
|
-
return { authorization: `Bearer ${config.token}`, "x-skill-sync-machine": config.machine, ...extra };
|
|
52
|
-
}
|
package/src/internal/path.ts
DELETED
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Validation for the one piece of untrusted input that becomes a filesystem path.
|
|
3
|
-
*
|
|
4
|
-
* Shared by the Worker and the daemon on purpose, and deliberately free of any Node import so the Worker
|
|
5
|
-
* can bundle it. Both ends check: the server so one compromised client cannot poison what every machine
|
|
6
|
-
* pulls, and the daemon because a server it does not control is not a thing to take paths from either.
|
|
7
|
-
*
|
|
8
|
-
* Without this, a pushed path of `../../.ssh/authorized_keys` is joined to the skills directory and
|
|
9
|
-
* written wherever the daemon's user can write.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** Long enough for any real nesting, short enough that nothing can be used to exhaust anything. */
|
|
13
|
-
const MAX_LENGTH = 512;
|
|
14
|
-
|
|
15
|
-
export function invalidPathReason(path: string): string | undefined {
|
|
16
|
-
if (path.length === 0 || path.length > MAX_LENGTH) return "path is empty or too long";
|
|
17
|
-
if (path.includes("\0")) return "path contains a null byte";
|
|
18
|
-
if (path.includes("\\")) return "path must use forward slashes";
|
|
19
|
-
if (path.startsWith("/")) return "path must be relative";
|
|
20
|
-
// A Windows drive letter is absolute on the machine that matters even though it has no leading slash.
|
|
21
|
-
if (/^[A-Za-z]:/.test(path)) return "path must be relative";
|
|
22
|
-
|
|
23
|
-
for (const segment of path.split("/")) {
|
|
24
|
-
if (segment === "") return "path has an empty segment";
|
|
25
|
-
if (segment === "." || segment === "..") return "path must not contain . or .. segments";
|
|
26
|
-
}
|
|
27
|
-
return undefined;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function isValidPath(path: string): boolean {
|
|
31
|
-
return invalidPathReason(path) === undefined;
|
|
32
|
-
}
|
package/src/internal/sync.ts
DELETED
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, utimesSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { dirname, join } from "node:path";
|
|
3
|
-
import { hashBytes, listLocalFiles, type Config, type State } from "./config";
|
|
4
|
-
import { invalidPathReason } from "./path";
|
|
5
|
-
import { skillOf, type ListFilesResponse, type PutFileRequest } from "./wire";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* One sync cycle: take every newer edit the server has, then send every newer edit this folder has.
|
|
9
|
-
*
|
|
10
|
-
* The latest edit wins, always. Every file carries the time it was last edited: here, the file's
|
|
11
|
-
* modification time; on the server, the edit time of the version it holds. When this folder and the
|
|
12
|
-
* server disagree about a file, whichever was edited later is kept, on both sides. Nothing is merged and
|
|
13
|
-
* nothing is set aside, so every folder converges on the same bytes.
|
|
14
|
-
*
|
|
15
|
-
* A pulled file is given the server's edit time as its modification time, so the next cycle does not
|
|
16
|
-
* mistake the pull for a local edit.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
export interface Report {
|
|
20
|
-
readonly pulled: string[];
|
|
21
|
-
readonly pushed: string[];
|
|
22
|
-
readonly deletedLocally: string[];
|
|
23
|
-
/** Paths the server offered that this machine refused to write. */
|
|
24
|
-
readonly rejected: string[];
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** True when a report has nothing in it worth telling anyone. */
|
|
28
|
-
export function isQuiet(report: Report): boolean {
|
|
29
|
-
return [report.pulled, report.pushed, report.deletedLocally, report.rejected].every((list) => list.length === 0);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* `skills` is the synced list. Every other skill in the folder is local to it: never pushed, never
|
|
34
|
-
* overwritten, never deleted by a sync.
|
|
35
|
-
*/
|
|
36
|
-
export async function syncOnce(config: Config, folder: string, state: State, skills: ReadonlySet<string>): Promise<Report> {
|
|
37
|
-
const report: Report = { pulled: [], pushed: [], deletedLocally: [], rejected: [] };
|
|
38
|
-
mkdirSync(folder, { recursive: true });
|
|
39
|
-
|
|
40
|
-
// A skill added to the list since last cycle may have server files older than the cursor; re-read from the start.
|
|
41
|
-
if ([...skills].some((skill) => !(state.skills ?? []).includes(skill))) state.cursor = 0;
|
|
42
|
-
state.skills = [...skills].sort();
|
|
43
|
-
// A skill removed from the list stops being tracked here. Left in `known`, its files would look deleted
|
|
44
|
-
// from this folder and the push would delete them on every machine.
|
|
45
|
-
for (const path of Object.keys(state.known)) {
|
|
46
|
-
if (!skills.has(skillOf(path))) delete state.known[path];
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const present = new Set(listLocalFiles(folder, config.exclude).filter((path) => skills.has(skillOf(path))));
|
|
50
|
-
// Files deleted here since the last cycle. When they were deleted is not recorded anywhere, so the
|
|
51
|
-
// deletion counts as an edit made now: the pull leaves these paths alone and the push deletes them.
|
|
52
|
-
const deletedHere = new Set(Object.keys(state.known).filter((path) => !present.has(path)));
|
|
53
|
-
|
|
54
|
-
const ctx: Cycle = { config, folder, state, report, skills };
|
|
55
|
-
await pull(ctx, deletedHere, present);
|
|
56
|
-
await push(ctx, present);
|
|
57
|
-
return report;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
interface Cycle {
|
|
61
|
-
readonly config: Config;
|
|
62
|
-
readonly folder: string;
|
|
63
|
-
readonly state: State;
|
|
64
|
-
readonly report: Report;
|
|
65
|
-
readonly skills: ReadonlySet<string>;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** When this file was last edited here, to the millisecond the server stores. */
|
|
69
|
-
const editedAt = (file: string): number => Math.floor(statSync(file).mtimeMs);
|
|
70
|
-
|
|
71
|
-
async function pull(ctx: Cycle, deletedHere: ReadonlySet<string>, present: Set<string>): Promise<void> {
|
|
72
|
-
const { config, folder, state, report } = ctx;
|
|
73
|
-
const body = await (await call(config, `/files?since=${state.cursor}`)).json() as ListFilesResponse;
|
|
74
|
-
|
|
75
|
-
for (const file of body.files) {
|
|
76
|
-
// The server is not trusted to supply a path either: one bad row would otherwise write outside the
|
|
77
|
-
// skills directory on every machine that syncs.
|
|
78
|
-
const badPath = invalidPathReason(file.path);
|
|
79
|
-
if (badPath) {
|
|
80
|
-
report.rejected.push(`${file.path} (${badPath})`);
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
if (!ctx.skills.has(skillOf(file.path))) continue;
|
|
84
|
-
// Deleted here, which is newer than anything the server has; the push sends the deletion.
|
|
85
|
-
if (deletedHere.has(file.path)) continue;
|
|
86
|
-
|
|
87
|
-
const local = join(folder, file.path);
|
|
88
|
-
const exists = existsSync(local);
|
|
89
|
-
const localHash = exists ? hashBytes(readFileSync(local)) : undefined;
|
|
90
|
-
if (localHash === (file.hash ?? undefined)) {
|
|
91
|
-
if (file.hash === null) delete state.known[file.path];
|
|
92
|
-
else state.known[file.path] = file.hash;
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
// Edited here since this folder last agreed with the server, and later than the server's version:
|
|
96
|
-
// this folder's edit wins, and the push sends it.
|
|
97
|
-
if (exists && localHash !== state.known[file.path] && editedAt(local) > file.editedAt) continue;
|
|
98
|
-
|
|
99
|
-
await apply(ctx, file, present);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
state.cursor = body.cursor;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
async function push(ctx: Cycle, present: Set<string>): Promise<void> {
|
|
106
|
-
const { config, folder, state, report } = ctx;
|
|
107
|
-
for (const path of [...present]) {
|
|
108
|
-
const file = join(folder, path);
|
|
109
|
-
const bytes = readFileSync(file);
|
|
110
|
-
const hash = hashBytes(bytes);
|
|
111
|
-
if (state.known[path] === hash) continue;
|
|
112
|
-
const later = await put(config, { path, hash, content: Buffer.from(bytes).toString("base64"), editedAt: editedAt(file) });
|
|
113
|
-
if (later === undefined) {
|
|
114
|
-
state.known[path] = hash;
|
|
115
|
-
report.pushed.push(path);
|
|
116
|
-
} else {
|
|
117
|
-
await apply(ctx, { path, ...later }, present);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// A path this folder agreed on that is no longer on disk was deleted here. The server keeps the
|
|
122
|
-
// deletion, or the next machine to sync would upload the file again.
|
|
123
|
-
for (const path of Object.keys(state.known)) {
|
|
124
|
-
if (present.has(path)) continue;
|
|
125
|
-
const later = await put(config, { path, hash: null, content: null, editedAt: Date.now() });
|
|
126
|
-
if (later === undefined) {
|
|
127
|
-
delete state.known[path];
|
|
128
|
-
report.pushed.push(`${path} (deleted)`);
|
|
129
|
-
} else {
|
|
130
|
-
await apply(ctx, { path, ...later }, present);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** The server's version of a file, as it answers a refused write. */
|
|
136
|
-
interface ServerVersion {
|
|
137
|
-
readonly hash: string | null;
|
|
138
|
-
readonly editedAt: number;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Writes the server's version of a file here: its bytes with its edit time, or a deletion. Used by the
|
|
143
|
-
* pull, and by the push when the server refuses a write because it holds a later edit, so a refused write
|
|
144
|
-
* settles at once rather than waiting for a pull that may never list the file again.
|
|
145
|
-
*/
|
|
146
|
-
async function apply(ctx: Cycle, file: ServerVersion & { readonly path: string }, present: Set<string>): Promise<void> {
|
|
147
|
-
const { config, folder, state, report } = ctx;
|
|
148
|
-
const local = join(folder, file.path);
|
|
149
|
-
if (file.hash === null) {
|
|
150
|
-
rmSync(local, { force: true });
|
|
151
|
-
removeEmptyParents(folder, local);
|
|
152
|
-
present.delete(file.path);
|
|
153
|
-
delete state.known[file.path];
|
|
154
|
-
report.deletedLocally.push(file.path);
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
const content = new Uint8Array(await (await call(config, `/content/${file.hash}`)).arrayBuffer());
|
|
158
|
-
mkdirSync(dirname(local), { recursive: true });
|
|
159
|
-
writeFileSync(local, content);
|
|
160
|
-
utimesSync(local, new Date(), new Date(file.editedAt));
|
|
161
|
-
state.known[file.path] = file.hash;
|
|
162
|
-
present.add(file.path);
|
|
163
|
-
report.pulled.push(file.path);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** A deleted skill leaves no empty directory behind, which would still show up as a skill. */
|
|
167
|
-
function removeEmptyParents(folder: string, file: string): void {
|
|
168
|
-
for (let dir = dirname(file); dir !== folder && dir.startsWith(folder); dir = dirname(dir)) {
|
|
169
|
-
if (readdirSync(dir).length > 0) return;
|
|
170
|
-
rmdirSync(dir);
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** Undefined when the server took the write; otherwise the later version it holds instead. */
|
|
175
|
-
async function put(config: Config, body: Omit<PutFileRequest, "machine">): Promise<ServerVersion | undefined> {
|
|
176
|
-
const request: PutFileRequest = { ...body, machine: config.machine };
|
|
177
|
-
const response = await fetch(`${config.endpoint}/files`, {
|
|
178
|
-
method: "PUT",
|
|
179
|
-
headers: { authorization: `Bearer ${config.token}`, "content-type": "application/json" },
|
|
180
|
-
body: JSON.stringify(request),
|
|
181
|
-
});
|
|
182
|
-
if (response.status === 409) {
|
|
183
|
-
const current = await response.json() as { currentHash: string | null; currentEditedAt: number };
|
|
184
|
-
return { hash: current.currentHash, editedAt: current.currentEditedAt };
|
|
185
|
-
}
|
|
186
|
-
if (!response.ok) throw new Error(`PUT /files ${body.path}: ${response.status} ${await response.text()}`);
|
|
187
|
-
return undefined;
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
async function call(config: Config, path: string): Promise<Response> {
|
|
191
|
-
const response = await fetch(`${config.endpoint}${path}`, {
|
|
192
|
-
headers: { authorization: `Bearer ${config.token}` },
|
|
193
|
-
});
|
|
194
|
-
if (!response.ok) throw new Error(`GET ${path}: ${response.status} ${await response.text()}`);
|
|
195
|
-
return response;
|
|
196
|
-
}
|