@jive-sh/skill-sync 0.4.4-latest-darwin-arm64 → 0.4.4
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/README.md +63 -0
- package/bin/package.json +1 -0
- package/bin/skill-sync +571 -0
- package/bin/skill-sync-daemon +571 -0
- package/dist/daemons/skill-sync-daemon.js +425 -0
- package/dist/daemons/skill-sync-daemon.js.map +1 -0
- package/dist/entrypoints/skill-sync.js +134 -0
- package/dist/entrypoints/skill-sync.js.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/config.js +142 -0
- package/dist/internal/config.js.map +1 -0
- package/dist/internal/control.js +101 -0
- package/dist/internal/control.js.map +1 -0
- package/dist/internal/folders.js +46 -0
- package/dist/internal/folders.js.map +1 -0
- package/dist/internal/path.js +36 -0
- package/dist/internal/path.js.map +1 -0
- package/dist/internal/server.js +32 -0
- package/dist/internal/server.js.map +1 -0
- package/dist/internal/sync.js +159 -0
- package/dist/internal/sync.js.map +1 -0
- package/dist/internal/wire.js +13 -0
- package/dist/internal/wire.js.map +1 -0
- package/install.cjs +571 -0
- package/package.json +39 -11
- package/src/daemons/skill-sync-daemon.ts +418 -0
- package/src/entrypoints/skill-sync.ts +136 -0
- package/src/index.ts +15 -0
- package/src/internal/config.ts +183 -0
- package/src/internal/control.ts +127 -0
- package/src/internal/folders.ts +49 -0
- package/src/internal/path.ts +32 -0
- package/src/internal/server.ts +34 -0
- package/src/internal/sync.ts +195 -0
- package/src/internal/wire.ts +154 -0
- package/multi-call-binary +0 -4
package/README.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# @jive-sh/skill-sync
|
|
2
|
+
|
|
3
|
+
Keeps the skills you choose identical across every Claude and Codex skills folder on every machine you
|
|
4
|
+
work on.
|
|
5
|
+
|
|
6
|
+
A daemon finds each agent home on the machine (Claude's `~/.claude` and every `~/.claude-<name>`, and
|
|
7
|
+
Codex's `~/.agents`, counted as soon as either `~/.agents` or `~/.codex` exists) and syncs the `skills`
|
|
8
|
+
folder in each through a Cloudflare Worker: file index in D1, contents in R2 addressed by hash. `skill-sync login` asks the daemon to sign in and opens
|
|
9
|
+
`skill-sync.jive.sh` in your browser, where you verify with your jive.sh passkey (from any passkey
|
|
10
|
+
manager, such as Google Password Manager). The service hands a per-machine token straight to the daemon,
|
|
11
|
+
never through the CLI. The daemon keeps it in `~/.jive/skill-sync-token.json` and starts syncing, and it
|
|
12
|
+
stays signed in through restarts, reboots and upgrades until you run `skill-sync logout` or revoke the
|
|
13
|
+
token.
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
skill-sync login sign this machine in with your passkey; the daemon starts syncing
|
|
17
|
+
skill-sync logout revoke this machine's token and stop syncing
|
|
18
|
+
skill-sync list list the skills that sync, as the server holds them (needs a sign-in)
|
|
19
|
+
skill-sync upgrade upgrade with the package manager that installed it
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Everything else — which folders and skills sync, renaming computers — is on the
|
|
23
|
+
dashboard.
|
|
24
|
+
|
|
25
|
+
The daemon registers itself to start at login when the package is installed.
|
|
26
|
+
|
|
27
|
+
## The dashboard
|
|
28
|
+
|
|
29
|
+
`https://skill-sync.jive.sh/` shows, live:
|
|
30
|
+
|
|
31
|
+
- **Synced skills**: the list, with the shared version of each skill and where it was last edited.
|
|
32
|
+
- **Computers**: each machine (renameable), whether its daemon is running, its skills folders, and the
|
|
33
|
+
skills in each with their files.
|
|
34
|
+
|
|
35
|
+
Every file shows the first 8 characters of its SHA-256, amber when a folder's copy differs from the synced
|
|
36
|
+
one. The page reloads itself when a new version of the service is deployed.
|
|
37
|
+
|
|
38
|
+
The server holds one list of skills to sync. Every enabled folder on every machine pushes and pulls
|
|
39
|
+
exactly the skills on that list, and never touches any other skill. A skill's checkbox adds it to the
|
|
40
|
+
list or removes it, for every machine at once. A folder's checkbox turns syncing on or off for that
|
|
41
|
+
folder; a folder seen for the first time starts on. Both are stored on the server, so they apply at once
|
|
42
|
+
and survive a reinstall.
|
|
43
|
+
|
|
44
|
+
A folder that lacks a listed skill gets a copy. Deleting a listed skill in any folder deletes it in every
|
|
45
|
+
folder and takes it off the list; unticking it instead stops syncing and leaves every copy where it is.
|
|
46
|
+
|
|
47
|
+
## The latest edit wins
|
|
48
|
+
|
|
49
|
+
Every file carries the time it was last edited: on a machine, its modification time; on the server, the
|
|
50
|
+
edit time of the version it holds. Whenever two copies of a file differ, the one edited later replaces
|
|
51
|
+
the other, on the server and in every folder. Nothing is merged and nothing is set aside. A deletion
|
|
52
|
+
counts as an edit made when the daemon noticed it.
|
|
53
|
+
|
|
54
|
+
Edit times come from each machine's clock, so a machine whose clock is far off can win or lose against
|
|
55
|
+
edits it should not.
|
|
56
|
+
|
|
57
|
+
## Configuration
|
|
58
|
+
|
|
59
|
+
`~/.jive/skill-sync.json` holds the endpoint and the machine name, written on first run.
|
|
60
|
+
`~/.jive/skill-sync-token.json` holds this machine's token while it is signed in. It is an ordinary file,
|
|
61
|
+
so anything that can read your home directory can copy the token; signing out, or the server rejecting the
|
|
62
|
+
token, deletes it. The CLI reaches the daemon over `~/.jive/skill-sync.sock` (a named pipe on Windows),
|
|
63
|
+
which only your user can open.
|
package/bin/package.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
package/bin/skill-sync
ADDED
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
// CommonJS, not an ES module: a launcher is an extensionless file, and Node 14 and 16 refuse to load an
|
|
4
|
+
// extensionless ES module in a "type": "module" package (ERR_UNKNOWN_FILE_EXTENSION; Node 18.20 accepts it). bin/package.json marks this directory
|
|
5
|
+
// "commonjs", and the install script is install.cjs, so both load the same way on every Node and on Bun.
|
|
6
|
+
const { spawnSync } = require("child_process");
|
|
7
|
+
const {
|
|
8
|
+
chmodSync,
|
|
9
|
+
copyFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
linkSync,
|
|
12
|
+
mkdirSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
realpathSync,
|
|
16
|
+
renameSync,
|
|
17
|
+
rmSync,
|
|
18
|
+
writeFileSync,
|
|
19
|
+
} = require("fs");
|
|
20
|
+
const { createHash } = require("crypto");
|
|
21
|
+
const { homedir } = require("os");
|
|
22
|
+
const { basename, dirname, join } = require("path");
|
|
23
|
+
|
|
24
|
+
const PLATFORM_MAP = {
|
|
25
|
+
"darwin-arm64": "darwin-arm64",
|
|
26
|
+
"darwin-x64": "darwin-x64",
|
|
27
|
+
"linux-arm64": "linux-arm64",
|
|
28
|
+
"linux-x64": "linux-x64",
|
|
29
|
+
"win32-x64": "windows-x64"
|
|
30
|
+
};
|
|
31
|
+
const PLATFORM_PACKAGES = {
|
|
32
|
+
"darwin-arm64": "@jive-sh/skill-sync-darwin-arm64",
|
|
33
|
+
"darwin-x64": "@jive-sh/skill-sync-darwin-x64",
|
|
34
|
+
"linux-arm64": "@jive-sh/skill-sync-linux-arm64",
|
|
35
|
+
"linux-x64": "@jive-sh/skill-sync-linux-x64",
|
|
36
|
+
"windows-x64": "@jive-sh/skill-sync-windows-x64"
|
|
37
|
+
};
|
|
38
|
+
const PLATFORM_BINARIES = {
|
|
39
|
+
"darwin-arm64": "multi-call-binary",
|
|
40
|
+
"darwin-x64": "multi-call-binary",
|
|
41
|
+
"linux-arm64": "multi-call-binary",
|
|
42
|
+
"linux-x64": "multi-call-binary",
|
|
43
|
+
"windows-x64": "multi-call-binary.exe"
|
|
44
|
+
};
|
|
45
|
+
const COMMANDS = [
|
|
46
|
+
"skill-sync",
|
|
47
|
+
"skill-sync-daemon"
|
|
48
|
+
];
|
|
49
|
+
const PACKAGE_NAME = "@jive-sh/skill-sync";
|
|
50
|
+
// Every src/daemons/ entry. The build refuses to produce a package where this disagrees with the files,
|
|
51
|
+
// so it needs no checking here.
|
|
52
|
+
const DAEMONS = [
|
|
53
|
+
"skill-sync-daemon"
|
|
54
|
+
];
|
|
55
|
+
const DAEMON_ID_PREFIX = "jive-daemon";
|
|
56
|
+
const DAEMON_HOME_DIR = ".jive";
|
|
57
|
+
const DAEMON_REGISTRY_SUBDIR = "daemons";
|
|
58
|
+
const DAEMON_LOG_SUBDIR = "logs";
|
|
59
|
+
const REPAIR_ONLY_FLAG = "--repair-only";
|
|
60
|
+
const BIN_DIR = "bin";
|
|
61
|
+
const INSTALL_SCRIPT = "install.cjs";
|
|
62
|
+
// Root install.cjs repairs command targets; bin launchers repair and then execute.
|
|
63
|
+
const IS_INSTALL_SCRIPT = false;
|
|
64
|
+
const WINDOWS_EXECUTABLE_SUFFIX = ".exe";
|
|
65
|
+
const INSTALLED_JSON = "installed.json";
|
|
66
|
+
|
|
67
|
+
// The real path: reached through a link in a bin directory, the package is wherever the link points.
|
|
68
|
+
const launcherPath = realpathSync(__filename);
|
|
69
|
+
const launcherDir = dirname(launcherPath);
|
|
70
|
+
const packageDir = IS_INSTALL_SCRIPT ? launcherDir : dirname(launcherDir);
|
|
71
|
+
const commandTargetDir = join(packageDir, BIN_DIR);
|
|
72
|
+
const rootInstallScriptPath = join(packageDir, INSTALL_SCRIPT);
|
|
73
|
+
|
|
74
|
+
function fail(message) {
|
|
75
|
+
console.error(message);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function installedPlatformPackage(platformKey) {
|
|
80
|
+
try {
|
|
81
|
+
const packageJsonPath = require.resolve(`${PLATFORM_PACKAGES[platformKey]}/package.json`);
|
|
82
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
83
|
+
return {
|
|
84
|
+
platformKey,
|
|
85
|
+
binaryPath: join(dirname(packageJsonPath), PLATFORM_BINARIES[platformKey]),
|
|
86
|
+
version: packageJson.version,
|
|
87
|
+
};
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The base package has one optional dependency per platform, each holding that platform's build of every
|
|
95
|
+
* command. The package manager installs the one whose "os" and "cpu" match the machine, so the launcher
|
|
96
|
+
* uses whichever is installed rather than deciding the platform again. Asking again can disagree with the
|
|
97
|
+
* package manager: on an Apple Silicon Mac, bun (native) installs darwin-arm64, while an Intel build of
|
|
98
|
+
* Node running under Rosetta reports process.arch "x64".
|
|
99
|
+
* @returns the path to the platform binary and the exact version of the platform package.
|
|
100
|
+
*/
|
|
101
|
+
function currentPlatformPackage() {
|
|
102
|
+
const installed = Object.keys(PLATFORM_PACKAGES).map(installedPlatformPackage).filter(Boolean);
|
|
103
|
+
if (installed.length === 1) return installed[0];
|
|
104
|
+
|
|
105
|
+
const reported = PLATFORM_MAP[`${process.platform}-${process.arch}`];
|
|
106
|
+
const matching = installed.find((platformPackage) => platformPackage.platformKey === reported);
|
|
107
|
+
// More than one is installed only when the package manager was told to (npm's --cpu and --os flags).
|
|
108
|
+
if (matching) return matching;
|
|
109
|
+
|
|
110
|
+
fail(installed.length === 0
|
|
111
|
+
? `None of ${PACKAGE_NAME}'s platform packages is installed (${Object.values(PLATFORM_PACKAGES).join(", ")}). Reinstall ${PACKAGE_NAME}.`
|
|
112
|
+
: `Several of ${PACKAGE_NAME}'s platform packages are installed (${installed.map((p) => PLATFORM_PACKAGES[p.platformKey]).join(", ")}) and none matches ${process.platform}-${process.arch}. Reinstall ${PACKAGE_NAME}.`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function commandTargetPath(command) {
|
|
116
|
+
const suffix = process.platform === "win32" ? WINDOWS_EXECUTABLE_SUFFIX : "";
|
|
117
|
+
return join(commandTargetDir, `${command}${suffix}`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function installedJsonPath() {
|
|
121
|
+
return join(commandTargetDir, INSTALLED_JSON);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function windowsTargetsAreCurrent(version) {
|
|
125
|
+
if (process.platform !== "win32") return false;
|
|
126
|
+
if (!COMMANDS.every((command) => existsSync(commandTargetPath(command)))) return false;
|
|
127
|
+
|
|
128
|
+
try {
|
|
129
|
+
const installed = JSON.parse(readFileSync(installedJsonPath(), "utf-8"));
|
|
130
|
+
return installed.version === version;
|
|
131
|
+
} catch {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function linkOrCopyFirstExecutable(sourcePath, targetPath) {
|
|
137
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
138
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp${process.platform === "win32" ? WINDOWS_EXECUTABLE_SUFFIX : ""}`;
|
|
139
|
+
rmSync(tempPath, { force: true });
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
try {
|
|
143
|
+
linkSync(sourcePath, tempPath);
|
|
144
|
+
} catch {
|
|
145
|
+
copyFileSync(sourcePath, tempPath);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
chmodSync(tempPath, 0o755);
|
|
149
|
+
rmSync(targetPath, { force: true });
|
|
150
|
+
renameSync(tempPath, targetPath);
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(tempPath, { force: true });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function hardLinkCommandTarget(sourcePath, targetPath) {
|
|
157
|
+
if (sourcePath === targetPath) return;
|
|
158
|
+
|
|
159
|
+
const tempPath = `${targetPath}.${process.pid}.${Date.now()}.tmp${process.platform === "win32" ? WINDOWS_EXECUTABLE_SUFFIX : ""}`;
|
|
160
|
+
rmSync(tempPath, { force: true });
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
linkSync(sourcePath, tempPath);
|
|
164
|
+
chmodSync(tempPath, 0o755);
|
|
165
|
+
rmSync(targetPath, { force: true });
|
|
166
|
+
renameSync(tempPath, targetPath);
|
|
167
|
+
} finally {
|
|
168
|
+
rmSync(tempPath, { force: true });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function repairCommandTargets() {
|
|
173
|
+
const platformPackage = currentPlatformPackage();
|
|
174
|
+
|
|
175
|
+
if (windowsTargetsAreCurrent(platformPackage.version)) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const firstTargetPath = commandTargetPath(COMMANDS[0]);
|
|
180
|
+
linkOrCopyFirstExecutable(platformPackage.binaryPath, firstTargetPath);
|
|
181
|
+
|
|
182
|
+
for (const command of COMMANDS.slice(1)) {
|
|
183
|
+
hardLinkCommandTarget(firstTargetPath, commandTargetPath(command));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (process.platform === "win32") {
|
|
187
|
+
writeFileSync(installedJsonPath(), `${JSON.stringify({ version: platformPackage.version }, null, 2)}\n`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function runExecutable(executablePath) {
|
|
192
|
+
const result = spawnSync(executablePath, process.argv.slice(2), { stdio: "inherit" });
|
|
193
|
+
|
|
194
|
+
if (result.error) {
|
|
195
|
+
fail(result.error.message);
|
|
196
|
+
}
|
|
197
|
+
if (result.status !== 0) {
|
|
198
|
+
process.exit(result.status ?? 1);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function removeInstallScript() {
|
|
203
|
+
try {
|
|
204
|
+
rmSync(rootInstallScriptPath, { force: true });
|
|
205
|
+
// TODO: should we also remove the postinstall script line from package.json?
|
|
206
|
+
} catch {
|
|
207
|
+
// Cleanup is best-effort; failing to remove install.cjs should not fail package installation.
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* ------------------------------------------------------------------ daemon registration ---------- */
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Registering a package's daemons with the operating system so they start at login.
|
|
215
|
+
*
|
|
216
|
+
* At login, not at boot: a daemon here acts for one user, with access to that user's keychain, SSH agent
|
|
217
|
+
* and home directory, and a system-wide service would have none of those. On Windows that is a scheduled
|
|
218
|
+
* task rather than a service for the same reason — a service runs in session 0, isolated from the desktop
|
|
219
|
+
* the user is actually logged into.
|
|
220
|
+
*
|
|
221
|
+
* This runs in the installer, which is plain JavaScript, because it has to run on a machine that has only
|
|
222
|
+
* just received the package. The daemon binary cannot register itself: nothing has started it yet.
|
|
223
|
+
*/
|
|
224
|
+
|
|
225
|
+
function jiveHome() {
|
|
226
|
+
return join(homedir(), DAEMON_HOME_DIR);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function registryDir() {
|
|
230
|
+
return join(jiveHome(), DAEMON_REGISTRY_SUBDIR);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function logDir() {
|
|
234
|
+
return join(jiveHome(), DAEMON_LOG_SUBDIR);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function recordPath(id) {
|
|
238
|
+
return join(registryDir(), `${id}.json`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The tree this copy of the package was installed into, which is where its daemons should work.
|
|
243
|
+
*
|
|
244
|
+
* Derived from the path rather than from INIT_CWD, which npm sets and Bun does not, and which answers the
|
|
245
|
+
* wrong question anyway: where the install was *typed*, not what it installed into. Walking out of the
|
|
246
|
+
* enclosing node_modules answers it for a project install and a global one alike, and a package with no
|
|
247
|
+
* node_modules above it — one built and installed in place — is its own root.
|
|
248
|
+
*/
|
|
249
|
+
function installRoot() {
|
|
250
|
+
let dir = packageDir;
|
|
251
|
+
while (true) {
|
|
252
|
+
if (basename(dir) === "node_modules") return dirname(dir);
|
|
253
|
+
const parent = dirname(dir);
|
|
254
|
+
if (parent === dir) return packageDir;
|
|
255
|
+
dir = parent;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* The id a registration is replaced at: the package's name, the daemon's name, and a digest of the
|
|
261
|
+
* directory this copy of the package was installed into.
|
|
262
|
+
*
|
|
263
|
+
* **Where the package is installed is what decides how many of its daemons exist.** Reinstalling or
|
|
264
|
+
* upgrading the same copy keeps the id, so the registration is replaced; a second copy installed somewhere
|
|
265
|
+
* else gets its own id, so each project that installs its own copy gets its own daemon and a globally
|
|
266
|
+
* installed copy gets exactly one.
|
|
267
|
+
*
|
|
268
|
+
* An earlier version had each daemon declare `local` or `global` in the manifest instead. That was a
|
|
269
|
+
* second source of truth that contradicted the real one: a `global` daemon installed into two projects
|
|
270
|
+
* registered once, pointing at whichever installed last, so uninstalling the second took the first
|
|
271
|
+
* project's daemon away — the very failure `local` existed to prevent.
|
|
272
|
+
*/
|
|
273
|
+
/** One id segment: dots separate segments, so a dot inside a name cannot be left as one. */
|
|
274
|
+
function idSegment(value) {
|
|
275
|
+
return value.replace(/[^A-Za-z0-9-]+/g, "-");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Scope and name get a segment each, and an unscoped package gets "_" where its scope would be.
|
|
280
|
+
*
|
|
281
|
+
* Joining them with a dash instead would not be reversible: `@ozyman42/ozy-cli` and a package actually
|
|
282
|
+
* named `ozyman42-ozy-cli` would produce the same text. "_" is safe as the marker because npm forbids a
|
|
283
|
+
* scope or name from starting with it, so no real scope can collide with it.
|
|
284
|
+
*/
|
|
285
|
+
function packageSegments() {
|
|
286
|
+
if (!PACKAGE_NAME.startsWith("@")) return ["_", idSegment(PACKAGE_NAME)];
|
|
287
|
+
const separator = PACKAGE_NAME.indexOf("/");
|
|
288
|
+
return [
|
|
289
|
+
idSegment(PACKAGE_NAME.slice(1, separator)),
|
|
290
|
+
idSegment(PACKAGE_NAME.slice(separator + 1)),
|
|
291
|
+
];
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function daemonId(name) {
|
|
295
|
+
const [scope, packageName] = packageSegments();
|
|
296
|
+
const installDigest = createHash("sha256").update(packageDir).digest("hex").slice(0, 10);
|
|
297
|
+
return `${DAEMON_ID_PREFIX}.${scope}.${packageName}.${idSegment(name)}.${installDigest}`;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function run(argv, allowFailure) {
|
|
301
|
+
const result = spawnSync(argv[0], argv.slice(1), { stdio: ["ignore", "ignore", "pipe"] });
|
|
302
|
+
if (result.error || result.status !== 0) {
|
|
303
|
+
if (!allowFailure) {
|
|
304
|
+
const detail = result.error ? result.error.message : String(result.stderr || "").trim();
|
|
305
|
+
console.error(` ${argv.join(" ")} failed: ${detail}`);
|
|
306
|
+
}
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
return true;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function xmlEscape(value) {
|
|
313
|
+
return String(value)
|
|
314
|
+
.split("&").join("&")
|
|
315
|
+
.split("<").join("<")
|
|
316
|
+
.split(">").join(">");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* launchd. KeepAlive on SuccessfulExit=false restarts a daemon that crashes but respects one that exits
|
|
321
|
+
* zero, which is how a daemon that has removed its own registration stays stopped.
|
|
322
|
+
*/
|
|
323
|
+
function registerLaunchd(id, name, executable, workingDirectory, logPath) {
|
|
324
|
+
// The id verbatim, rather than a reverse-DNS label: one string names this daemon on every platform.
|
|
325
|
+
const label = id;
|
|
326
|
+
const agentsDir = join(homedir(), "Library", "LaunchAgents");
|
|
327
|
+
const plistPath = join(agentsDir, `${label}.plist`);
|
|
328
|
+
const domain = `gui/${process.getuid()}`;
|
|
329
|
+
const unregister = ["launchctl", "bootout", `${domain}/${label}`];
|
|
330
|
+
|
|
331
|
+
mkdirSync(agentsDir, { recursive: true });
|
|
332
|
+
writeFileSync(plistPath, [
|
|
333
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
334
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
335
|
+
'<plist version="1.0">',
|
|
336
|
+
"<dict>",
|
|
337
|
+
` <key>Label</key><string>${xmlEscape(label)}</string>`,
|
|
338
|
+
` <key>ProgramArguments</key><array><string>${xmlEscape(executable)}</string></array>`,
|
|
339
|
+
` <key>WorkingDirectory</key><string>${xmlEscape(workingDirectory)}</string>`,
|
|
340
|
+
" <key>RunAtLoad</key><true/>",
|
|
341
|
+
" <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>",
|
|
342
|
+
` <key>StandardOutPath</key><string>${xmlEscape(logPath)}</string>`,
|
|
343
|
+
` <key>StandardErrorPath</key><string>${xmlEscape(logPath)}</string>`,
|
|
344
|
+
" <key>ProcessType</key><string>Background</string>",
|
|
345
|
+
"</dict>",
|
|
346
|
+
"</plist>",
|
|
347
|
+
"",
|
|
348
|
+
].join("\n"));
|
|
349
|
+
|
|
350
|
+
// Booting out first: bootstrap refuses a label that is already loaded, and a reinstall is the common case.
|
|
351
|
+
// bootout returns before launchd has finished stopping a daemon that is running, and bootstrap is refused
|
|
352
|
+
// until it has ("Bootstrap failed: 5"), so a reinstall over a live daemon left it stopped until the next
|
|
353
|
+
// login. Retry for a few seconds; only the last failure is reported.
|
|
354
|
+
run(unregister, true);
|
|
355
|
+
const attempts = 20;
|
|
356
|
+
let started = false;
|
|
357
|
+
for (let attempt = 1; attempt <= attempts && !started; attempt++) {
|
|
358
|
+
if (attempt > 1) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 250);
|
|
359
|
+
started = run(["launchctl", "bootstrap", domain, plistPath], attempt < attempts);
|
|
360
|
+
}
|
|
361
|
+
return { kind: "launchd", unit: label, files: [plistPath], unregister, started };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** systemd user units. `--user` rather than system units, for the same reason launchd gets a LaunchAgent. */
|
|
365
|
+
function registerSystemd(id, name, executable, workingDirectory, logPath) {
|
|
366
|
+
const unit = `${id}.service`;
|
|
367
|
+
const unitDir = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "systemd", "user");
|
|
368
|
+
const unitPath = join(unitDir, unit);
|
|
369
|
+
const unregister = ["systemctl", "--user", "disable", "--now", unit];
|
|
370
|
+
|
|
371
|
+
mkdirSync(unitDir, { recursive: true });
|
|
372
|
+
writeFileSync(unitPath, [
|
|
373
|
+
"[Unit]",
|
|
374
|
+
`Description=Jive daemon ${name} for ${PACKAGE_NAME}`,
|
|
375
|
+
"",
|
|
376
|
+
"[Service]",
|
|
377
|
+
"Type=simple",
|
|
378
|
+
`ExecStart=${executable}`,
|
|
379
|
+
`WorkingDirectory=${workingDirectory}`,
|
|
380
|
+
"Restart=on-failure",
|
|
381
|
+
"RestartSec=5",
|
|
382
|
+
"",
|
|
383
|
+
"[Install]",
|
|
384
|
+
"WantedBy=default.target",
|
|
385
|
+
"",
|
|
386
|
+
].join("\n"));
|
|
387
|
+
|
|
388
|
+
run(["systemctl", "--user", "daemon-reload"], true);
|
|
389
|
+
const started = run(["systemctl", "--user", "enable", "--now", unit], false);
|
|
390
|
+
return { kind: "systemd", unit, files: [unitPath], unregister, started };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Windows Task Scheduler, through a .cmd shim.
|
|
395
|
+
*
|
|
396
|
+
* The shim exists because schtasks can set neither a working directory nor environment variables, and it
|
|
397
|
+
* also supplies the restart-on-crash behaviour the other two platforms get from their service manager: an
|
|
398
|
+
* ONLOGON task starts its program once and does not watch it. The loop mirrors launchd's KeepAlive, so a
|
|
399
|
+
* daemon that exits zero after removing itself is not restarted.
|
|
400
|
+
*/
|
|
401
|
+
function registerScheduledTask(id, name, executable, workingDirectory, logPath) {
|
|
402
|
+
const taskName = id;
|
|
403
|
+
const shimPath = join(registryDir(), `${id}.cmd`);
|
|
404
|
+
const unregister = ["schtasks", "/Delete", "/F", "/TN", taskName];
|
|
405
|
+
|
|
406
|
+
writeFileSync(shimPath, [
|
|
407
|
+
"@echo off",
|
|
408
|
+
`cd /d "${workingDirectory}"`,
|
|
409
|
+
":jive_loop",
|
|
410
|
+
`"${executable}" >> "${logPath}" 2>&1`,
|
|
411
|
+
"if %ERRORLEVEL% EQU 0 goto :eof",
|
|
412
|
+
"timeout /t 5 /nobreak > nul",
|
|
413
|
+
"goto jive_loop",
|
|
414
|
+
"",
|
|
415
|
+
].join("\r\n"));
|
|
416
|
+
|
|
417
|
+
const started = run([
|
|
418
|
+
"schtasks", "/Create", "/F",
|
|
419
|
+
"/SC", "ONLOGON",
|
|
420
|
+
"/TN", taskName,
|
|
421
|
+
"/TR", `"${shimPath}"`,
|
|
422
|
+
"/RL", "LIMITED",
|
|
423
|
+
], false);
|
|
424
|
+
return { kind: "scheduled-task", unit: taskName, files: [shimPath], unregister, started };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const DAEMON_REGISTRARS = {
|
|
428
|
+
darwin: registerLaunchd,
|
|
429
|
+
linux: registerSystemd,
|
|
430
|
+
win32: registerScheduledTask,
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
function readRecord(path) {
|
|
434
|
+
try {
|
|
435
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
436
|
+
} catch {
|
|
437
|
+
return undefined;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function removeRegistration(record, reason) {
|
|
442
|
+
console.log(`Removing daemon ${record.id} (${reason})`);
|
|
443
|
+
if (Array.isArray(record.unregister) && record.unregister.length > 0) {
|
|
444
|
+
run(record.unregister, true);
|
|
445
|
+
}
|
|
446
|
+
for (const file of record.files || []) {
|
|
447
|
+
rmSync(file, { force: true });
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Drops this package's registrations that should no longer exist.
|
|
453
|
+
*
|
|
454
|
+
* Three cases, all of which would otherwise leave the operating system starting a missing or unwanted
|
|
455
|
+
* executable at every login: a daemon this version of the package no longer has, a copy of the package
|
|
456
|
+
* whose directory is gone, and a registration of this very copy under an id we no longer use — which is
|
|
457
|
+
* what makes a change to the id scheme replace its registrations instead of doubling them.
|
|
458
|
+
*
|
|
459
|
+
* Only this package's own registrations are considered, by name, so another package's daemon is never
|
|
460
|
+
* touched; and only this copy's, except where the copy itself has been deleted.
|
|
461
|
+
*/
|
|
462
|
+
function pruneRemovedDaemons(currentIds) {
|
|
463
|
+
let entries;
|
|
464
|
+
try {
|
|
465
|
+
entries = readdirSync(registryDir());
|
|
466
|
+
} catch {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
for (const entry of entries) {
|
|
471
|
+
if (!entry.endsWith(".json")) continue;
|
|
472
|
+
const path = join(registryDir(), entry);
|
|
473
|
+
const record = readRecord(path);
|
|
474
|
+
if (!record || record.packageName !== PACKAGE_NAME) continue;
|
|
475
|
+
|
|
476
|
+
const reason =
|
|
477
|
+
!DAEMONS.includes(record.name) ? `no longer part of ${PACKAGE_NAME}`
|
|
478
|
+
: !existsSync(record.packageDir) ? `${record.packageDir} is gone`
|
|
479
|
+
: record.packageDir === packageDir && !currentIds.includes(record.id) ? "superseded by a new id"
|
|
480
|
+
: undefined;
|
|
481
|
+
if (reason === undefined) continue;
|
|
482
|
+
|
|
483
|
+
removeRegistration(record, reason);
|
|
484
|
+
rmSync(path, { force: true });
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function registerDaemons() {
|
|
489
|
+
// Two reasons not to register. The caller asked for repair alone — the local deploy does, because its
|
|
490
|
+
// install exists so the launcher can resolve a platform package, not because anyone chose to install
|
|
491
|
+
// that copy. Or this is a build agent, which installs a package to run one command and is then
|
|
492
|
+
// discarded: registering something to start at a login that will never happen is pure noise, and in a
|
|
493
|
+
// container it fails outright.
|
|
494
|
+
const skip = IS_INSTALL_SCRIPT && process.argv.includes(REPAIR_ONLY_FLAG) ? `${REPAIR_ONLY_FLAG} was passed`
|
|
495
|
+
: process.env.CI ? "CI is set"
|
|
496
|
+
: undefined;
|
|
497
|
+
const names = skip === undefined ? DAEMONS : [];
|
|
498
|
+
|
|
499
|
+
// Pruning first, and with no current ids when skipping: declining to run daemons here has to also take
|
|
500
|
+
// away any this copy registered earlier, or one such install would leave them starting at every login
|
|
501
|
+
// forever. Before the early return below, too — a version that drops its last daemon still has to take
|
|
502
|
+
// the previous version's registration away, and the record carries everything undoing it needs.
|
|
503
|
+
pruneRemovedDaemons(names.map(daemonId));
|
|
504
|
+
if (skip !== undefined) {
|
|
505
|
+
if (DAEMONS.length > 0) console.log(`${skip} — not registering ${DAEMONS.join(", ")}`);
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (names.length === 0) return;
|
|
509
|
+
|
|
510
|
+
const registrar = DAEMON_REGISTRARS[process.platform];
|
|
511
|
+
if (!registrar) {
|
|
512
|
+
console.log(`No daemon support for ${process.platform} — ${names.join(", ")} will not start automatically`);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
mkdirSync(registryDir(), { recursive: true });
|
|
517
|
+
mkdirSync(logDir(), { recursive: true });
|
|
518
|
+
|
|
519
|
+
for (const name of names) {
|
|
520
|
+
const id = daemonId(name);
|
|
521
|
+
const path = recordPath(id);
|
|
522
|
+
const previous = readRecord(path);
|
|
523
|
+
const workingDirectory = installRoot();
|
|
524
|
+
const executable = commandTargetPath(name);
|
|
525
|
+
const logPath = join(logDir(), `${id}.log`);
|
|
526
|
+
|
|
527
|
+
console.log(
|
|
528
|
+
`${previous ? "Replacing existing" : "Creating"} daemon ${id}`
|
|
529
|
+
+ ` (${name} from ${PACKAGE_NAME}) in ${workingDirectory}`,
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
// The record goes down before the unit is loaded, because loading it starts the daemon, and the first
|
|
533
|
+
// thing the daemon does is read this file to find out how it was registered.
|
|
534
|
+
const pending = {
|
|
535
|
+
id,
|
|
536
|
+
name,
|
|
537
|
+
packageName: PACKAGE_NAME,
|
|
538
|
+
packageDir,
|
|
539
|
+
workingDirectory,
|
|
540
|
+
executable,
|
|
541
|
+
registeredAt: new Date().toISOString(),
|
|
542
|
+
files: [path],
|
|
543
|
+
unregister: previous && previous.unregister ? previous.unregister : [],
|
|
544
|
+
};
|
|
545
|
+
writeFileSync(path, `${JSON.stringify(pending, null, 2)}\n`);
|
|
546
|
+
|
|
547
|
+
const outcome = registrar(id, name, executable, workingDirectory, logPath);
|
|
548
|
+
// kind and unit are recorded for the sake of reading a daemon's status later: they are what a listing
|
|
549
|
+
// command has to ask the platform about, and only the installer knows which registrar ran.
|
|
550
|
+
writeFileSync(path, `${JSON.stringify({
|
|
551
|
+
...pending,
|
|
552
|
+
kind: outcome.kind,
|
|
553
|
+
unit: outcome.unit,
|
|
554
|
+
logPath,
|
|
555
|
+
files: [...outcome.files, path],
|
|
556
|
+
unregister: outcome.unregister,
|
|
557
|
+
}, null, 2)}\n`);
|
|
558
|
+
|
|
559
|
+
if (!outcome.started) {
|
|
560
|
+
console.error(` ${id} is registered but could not be started; it will start at your next login`);
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
repairCommandTargets();
|
|
566
|
+
registerDaemons();
|
|
567
|
+
removeInstallScript();
|
|
568
|
+
if (!IS_INSTALL_SCRIPT) {
|
|
569
|
+
const commandName = basename(launcherPath);
|
|
570
|
+
runExecutable(commandTargetPath(commandName));
|
|
571
|
+
}
|