@jive-sh/skill-sync 0.4.0-latest-darwin-arm64 → 0.4.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.
@@ -0,0 +1,123 @@
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
+
5
+ /**
6
+ * The channel between the CLI and the running daemon.
7
+ *
8
+ * The daemon owns the sync token: it receives it from the browser and keeps it only in memory, so it is
9
+ * never in a file and never passes through the CLI. The CLI's `login` and `logout` are requests to the
10
+ * daemon over this channel. Messages are newline-delimited JSON; each request gets events until one that
11
+ * ends it.
12
+ */
13
+
14
+ export type ControlRequest =
15
+ | { readonly type: "login" }
16
+ | { readonly type: "logout" };
17
+
18
+ export type ControlEvent =
19
+ /** Sign-in has started: the CLI opens this URL in the browser, where the passkey is verified. */
20
+ | { readonly type: "open"; readonly url: string }
21
+ | { readonly type: "signedIn"; readonly machine: string }
22
+ | { readonly type: "signedOut"; readonly revoked: boolean }
23
+ | { readonly type: "error"; readonly message: string };
24
+
25
+ const isFinal = (event: ControlEvent): boolean => event.type !== "open";
26
+
27
+ /** The daemon is not running, as opposed to running and refusing the request. */
28
+ export class DaemonUnreachable extends Error {}
29
+
30
+ /** Sends one request and calls `onEvent` for each event until the final one, which it resolves with. */
31
+ export function request(message: ControlRequest, onEvent: (event: ControlEvent) => void): Promise<ControlEvent> {
32
+ return new Promise((resolve, reject) => {
33
+ let settled = false;
34
+ const socket = createConnection(CONTROL_SOCKET);
35
+ socket.on("connect", () => socket.write(`${JSON.stringify(message)}\n`));
36
+ socket.on("error", (cause: NodeJS.ErrnoException) => {
37
+ if (settled) return;
38
+ settled = true;
39
+ reject(cause.code === "ENOENT" || cause.code === "ECONNREFUSED"
40
+ ? new DaemonUnreachable(`the skill-sync daemon is not running (${cause.code} on ${CONTROL_SOCKET})`)
41
+ : cause);
42
+ });
43
+ readLines(socket, (line) => {
44
+ const event = JSON.parse(line) as ControlEvent;
45
+ onEvent(event);
46
+ if (isFinal(event) && !settled) {
47
+ settled = true;
48
+ socket.end();
49
+ resolve(event);
50
+ }
51
+ });
52
+ socket.on("close", () => {
53
+ if (!settled) {
54
+ settled = true;
55
+ reject(new Error("the daemon closed the connection without answering"));
56
+ }
57
+ });
58
+ });
59
+ }
60
+
61
+ /** One request being answered: send events, and end the conversation with a final one. */
62
+ export interface Reply {
63
+ readonly send: (event: ControlEvent) => void;
64
+ }
65
+
66
+ /**
67
+ * Listens on the control socket. Resolves false, without listening, when another live daemon already
68
+ * holds it: two installs of skill-sync mean two daemons, and the second stands down rather than fight.
69
+ */
70
+ export async function serve(handle: (message: ControlRequest, reply: Reply) => void): Promise<Server | false> {
71
+ if (await answers()) return false;
72
+ // Nothing answered, so a socket file left here is from a daemon that died; it would block the listen.
73
+ if (process.platform !== "win32") rmSync(CONTROL_SOCKET, { force: true });
74
+
75
+ const server = createServer((socket) => {
76
+ const reply: Reply = {
77
+ send: (event) => {
78
+ if (socket.writable) socket.write(`${JSON.stringify(event)}\n`);
79
+ if (isFinal(event)) socket.end();
80
+ },
81
+ };
82
+ readLines(socket, (line) => {
83
+ try {
84
+ handle(JSON.parse(line) as ControlRequest, reply);
85
+ } catch (cause) {
86
+ reply.send({ type: "error", message: cause instanceof Error ? cause.message : String(cause) });
87
+ }
88
+ });
89
+ socket.on("error", () => undefined); // A CLI that went away mid-request; nothing to tell it.
90
+ });
91
+ await new Promise<void>((resolve, reject) => {
92
+ server.once("error", reject);
93
+ server.listen(CONTROL_SOCKET, () => resolve());
94
+ });
95
+ // Only this user may talk to the daemon that holds their token.
96
+ if (process.platform !== "win32") chmodSync(CONTROL_SOCKET, 0o600);
97
+ return server;
98
+ }
99
+
100
+ /** Whether a live daemon is listening on the control socket. */
101
+ function answers(): Promise<boolean> {
102
+ return new Promise((resolve) => {
103
+ const socket = createConnection(CONTROL_SOCKET);
104
+ socket.on("connect", () => {
105
+ socket.end();
106
+ resolve(true);
107
+ });
108
+ socket.on("error", () => resolve(false));
109
+ });
110
+ }
111
+
112
+ function readLines(socket: Socket, onLine: (line: string) => void): void {
113
+ let buffer = "";
114
+ socket.on("data", (chunk) => {
115
+ buffer += chunk.toString();
116
+ let newline: number;
117
+ while ((newline = buffer.indexOf("\n")) >= 0) {
118
+ const line = buffer.slice(0, newline).trim();
119
+ buffer = buffer.slice(newline + 1);
120
+ if (line !== "") onLine(line);
121
+ }
122
+ });
123
+ }
@@ -0,0 +1,52 @@
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
+ }
@@ -0,0 +1,32 @@
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
+ }
@@ -0,0 +1,196 @@
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
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The shapes the daemon and the service exchange.
3
+ *
4
+ * Declared in the library and imported by the service so there is one definition rather than two that
5
+ * drift. The service depends on the library; nothing goes the other way.
6
+ */
7
+
8
+ export interface RemoteFile {
9
+ readonly path: string;
10
+ /** Null when the file is deleted; the row stays, so the deletion reaches every machine. */
11
+ readonly hash: string | null;
12
+ /** When this version was edited, in milliseconds since the epoch. The latest edit wins. */
13
+ readonly editedAt: number;
14
+ readonly updatedBy: string;
15
+ }
16
+
17
+ export interface ListFilesResponse {
18
+ /** Highest sequence number in this page; the caller sends it back as `since`. */
19
+ readonly cursor: number;
20
+ readonly files: ReadonlyArray<RemoteFile>;
21
+ }
22
+
23
+ /** Taken only when `editedAt` is later than the edit the server holds; refused with 409 otherwise. */
24
+ export interface PutFileRequest {
25
+ readonly path: string;
26
+ /** When the file was edited: its modification time, or for a deletion, when it was noticed. */
27
+ readonly editedAt: number;
28
+ /** Null to delete. */
29
+ readonly hash: string | null;
30
+ /** Base64, absent for a delete. */
31
+ readonly content: string | null;
32
+ readonly machine: string;
33
+ }
34
+
35
+ /** Where a folder's sync stands, as the dashboard shows it. `attention` means a cycle failed; `detail` says why. */
36
+ export type FolderState = "idle" | "attention";
37
+
38
+ /**
39
+ * A skill is a top-level entry of a skills folder — usually a directory like `jive/` holding `SKILL.md`
40
+ * and its files — and it is the unit of opting in: all of its files sync, or none do, so a skill never
41
+ * arrives half-present on another machine.
42
+ */
43
+ export const skillOf = (path: string): string => path.split("/")[0]!;
44
+
45
+ /** A file and the SHA-256 of its content — the same hash the server stores, so copies can be compared. */
46
+ export interface FileHash {
47
+ readonly path: string;
48
+ readonly hash: string;
49
+ }
50
+
51
+ /** One skill as a folder holds it. */
52
+ export interface LocalSkill {
53
+ readonly name: string;
54
+ readonly files: ReadonlyArray<FileHash>;
55
+ }
56
+
57
+ /**
58
+ * `POST /folders`: every folder this machine found, with the skills in each, for the dashboard. Answered
59
+ * with the enabled folders, each with the synced list: the daemon pushes and pulls exactly those skills,
60
+ * and never touches any other.
61
+ */
62
+ export interface FoldersRequest {
63
+ readonly folders: ReadonlyArray<{ readonly path: string; readonly skills: ReadonlyArray<LocalSkill> }>;
64
+ }
65
+ export interface FolderPrefs {
66
+ readonly path: string;
67
+ readonly skills: ReadonlyArray<string>;
68
+ }
69
+ export interface FoldersResponse {
70
+ readonly folders: ReadonlyArray<FolderPrefs>;
71
+ }
72
+
73
+ /** Daemon → hub, over the daemon's WebSocket. */
74
+ export type DaemonMessage = {
75
+ readonly type: "status";
76
+ readonly folder: string;
77
+ readonly state: FolderState;
78
+ readonly detail?: string;
79
+ /** A one-line summary of what the last cycle changed, when it changed anything. */
80
+ readonly activity?: string;
81
+ };
82
+
83
+ /** Hub → daemon. `prefs` replaces what syncs; `changed` means another write landed on the server. */
84
+ export type HubToDaemon =
85
+ | { readonly type: "prefs"; readonly folders: ReadonlyArray<FolderPrefs> }
86
+ | { readonly type: "changed" };
87
+
88
+ export interface SkillView {
89
+ readonly name: string;
90
+ /** On the list of synced skills, which every enabled folder sends and receives. */
91
+ readonly synced: boolean;
92
+ /** The folder has a copy on disk. */
93
+ readonly local: boolean;
94
+ readonly files: ReadonlyArray<FileHash>;
95
+ }
96
+
97
+ export interface FolderView {
98
+ readonly path: string;
99
+ readonly enabled: boolean;
100
+ readonly state: FolderState;
101
+ readonly detail: string | null;
102
+ readonly lastActivityAt: number | null;
103
+ readonly lastActivity: string | null;
104
+ readonly skills: ReadonlyArray<SkillView>;
105
+ }
106
+
107
+ export interface MachineView {
108
+ /** The name the machine's daemon uses; it identifies the machine and never changes. */
109
+ readonly machine: string;
110
+ /** What the dashboard calls it: renamed there, or the machine name until then. */
111
+ readonly name: string;
112
+ /** True while this machine's daemon holds a connection to the hub. */
113
+ readonly online: boolean;
114
+ readonly lastSeen: number | null;
115
+ readonly folders: ReadonlyArray<FolderView>;
116
+ }
117
+
118
+ /** A skill on the synced list, as the server holds it: the version every enabled folder converges on. */
119
+ export interface SyncedSkill {
120
+ readonly name: string;
121
+ readonly files: ReadonlyArray<FileHash & { readonly updatedBy: string; readonly editedAt: number }>;
122
+ }
123
+
124
+ /** Hub → dashboard: the whole picture, resent on every change. It is small, and a diff protocol is not. */
125
+ export interface Snapshot {
126
+ readonly type: "snapshot";
127
+ /** The deployed service version. A dashboard built by another version reloads itself. */
128
+ readonly version: string;
129
+ /** Display name for every machine name that appears anywhere in the snapshot, including old log entries. */
130
+ readonly names: Readonly<Record<string, string>>;
131
+ readonly skills: ReadonlyArray<SyncedSkill>;
132
+ readonly machines: ReadonlyArray<MachineView>;
133
+ }
134
+
135
+ /** Dashboard → hub. */
136
+ export type DashboardMessage =
137
+ | { readonly type: "setFolder"; readonly machine: string; readonly path: string; readonly enabled: boolean }
138
+ /** Adds a skill to the synced list, or removes it: one list for every machine and folder. */
139
+ | { readonly type: "setSkill"; readonly skill: string; readonly synced: boolean }
140
+ /** An empty name restores the machine name. */
141
+ | { readonly type: "renameMachine"; readonly machine: string; readonly name: string };
package/multi-call-binary DELETED
@@ -1,4 +0,0 @@
1
- [diffend] Oversized file quarantined before diffing.
2
- name: package/multi-call-binary
3
- size: 91174370 bytes
4
- sha256: f59b146b747e2e96e2b1470718fbe038382006a49a67845c55783ef053c6d3e4