@ccoalm/ccl-skills 0.18.7 → 0.18.8
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 +69 -2
- package/dist/assets/release.json +16 -6
- package/dist/auto-update.d.ts +26 -0
- package/dist/auto-update.js +551 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +29 -3
- package/dist/opencode-adapter.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,7 +60,7 @@ ccl-skills uninstall # preview
|
|
|
60
60
|
ccl-skills uninstall --yes # remove host assets
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
Limit
|
|
63
|
+
Limit these operations to one host with `--host claude`, `--host codex`, or `--host opencode`. Add `--json` for machine-readable output.
|
|
64
64
|
|
|
65
65
|
For `--host codex`, unreadable public plugin state returns exit `3` with `host-state-unknown`; a missing CLI or failed capability probe returns `4`. If that host failure occurs with a pending journal, recovery is deferred: exit `5` with `partial-journal` retains the journal and records `details.hostFailure`. Restore the CLI or readable public plugin state, then rerun the command. Other outcomes can share these exit codes, so inspect the JSON status as well.
|
|
66
66
|
|
|
@@ -70,9 +70,76 @@ Codex `doctor` also reads the native hook inventory. When every expected package
|
|
|
70
70
|
|
|
71
71
|
After `ccl-skills uninstall --yes`, remove the CLI package itself with `npm uninstall --global @ccoalm/ccl-skills` if it is no longer needed.
|
|
72
72
|
|
|
73
|
+
## Automatic CCL updates
|
|
74
|
+
|
|
75
|
+
On macOS, explicitly enable daily CCL updates for one host:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
# Existing Codex Git plugin; Codex is the default host
|
|
79
|
+
npx --yes @ccoalm/ccl-skills@latest auto-update enable
|
|
80
|
+
npx --yes @ccoalm/ccl-skills@latest auto-update status --json
|
|
81
|
+
npx --yes @ccoalm/ccl-skills@latest auto-update disable
|
|
82
|
+
|
|
83
|
+
# Existing npm-managed OpenCode skills, plugin and hooks
|
|
84
|
+
npx --yes @ccoalm/ccl-skills@latest auto-update enable --host opencode
|
|
85
|
+
npx --yes @ccoalm/ccl-skills@latest auto-update status --host opencode --json
|
|
86
|
+
npx --yes @ccoalm/ccl-skills@latest auto-update disable --host opencode
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
With the CLI installed globally, replace `npx --yes @ccoalm/ccl-skills@latest` with `ccl-skills`. Each command acts on one host and executes directly; `--yes` and `--host all` are unsupported. Installation never enables scheduling. Separate launchd jobs run every 24 hours while logged in and when loaded at login. A non-root macOS user is required; other platforms return exit `4` with `unsupported-platform`.
|
|
90
|
+
|
|
91
|
+
### Codex
|
|
92
|
+
|
|
93
|
+
The target is `ccl-skills@ccl-skills` in the current `CODEX_HOME`, or `~/.codex` when unset. Its marketplace and plugin must use the canonical `ccoalm/ccl-skills` Git repository over HTTPS or SSH, and the plugin must be enabled. Codex must support `--no-daemon` and plugin JSON output.
|
|
94
|
+
|
|
95
|
+
Each run checks public plugin state before and between these steps:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
codex --no-daemon plugin marketplace upgrade ccl-skills
|
|
99
|
+
codex --no-daemon plugin add ccl-skills@ccl-skills
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
An unsuccessful upgrade prevents the add step. Each update command has a two-minute limit; timeout terminates its child process group. Source changes are checked at those boundaries; Codex does not provide an atomic compare-and-update operation. Restart Codex to load changes. Hook trust follows Codex's normal policy.
|
|
103
|
+
|
|
104
|
+
Codex npm installations use a local snapshot under `ccl-skills-npm`, which this scheduler refuses. Keep those current with `ccl-skills update --yes`. To migrate, preview removal with `ccl-skills uninstall --host codex`, then repeat with `--yes`. Register the Git installation explicitly:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
codex --no-daemon plugin marketplace add https://github.com/ccoalm/ccl-skills.git
|
|
108
|
+
codex --no-daemon plugin add ccl-skills@ccl-skills
|
|
109
|
+
npx --yes @ccoalm/ccl-skills@latest auto-update enable
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### OpenCode
|
|
113
|
+
|
|
114
|
+
OpenCode requires an existing healthy npm-managed CCL installation with bundled assets. The scheduler updates only its CCL skills, native plugin and hook runtime under `~/.config/opencode`. It preserves OpenCode itself, application configuration, authentication, unrelated files and `~/.agents`. Restart OpenCode to load changes.
|
|
115
|
+
|
|
116
|
+
Each run downloads `@ccoalm/ccl-skills@latest` from the public npm registry into a fresh private directory. It disables lifecycle scripts and package self-update, validates package identity, then invokes the fetched CLI's OpenCode doctor and update commands. It refuses missing installations, source overrides and locally changed managed files. A newer installed version is retained. Success requires a healthy receipt matching the fetched package version and bundled source.
|
|
117
|
+
|
|
118
|
+
Source-checkout installs are unsupported. To migrate, first back up their CCL files and receipt, move only those verified CCL-owned files out of OpenCode's shared directories, then explicitly run the npm installer:
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
npx --yes @ccoalm/ccl-skills@latest install --host opencode
|
|
122
|
+
npx --yes @ccoalm/ccl-skills@latest doctor --host opencode
|
|
123
|
+
npx --yes @ccoalm/ccl-skills@latest auto-update enable --host opencode
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The scheduler never adopts or deletes a source-copy installation. Resolve any collision or drift reported by the installer before enabling it.
|
|
127
|
+
|
|
128
|
+
Package download has a three-minute limit; asset update has a two-minute limit. Cancellation sends SIGINT and allows ten seconds for rollback before forcing termination. A forced termination reports unknown finality; inspect `doctor --host opencode` before retrying. Fetch and preflight failures leave installed assets intact.
|
|
129
|
+
|
|
130
|
+
### Status and recovery
|
|
131
|
+
|
|
132
|
+
State lives in `$CODEX_HOME/ccl-skills-auto-update` for Codex and `~/.config/opencode/ccl-skills-auto-update` for OpenCode. Each profile has its own file in `~/Library/LaunchAgents`. The saved runner survives removal of an npx download and keeps the executable paths and profile selected at enable time. Those executables must remain installed. Re-enabling retains the saved runner; upgrading the npm CLI does not replace it.
|
|
133
|
+
|
|
134
|
+
Commands receive fixed HOME/PATH plus CODEX_HOME and `GIT_TERMINAL_PROMPT=0` for Codex, or the self-update/notifier opt-outs for OpenCode. npm uses private empty config files and cache. Credentials and source/registry overrides from the enabling shell are not persisted; macOS may add runtime variables.
|
|
135
|
+
|
|
136
|
+
`status` checks live registration and the latest bounded `last-run.json` record, which excludes raw command output. Failed or interrupted runs, registration errors and ownership errors return exit `5`. Restore the reported dependency and retry `enable` or `disable`. Overlapping runs are skipped. `disable` removes only that host's owned schedule and retains its runner, state and log. Disable first if removing the CLI should also stop updates.
|
|
137
|
+
|
|
138
|
+
SIGINT and SIGTERM release owned locks, including between commands. A crash or forced kill can leave a `run.lock` or `manage.lock`. A lock at least 15 minutes old, or dated at least 15 minutes into the future, reports failure even if its PID is alive. The scheduler never removes these locks automatically. Inspect the recorded PID and launchd job; remove only that lock after confirming no updater owns it. Never remove a running updater's lock.
|
|
139
|
+
|
|
73
140
|
## Update notice
|
|
74
141
|
|
|
75
|
-
|
|
142
|
+
The npm package does not update itself automatically. An interactive run prints a one-line notice on **stderr** when a newer version exists, at most once a day per version:
|
|
76
143
|
|
|
77
144
|
```
|
|
78
145
|
Update available: 0.2.0 -> 0.3.0
|
package/dist/assets/release.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"npmPackage": "@ccoalm/ccl-skills",
|
|
4
|
-
"version": "0.18.
|
|
5
|
-
"sourceCommit": "
|
|
4
|
+
"version": "0.18.8",
|
|
5
|
+
"sourceCommit": "32941fa8b6a3d9d98779f18802dea0c21d3b61ff",
|
|
6
6
|
"sourceState": "clean",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
@@ -3352,6 +3352,16 @@
|
|
|
3352
3352
|
}
|
|
3353
3353
|
],
|
|
3354
3354
|
"runtime": [
|
|
3355
|
+
{
|
|
3356
|
+
"path": "auto-update.d.ts",
|
|
3357
|
+
"sha256": "980512a33d96d431e4ddfa3eae73e1921cd7353c6edcd0590aba38cf3fd9c8fc",
|
|
3358
|
+
"mode": 420
|
|
3359
|
+
},
|
|
3360
|
+
{
|
|
3361
|
+
"path": "auto-update.js",
|
|
3362
|
+
"sha256": "a77be76bb68c1281d9ad046add1b2f3713337001754fd6339792c7fb0550122a",
|
|
3363
|
+
"mode": 420
|
|
3364
|
+
},
|
|
3355
3365
|
{
|
|
3356
3366
|
"path": "claude-adapter.d.ts",
|
|
3357
3367
|
"sha256": "c4a33e6f04af043351174c4df0b2f66a0f76300a82d16598ae7fb4767fe11b8f",
|
|
@@ -3374,12 +3384,12 @@
|
|
|
3374
3384
|
},
|
|
3375
3385
|
{
|
|
3376
3386
|
"path": "cli.d.ts",
|
|
3377
|
-
"sha256": "
|
|
3387
|
+
"sha256": "8cad36e2eba4bd3d62ab660050bca95dc3b2c204dd791e18a1bf0c82531985a2",
|
|
3378
3388
|
"mode": 420
|
|
3379
3389
|
},
|
|
3380
3390
|
{
|
|
3381
3391
|
"path": "cli.js",
|
|
3382
|
-
"sha256": "
|
|
3392
|
+
"sha256": "eeebfd87d3b83885e683698e10596d8fc8b3a25e6b6ddf609cbf8e8e00506373",
|
|
3383
3393
|
"mode": 493
|
|
3384
3394
|
},
|
|
3385
3395
|
{
|
|
@@ -3449,7 +3459,7 @@
|
|
|
3449
3459
|
},
|
|
3450
3460
|
{
|
|
3451
3461
|
"path": "opencode-adapter.js",
|
|
3452
|
-
"sha256": "
|
|
3462
|
+
"sha256": "b2eb03764adb252924d355281bffa8532f6add36d06cb2a6b907b4406b4e4c13",
|
|
3453
3463
|
"mode": 420
|
|
3454
3464
|
},
|
|
3455
3465
|
{
|
|
@@ -3523,5 +3533,5 @@
|
|
|
3523
3533
|
"mode": 420
|
|
3524
3534
|
}
|
|
3525
3535
|
],
|
|
3526
|
-
"snapshotHash": "
|
|
3536
|
+
"snapshotHash": "9f8142a50f984550552470abdd4c051e69831d649394c5fce0e70da6d59fff8f"
|
|
3527
3537
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Result } from "./types.js";
|
|
2
|
+
export type AutoUpdateAction = "enable" | "disable" | "status";
|
|
3
|
+
export interface CommandResult {
|
|
4
|
+
code: number | null;
|
|
5
|
+
stdout: string;
|
|
6
|
+
stderr: string;
|
|
7
|
+
failure?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface CommandOptions {
|
|
10
|
+
cwd?: string;
|
|
11
|
+
graceful?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export type Command = (file: string, args: string[], env: NodeJS.ProcessEnv, timeout: number, options?: CommandOptions) => Promise<CommandResult>;
|
|
14
|
+
export type AutoUpdateHost = "codex" | "opencode";
|
|
15
|
+
export interface AutoUpdateDeps {
|
|
16
|
+
host?: AutoUpdateHost;
|
|
17
|
+
env?: NodeJS.ProcessEnv;
|
|
18
|
+
platform?: string;
|
|
19
|
+
command?: Command;
|
|
20
|
+
node?: string;
|
|
21
|
+
}
|
|
22
|
+
/** Kill the owned process group on timeout/cancellation, including Git children.
|
|
23
|
+
* Output is bounded and never persisted: child diagnostics can contain secrets. */
|
|
24
|
+
export declare const runCommand: Command;
|
|
25
|
+
export declare function autoUpdate(action: AutoUpdateAction, deps?: AutoUpdateDeps): Promise<Result>;
|
|
26
|
+
export declare function runScheduled(env?: NodeJS.ProcessEnv, execute?: Command, host?: AutoUpdateHost): Promise<Result>;
|
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import { accessSync, constants, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, openSync, closeSync, fsyncSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
const OWNER = "ccl-skills-auto-update-v1";
|
|
7
|
+
const SOURCES = new Set(["https://github.com/ccoalm/ccl-skills.git", "git@github.com:ccoalm/ccl-skills.git", "ssh://git@github.com/ccoalm/ccl-skills.git"]);
|
|
8
|
+
const REF = "ccl-skills@ccl-skills";
|
|
9
|
+
const LAUNCHCTL = "/bin/launchctl";
|
|
10
|
+
const MAX_LOCK_AGE_MS = 15 * 60 * 1000;
|
|
11
|
+
const digest = (text) => createHash("sha256").update(text).digest("hex");
|
|
12
|
+
const clean = (value) => value.length > 0 && !/[\x00-\x1f\x7f]/.test(value);
|
|
13
|
+
/** Kill the owned process group on timeout/cancellation, including Git children.
|
|
14
|
+
* Output is bounded and never persisted: child diagnostics can contain secrets. */
|
|
15
|
+
export const runCommand = (file, args, env, timeout, options = {}) => new Promise((done) => {
|
|
16
|
+
const child = spawn(file, args, { env, cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], detached: true });
|
|
17
|
+
const stdout = [], stderr = [];
|
|
18
|
+
let stdoutBytes = 0, stderrBytes = 0, failure;
|
|
19
|
+
let grace;
|
|
20
|
+
const signalGroup = (signal) => {
|
|
21
|
+
if (child.pid) {
|
|
22
|
+
try {
|
|
23
|
+
process.kill(-child.pid, signal);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
child.kill(signal);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
const kill = (reason) => {
|
|
31
|
+
if (failure)
|
|
32
|
+
return;
|
|
33
|
+
failure = reason;
|
|
34
|
+
if (options.graceful) {
|
|
35
|
+
signalGroup("SIGINT");
|
|
36
|
+
grace = setTimeout(() => { failure = `${reason}-forced-finality-unknown`; signalGroup("SIGKILL"); }, 10000);
|
|
37
|
+
}
|
|
38
|
+
else
|
|
39
|
+
signalGroup("SIGKILL");
|
|
40
|
+
};
|
|
41
|
+
const interrupt = () => kill("interrupted");
|
|
42
|
+
process.once("SIGINT", interrupt);
|
|
43
|
+
process.once("SIGTERM", interrupt);
|
|
44
|
+
const timer = setTimeout(() => kill("timeout"), timeout);
|
|
45
|
+
child.stdout.on("data", (chunk) => {
|
|
46
|
+
if (stdoutBytes + chunk.length > 4 * 1024 * 1024) {
|
|
47
|
+
kill("output-limit");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
stdoutBytes += chunk.length;
|
|
51
|
+
stdout.push(chunk);
|
|
52
|
+
});
|
|
53
|
+
child.stderr.on("data", (chunk) => {
|
|
54
|
+
if (stderrBytes + chunk.length > 64 * 1024) {
|
|
55
|
+
kill("output-limit");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
stderrBytes += chunk.length;
|
|
59
|
+
stderr.push(chunk);
|
|
60
|
+
});
|
|
61
|
+
child.on("error", () => { failure ||= "unavailable"; });
|
|
62
|
+
child.on("close", (code) => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
if (grace)
|
|
65
|
+
clearTimeout(grace);
|
|
66
|
+
process.removeListener("SIGINT", interrupt);
|
|
67
|
+
process.removeListener("SIGTERM", interrupt);
|
|
68
|
+
done({ code, stdout: Buffer.concat(stdout).toString(), stderr: Buffer.concat(stderr).toString(), ...(failure ? { failure } : {}) });
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
// Child cancellation belongs to runCommand; this scope also covers the gaps
|
|
72
|
+
// between children and keeps repeated signals from bypassing owned cleanup.
|
|
73
|
+
function interruptionScope(execute) {
|
|
74
|
+
let interrupted = false;
|
|
75
|
+
const interrupt = () => { interrupted = true; };
|
|
76
|
+
const check = () => {
|
|
77
|
+
if (interrupted)
|
|
78
|
+
throw new Error("Automatic-update operation interrupted; inspect host state before retrying");
|
|
79
|
+
};
|
|
80
|
+
process.on("SIGINT", interrupt);
|
|
81
|
+
process.on("SIGTERM", interrupt);
|
|
82
|
+
const command = async (...args) => {
|
|
83
|
+
check();
|
|
84
|
+
const result = await execute(...args);
|
|
85
|
+
// Promise continuations can outrun queued signal callbacks. Yield to the
|
|
86
|
+
// event loop before accepting a result or starting another command.
|
|
87
|
+
await new Promise(resolve => setImmediate(resolve));
|
|
88
|
+
// Preserve child failures, including forced termination's unknown finality.
|
|
89
|
+
if (!result.failure)
|
|
90
|
+
check();
|
|
91
|
+
return result;
|
|
92
|
+
};
|
|
93
|
+
return { command, dispose: () => {
|
|
94
|
+
process.removeListener("SIGINT", interrupt);
|
|
95
|
+
process.removeListener("SIGTERM", interrupt);
|
|
96
|
+
} };
|
|
97
|
+
}
|
|
98
|
+
function owned(path, directory = false) {
|
|
99
|
+
try {
|
|
100
|
+
const s = lstatSync(path);
|
|
101
|
+
if (s.isSymbolicLink() || (directory ? !s.isDirectory() : !s.isFile() || s.nlink !== 1) || s.uid !== process.getuid?.() || (s.mode & 0o022))
|
|
102
|
+
throw new Error(`Unsafe ownership or file type: ${path}`);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code === "ENOENT")
|
|
107
|
+
return false;
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
function directory(path) {
|
|
112
|
+
if (owned(path, true))
|
|
113
|
+
return;
|
|
114
|
+
directory(dirname(path));
|
|
115
|
+
mkdirSync(path, { mode: 0o700 });
|
|
116
|
+
}
|
|
117
|
+
function atomic(path, contents) {
|
|
118
|
+
owned(dirname(path), true);
|
|
119
|
+
owned(path);
|
|
120
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
121
|
+
const fd = openSync(temp, "wx", 0o600);
|
|
122
|
+
try {
|
|
123
|
+
try {
|
|
124
|
+
writeFileSync(fd, contents);
|
|
125
|
+
fsyncSync(fd);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
closeSync(fd);
|
|
129
|
+
}
|
|
130
|
+
owned(path);
|
|
131
|
+
renameSync(temp, path);
|
|
132
|
+
}
|
|
133
|
+
finally {
|
|
134
|
+
if (existsSync(temp))
|
|
135
|
+
unlinkSync(temp);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function context(env, host = "codex") {
|
|
139
|
+
if (host !== "codex" && host !== "opencode")
|
|
140
|
+
throw new Error("Unsupported auto-update host");
|
|
141
|
+
if (!env.HOME || !isAbsolute(env.HOME) || !clean(env.HOME))
|
|
142
|
+
throw new Error("HOME must be an existing absolute directory");
|
|
143
|
+
const home = realpathSync(env.HOME), profileInput = host === "codex" ? env.CODEX_HOME || join(home, ".codex") : join(home, ".config", "opencode");
|
|
144
|
+
if (!isAbsolute(profileInput) || !clean(profileInput))
|
|
145
|
+
throw new Error("Host profile must be an existing absolute directory");
|
|
146
|
+
if (!existsSync(profileInput))
|
|
147
|
+
throw new Error(`${host} profile is absent; install CCL for this host before enabling automatic updates`);
|
|
148
|
+
const profile = realpathSync(profileInput), uid = process.getuid?.();
|
|
149
|
+
if (!clean(home) || !clean(profile))
|
|
150
|
+
throw new Error("Canonical HOME and host profile paths must not contain control characters");
|
|
151
|
+
if (uid === undefined || uid === 0)
|
|
152
|
+
throw new Error("Automatic updates require a non-root user");
|
|
153
|
+
owned(home, true);
|
|
154
|
+
owned(profile, true);
|
|
155
|
+
owned(join(home, "Library"), true);
|
|
156
|
+
owned(join(home, "Library", "LaunchAgents"), true);
|
|
157
|
+
const root = join(profile, "ccl-skills-auto-update"), label = `com.ccoalm.ccl-skills.${host}.${digest(profile).slice(0, 16)}`;
|
|
158
|
+
return { host, home, profile, uid, root, label, plist: join(home, "Library", "LaunchAgents", `${label}.plist`), runner: join(root, "runner.mjs"), config: join(root, "state.json"), log: join(root, "last-run.json") };
|
|
159
|
+
}
|
|
160
|
+
function readState(c) {
|
|
161
|
+
if (!owned(c.root, true))
|
|
162
|
+
return null;
|
|
163
|
+
if (!owned(c.config))
|
|
164
|
+
throw new Error("Automatic-update directory has no ownership record; leave it intact and inspect it manually");
|
|
165
|
+
const s = JSON.parse(readFileSync(c.config, "utf8"));
|
|
166
|
+
const keys = ["host", ...(c.host === "opencode" ? ["npm"] : []), "owner", "schema", "home", "profile", "uid", "label", "node", "binary", "path", "runnerHash", "enabled"];
|
|
167
|
+
if (Object.keys(s).sort().join() !== keys.sort().join() || s.host !== c.host || s.owner !== OWNER || s.schema !== 1 || s.home !== c.home || s.profile !== c.profile || s.uid !== c.uid || s.label !== c.label || typeof s.enabled !== "boolean" || !/^[a-f0-9]{64}$/.test(s.runnerHash) || ![s.node, s.binary, s.path].every(v => typeof v === "string" && clean(v)) || !isAbsolute(s.node) || !isAbsolute(s.binary) || !s.path.split(":").every(isAbsolute) || (c.host === "opencode" && (typeof s.npm !== "string" || !clean(s.npm) || !isAbsolute(s.npm))))
|
|
168
|
+
throw new Error("Invalid automatic-update ownership record; no files were changed");
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
function executable(name, path) {
|
|
172
|
+
for (const dir of path.split(":")) {
|
|
173
|
+
const file = join(dir, name);
|
|
174
|
+
try {
|
|
175
|
+
accessSync(file, constants.X_OK);
|
|
176
|
+
if (lstatSync(realpathSync(file)).isFile())
|
|
177
|
+
return file;
|
|
178
|
+
}
|
|
179
|
+
catch { /* try the next PATH entry */ }
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`${name} executable unavailable; install it and rerun auto-update enable`);
|
|
182
|
+
}
|
|
183
|
+
const runtimeEnv = (s) => s.host === "codex" ? { HOME: s.home, CODEX_HOME: s.profile, PATH: s.path, GIT_TERMINAL_PROMPT: "0" } : { HOME: s.home, PATH: s.path, CCL_SKILLS_SKIP_SELF_UPDATE: "1", CCL_SKILLS_NO_UPDATE_NOTIFIER: "1" };
|
|
184
|
+
const xml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
185
|
+
function plist(c, s) {
|
|
186
|
+
const string = (v) => `<string>${xml(v)}</string>`;
|
|
187
|
+
return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0"><dict>\n<key>Label</key>${string(c.label)}\n<key>ProgramArguments</key><array>${[s.node, c.runner, "--run", s.host].map(string).join("")}</array>\n<key>EnvironmentVariables</key><dict>${Object.entries(runtimeEnv(s)).map(([k, v]) => `<key>${k}</key>${string(v)}`).join("")}</dict>\n<key>ExitTimeOut</key><integer>30</integer>\n<key>StartInterval</key><integer>86400</integer>\n<key>RunAtLoad</key><true/>\n<key>WorkingDirectory</key>${string(c.home)}\n<key>StandardOutPath</key><string>/dev/null</string>\n<key>StandardErrorPath</key><string>/dev/null</string>\n</dict></plist>\n`;
|
|
188
|
+
}
|
|
189
|
+
function verifyFiles(c, s) {
|
|
190
|
+
if (owned(c.runner) && digest(readFileSync(c.runner, "utf8")) !== s.runnerHash)
|
|
191
|
+
throw new Error("Automatic-update runner changed; refusing to replace it");
|
|
192
|
+
if (owned(c.plist) && readFileSync(c.plist, "utf8") !== plist(c, s))
|
|
193
|
+
throw new Error("Schedule is not the owned automatic-update job; refusing to change it");
|
|
194
|
+
}
|
|
195
|
+
async function registration(c, s, command) {
|
|
196
|
+
const r = await command(LAUNCHCTL, ["print", `gui/${c.uid}/${c.label}`], runtimeEnv(s), 10000);
|
|
197
|
+
if (r.failure)
|
|
198
|
+
throw new Error(`launchctl inspection failed (${r.failure}); registration is unknown`);
|
|
199
|
+
if (r.code !== 0) {
|
|
200
|
+
if (/Could not find service|service could not be found/i.test(r.stderr))
|
|
201
|
+
return { registered: false };
|
|
202
|
+
throw new Error("launchctl inspection failed; registration is unknown. Run from your logged-in macOS session");
|
|
203
|
+
}
|
|
204
|
+
const match = r.stdout.match(/^\s*path = (.+)$/m);
|
|
205
|
+
if (!match || match[1] !== c.plist)
|
|
206
|
+
throw new Error("A foreign launchd job uses this label; no scheduling changes made");
|
|
207
|
+
const exit = r.stdout.match(/^\s*last exit code = (\d+)/m);
|
|
208
|
+
return { registered: true, running: /^\s*state = running\s*$/m.test(r.stdout), ...(exit ? { lastExit: Number(exit[1]) } : {}) };
|
|
209
|
+
}
|
|
210
|
+
async function validateCodex(s, command) {
|
|
211
|
+
const query = async (args) => {
|
|
212
|
+
const r = await command(s.binary, ["--no-daemon", "plugin", ...args, "--json"], runtimeEnv(s), 10000);
|
|
213
|
+
if (r.failure || r.code !== 0)
|
|
214
|
+
throw new Error("Codex public JSON query failed; install a Codex CLI supporting --no-daemon and plugin JSON queries, then retry");
|
|
215
|
+
try {
|
|
216
|
+
return JSON.parse(r.stdout);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
throw new Error("Codex returned invalid JSON; plugin source is unknown");
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
const markets = await query(["marketplace", "list"]);
|
|
223
|
+
if (!Array.isArray(markets.marketplaces))
|
|
224
|
+
throw new Error("Codex marketplace state is unknown");
|
|
225
|
+
const matches = markets.marketplaces.filter((m) => m.name === "ccl-skills");
|
|
226
|
+
if (matches.length !== 1)
|
|
227
|
+
throw new Error("Requires an existing Git marketplace named ccl-skills. npm snapshots are not supported; use ccl-skills update --yes for npm-managed installs");
|
|
228
|
+
const source = matches[0].marketplaceSource;
|
|
229
|
+
if (source?.sourceType !== "git" || !SOURCES.has(source.source))
|
|
230
|
+
throw new Error("Requires the canonical ccl-skills Git marketplace. npm/local snapshots cannot auto-update; use ccl-skills update --yes or explicitly migrate to the Git install described in the README");
|
|
231
|
+
const plugins = await query(["list", "--marketplace", "ccl-skills"]);
|
|
232
|
+
if (!Array.isArray(plugins.installed))
|
|
233
|
+
throw new Error("Codex installed plugin state is unknown");
|
|
234
|
+
const installed = plugins.installed.filter((p) => p.pluginId === REF);
|
|
235
|
+
if (installed.length !== 1 || installed[0].installed !== true || installed[0].enabled !== true || installed[0].marketplaceName !== "ccl-skills" || installed[0].source?.source !== "git" || !SOURCES.has(installed[0].source?.url) || installed[0].marketplaceSource?.sourceType !== "git" || !SOURCES.has(installed[0].marketplaceSource?.source))
|
|
236
|
+
throw new Error("ccl-skills@ccl-skills must be installed and enabled from the canonical Git marketplace before enabling updates");
|
|
237
|
+
}
|
|
238
|
+
const PACKAGE = "@ccoalm/ccl-skills";
|
|
239
|
+
const REGISTRY = "https://registry.npmjs.org/";
|
|
240
|
+
const stableVersion = (value) => typeof value === "string" && /^\d+\.\d+\.\d+$/.test(value);
|
|
241
|
+
function compareVersions(left, right) {
|
|
242
|
+
const a = left.split(".").map(BigInt), b = right.split(".").map(BigInt);
|
|
243
|
+
for (let i = 0; i < 3; i++)
|
|
244
|
+
if (a[i] !== b[i])
|
|
245
|
+
return a[i] > b[i] ? 1 : -1;
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
async function packageCommand(s, cli, action, command, cwd = s.home) {
|
|
249
|
+
const args = [cli, action, "--host", "opencode", ...(action === "update" ? ["--yes"] : []), "--json"];
|
|
250
|
+
const result = await command(s.node, args, runtimeEnv(s), action === "update" ? 120000 : 30000, { cwd, graceful: action === "update" });
|
|
251
|
+
if (result.failure)
|
|
252
|
+
throw new Error(`OpenCode ${action} ${result.failure}; inspect installed assets before retrying`);
|
|
253
|
+
let value;
|
|
254
|
+
try {
|
|
255
|
+
value = JSON.parse(result.stdout);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
throw new Error(`OpenCode ${action} returned invalid JSON; finality is unknown`);
|
|
259
|
+
}
|
|
260
|
+
if (!value || typeof value !== "object" || ![0, 1, 2, 3, 4, 5, 130].includes(value.code) || result.code !== value.code || typeof value.message !== "string" || typeof value.status !== "string" || !/^[a-z-]+$/.test(value.status))
|
|
261
|
+
throw new Error(`OpenCode ${action} returned an invalid result; finality is unknown`);
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
function healthyOpenCode(result, expected) {
|
|
265
|
+
if (result.code !== 0 || result.status !== "healthy" || result.details?.sourceKind !== "bundled" || !stableVersion(result.details?.version))
|
|
266
|
+
throw new Error(`OpenCode requires a healthy npm-managed bundled CCL installation (${result.status}). Repair drift first; for source-copy installs, back up the existing CCL files and explicitly migrate with the npm installer before enabling updates`);
|
|
267
|
+
if (expected && result.details.version !== expected)
|
|
268
|
+
throw new Error("OpenCode post-update version does not match the fetched package; finality is unknown");
|
|
269
|
+
return result.details.version;
|
|
270
|
+
}
|
|
271
|
+
async function validateInstallation(s, command) {
|
|
272
|
+
if (s.host === "codex")
|
|
273
|
+
return validateCodex(s, command);
|
|
274
|
+
const cli = fileURLToPath(new URL("./cli.js", import.meta.url));
|
|
275
|
+
healthyOpenCode(await packageCommand(s, cli, "doctor", command));
|
|
276
|
+
}
|
|
277
|
+
function jsonFile(path) {
|
|
278
|
+
if (!owned(path) || lstatSync(path).size > 1024 * 1024)
|
|
279
|
+
throw new Error("Fetched package metadata is missing or oversized");
|
|
280
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
281
|
+
}
|
|
282
|
+
function verifyPrivateTree(root) {
|
|
283
|
+
owned(root, true);
|
|
284
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
285
|
+
const path = join(root, entry.name);
|
|
286
|
+
if (entry.isDirectory())
|
|
287
|
+
verifyPrivateTree(path);
|
|
288
|
+
else
|
|
289
|
+
owned(path);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
async function updateOpenCode(c, s, command) {
|
|
293
|
+
const pending = mkdtempSync(join(c.root, "refresh-"));
|
|
294
|
+
try {
|
|
295
|
+
const prefix = join(pending, "package"), cache = join(pending, "cache");
|
|
296
|
+
directory(prefix);
|
|
297
|
+
directory(cache);
|
|
298
|
+
const userConfig = join(pending, "user.npmrc"), globalConfig = join(pending, "global.npmrc");
|
|
299
|
+
atomic(userConfig, "");
|
|
300
|
+
atomic(globalConfig, "");
|
|
301
|
+
atomic(join(prefix, "package.json"), JSON.stringify({ private: true }));
|
|
302
|
+
const result = await command(s.npm, ["install", "--prefix", prefix, "--registry", REGISTRY, "--userconfig", userConfig, "--globalconfig", globalConfig, "--cache", cache, "--ignore-scripts", "--no-bin-links", "--no-audit", "--no-fund", "--package-lock=false", `${PACKAGE}@latest`], runtimeEnv(s), 180000, { cwd: prefix });
|
|
303
|
+
if (result.failure || result.code !== 0)
|
|
304
|
+
throw new Error(`OpenCode package refresh failed (${result.failure || `exit ${result.code}`}); installed assets were not changed`);
|
|
305
|
+
verifyPrivateTree(prefix);
|
|
306
|
+
const packageRoot = join(prefix, "node_modules", "@ccoalm", "ccl-skills"), pkg = jsonFile(join(packageRoot, "package.json")), release = jsonFile(join(packageRoot, "dist", "assets", "release.json"));
|
|
307
|
+
if (pkg.name !== PACKAGE || !stableVersion(pkg.version) || pkg.type !== "module" || pkg.bin?.["ccl-skills"] !== "dist/cli.js" || release.npmPackage !== PACKAGE || release.version !== pkg.version)
|
|
308
|
+
throw new Error("Fetched OpenCode package identity is invalid; installed assets were not changed");
|
|
309
|
+
const cli = join(packageRoot, "dist", "cli.js");
|
|
310
|
+
if (!owned(cli))
|
|
311
|
+
throw new Error("Fetched package CLI is missing");
|
|
312
|
+
const current = healthyOpenCode(await packageCommand(s, cli, "doctor", command, prefix));
|
|
313
|
+
if (compareVersions(pkg.version, current) < 0)
|
|
314
|
+
return `OpenCode retains newer installed CCL ${current}; registry latest is ${pkg.version}`;
|
|
315
|
+
if (!readState(c)?.enabled)
|
|
316
|
+
throw new Error("Schedule was disabled; OpenCode assets were not changed");
|
|
317
|
+
const updated = await packageCommand(s, cli, "update", command, prefix);
|
|
318
|
+
if (updated.code !== 0 || !["updated", "healthy"].includes(updated.status))
|
|
319
|
+
throw new Error(`OpenCode update returned ${updated.status}; inspect installed assets before retrying`);
|
|
320
|
+
healthyOpenCode(await packageCommand(s, cli, "doctor", command, prefix), pkg.version);
|
|
321
|
+
return `OpenCode CCL assets updated to ${pkg.version}; restart OpenCode to load changes`;
|
|
322
|
+
}
|
|
323
|
+
finally {
|
|
324
|
+
// Only this invocation's exclusive, freshly created prefix is removed.
|
|
325
|
+
owned(c.root, true);
|
|
326
|
+
owned(pending, true);
|
|
327
|
+
rmSync(pending, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function lastRun(c) {
|
|
331
|
+
if (!owned(c.log))
|
|
332
|
+
return null;
|
|
333
|
+
const text = readFileSync(c.log, "utf8");
|
|
334
|
+
if (text.length > 16384)
|
|
335
|
+
throw new Error("Automatic-update run record exceeds its size limit");
|
|
336
|
+
const record = JSON.parse(text);
|
|
337
|
+
if (!record || record.owner !== OWNER || !["running", "success", "failed"].includes(record.status) || typeof record.message !== "string" || typeof record.finishedAt !== "string")
|
|
338
|
+
throw new Error("Invalid automatic-update run record; inspect the log before retrying");
|
|
339
|
+
return record;
|
|
340
|
+
}
|
|
341
|
+
function lockState(path) {
|
|
342
|
+
if (!owned(path))
|
|
343
|
+
return "absent";
|
|
344
|
+
const pid = Number(readFileSync(path, "utf8"));
|
|
345
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
346
|
+
throw new Error(`Invalid automatic-update lock: ${path}; inspect it manually`);
|
|
347
|
+
// PID liveness cannot establish process identity after a crash and reuse.
|
|
348
|
+
// A bounded age prevents indefinite healthy overlap; never reclaim the lock.
|
|
349
|
+
if (Math.abs(Date.now() - lstatSync(path).mtimeMs) >= MAX_LOCK_AGE_MS)
|
|
350
|
+
return "uncertain";
|
|
351
|
+
try {
|
|
352
|
+
process.kill(pid, 0);
|
|
353
|
+
return "active";
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
if (error.code === "ESRCH")
|
|
357
|
+
return "stale";
|
|
358
|
+
throw error;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function lock(c, name = "run.lock") {
|
|
362
|
+
const path = join(c.root, name), state = lockState(path);
|
|
363
|
+
if (state === "stale")
|
|
364
|
+
throw new Error(`Stale automatic-update lock: ${path}. Confirm its PID has exited, remove only this lock file, then retry`);
|
|
365
|
+
if (state === "uncertain")
|
|
366
|
+
throw new Error(`Automatic-update lock is older than 15 minutes or its timestamp is uncertain: ${path}. Inspect the PID and job; remove only this lock after confirming no updater owns it`);
|
|
367
|
+
if (state === "active")
|
|
368
|
+
throw new Error("Automatic-update operation already running; retry after it finishes");
|
|
369
|
+
try {
|
|
370
|
+
const fd = openSync(path, "wx", 0o600);
|
|
371
|
+
try {
|
|
372
|
+
writeFileSync(fd, String(process.pid));
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
closeSync(fd);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (error.code === "EEXIST")
|
|
380
|
+
throw new Error("Automatic-update operation already running; retry after it finishes");
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
return () => { if (owned(path) && readFileSync(path, "utf8") === String(process.pid))
|
|
384
|
+
unlinkSync(path); };
|
|
385
|
+
}
|
|
386
|
+
export async function autoUpdate(action, deps = {}) {
|
|
387
|
+
if ((deps.platform || process.platform) !== "darwin")
|
|
388
|
+
return { code: 4, status: "unsupported-platform", message: "Automatic updates currently require macOS launchd. Use the documented manual update commands on other platforms." };
|
|
389
|
+
const env = deps.env || process.env;
|
|
390
|
+
const scope = interruptionScope(deps.command || runCommand), command = scope.command;
|
|
391
|
+
let release;
|
|
392
|
+
try {
|
|
393
|
+
const c = context(env, deps.host);
|
|
394
|
+
const name = c.host === "codex" ? "Codex" : "OpenCode";
|
|
395
|
+
let s = readState(c);
|
|
396
|
+
if (!s && action !== "enable")
|
|
397
|
+
return { code: 0, status: "disabled", message: `${name} automatic updates are not configured for this profile.` };
|
|
398
|
+
if (s)
|
|
399
|
+
verifyFiles(c, s);
|
|
400
|
+
if (action === "status") {
|
|
401
|
+
const registrationState = await registration(c, s, command), registered = registrationState.registered, last = lastRun(c);
|
|
402
|
+
const enabled = s.enabled && registered && owned(c.runner) && owned(c.plist);
|
|
403
|
+
const runLock = lockState(join(c.root, "run.lock"));
|
|
404
|
+
const manageLock = lockState(join(c.root, "manage.lock"));
|
|
405
|
+
const staleLock = runLock === "stale" || manageLock === "stale";
|
|
406
|
+
const uncertainLock = runLock === "uncertain" || manageLock === "uncertain";
|
|
407
|
+
const running = registrationState.running || runLock === "active";
|
|
408
|
+
const failed = staleLock || uncertainLock || last?.status === "failed" || !!registrationState.lastExit || (last?.status === "running" && !running);
|
|
409
|
+
const details = { registered, enabled, running, staleLock, uncertainLock, lastExit: registrationState.lastExit, profile: c.profile, label: c.label, log: c.log, lastRun: last };
|
|
410
|
+
if (!enabled) {
|
|
411
|
+
if (registered || s.enabled)
|
|
412
|
+
return { code: 5, status: "scheduler-error", message: "Schedule registration is incomplete; rerun auto-update enable or disable to recover.", details };
|
|
413
|
+
return { code: 0, status: "disabled", message: `${name} automatic updates are disabled.`, details };
|
|
414
|
+
}
|
|
415
|
+
let summary = last?.status || "not yet recorded";
|
|
416
|
+
if (running)
|
|
417
|
+
summary = "running";
|
|
418
|
+
if (failed)
|
|
419
|
+
summary = "failed or interrupted; inspect the run log";
|
|
420
|
+
if (staleLock)
|
|
421
|
+
summary = "stale lock; confirm its PID has exited, remove only that lock file, then retry";
|
|
422
|
+
if (uncertainLock)
|
|
423
|
+
summary = "lock older than 15 minutes or timestamp uncertain; inspect its PID and job before recovery, never remove a running updater's lock";
|
|
424
|
+
return { code: failed ? 5 : 0, status: failed ? "update-failed" : "enabled", message: `${name} automatic updates are scheduled every 24 hours. Last run: ${summary}. Log: ${c.log}`, details };
|
|
425
|
+
}
|
|
426
|
+
if (!s) {
|
|
427
|
+
if (owned(c.plist))
|
|
428
|
+
throw new Error("A schedule already exists without our ownership record; refusing to replace it");
|
|
429
|
+
const path = (env.PATH || "/usr/bin:/bin").split(":").filter(p => isAbsolute(p) && clean(p)).join(":");
|
|
430
|
+
const source = readFileSync(fileURLToPath(import.meta.url), "utf8");
|
|
431
|
+
s = { host: c.host, ...(c.host === "opencode" ? { npm: executable("npm", path) } : {}), owner: OWNER, schema: 1, home: c.home, profile: c.profile, uid: c.uid, label: c.label, node: deps.node || executable("node", path), binary: executable(c.host, path), path, runnerHash: digest(source), enabled: false };
|
|
432
|
+
await validateInstallation(s, command);
|
|
433
|
+
if ((await registration(c, s, command)).registered)
|
|
434
|
+
throw new Error("A schedule is registered without our ownership record; refusing to change it");
|
|
435
|
+
mkdirSync(c.root, { mode: 0o700 });
|
|
436
|
+
atomic(c.config, JSON.stringify(s));
|
|
437
|
+
}
|
|
438
|
+
release = lock(c, "manage.lock");
|
|
439
|
+
s = readState(c);
|
|
440
|
+
verifyFiles(c, s);
|
|
441
|
+
const registered = (await registration(c, s, command)).registered;
|
|
442
|
+
if (action === "disable") {
|
|
443
|
+
s.enabled = false;
|
|
444
|
+
atomic(c.config, JSON.stringify(s));
|
|
445
|
+
if (registered) {
|
|
446
|
+
const result = await command(LAUNCHCTL, ["bootout", `gui/${c.uid}`, c.plist], runtimeEnv(s), 10000);
|
|
447
|
+
if (result.failure || result.code !== 0)
|
|
448
|
+
throw new Error("Schedule was fenced but unload failed; rerun auto-update disable from your logged-in session");
|
|
449
|
+
}
|
|
450
|
+
if ((await registration(c, s, command)).registered)
|
|
451
|
+
throw new Error("Schedule remains registered; disabling is incomplete");
|
|
452
|
+
if (owned(c.plist))
|
|
453
|
+
unlinkSync(c.plist);
|
|
454
|
+
return { code: 0, status: "disabled", message: `${name} automatic updates are disabled. Runner and log retained at ${c.root}.` };
|
|
455
|
+
}
|
|
456
|
+
await validateInstallation(s, command);
|
|
457
|
+
if (registered && s.enabled && owned(c.runner) && owned(c.plist))
|
|
458
|
+
return { code: 0, status: "enabled", message: `${name} automatic updates are already scheduled every 24 hours. Log: ${c.log}`, details: { label: c.label, log: c.log } };
|
|
459
|
+
if (registered)
|
|
460
|
+
throw new Error("An incomplete schedule remains registered; run auto-update disable, then enable");
|
|
461
|
+
const source = readFileSync(fileURLToPath(import.meta.url), "utf8");
|
|
462
|
+
if (!owned(c.runner)) {
|
|
463
|
+
if (s.runnerHash !== digest(source))
|
|
464
|
+
throw new Error("Saved runner is missing and belongs to a different package version; restore the saved runner before retrying");
|
|
465
|
+
atomic(c.runner, source);
|
|
466
|
+
}
|
|
467
|
+
directory(dirname(c.plist));
|
|
468
|
+
atomic(c.plist, plist(c, s));
|
|
469
|
+
s.enabled = true;
|
|
470
|
+
atomic(c.config, JSON.stringify(s));
|
|
471
|
+
const result = await command(LAUNCHCTL, ["bootstrap", `gui/${c.uid}`, c.plist], runtimeEnv(s), 10000);
|
|
472
|
+
if (result.failure || result.code !== 0 || !(await registration(c, s, command)).registered)
|
|
473
|
+
throw new Error("Schedule registration failed; files retained for recovery. Rerun auto-update enable from your logged-in macOS session");
|
|
474
|
+
return { code: 0, status: "enabled", message: `${name} automatic updates are scheduled every 24 hours and at login. Log: ${c.log}`, details: { label: c.label, profile: c.profile, log: c.log } };
|
|
475
|
+
}
|
|
476
|
+
catch (error) {
|
|
477
|
+
return { code: 5, status: "auto-update-error", message: error instanceof Error ? error.message : "Automatic-update operation failed" };
|
|
478
|
+
}
|
|
479
|
+
finally {
|
|
480
|
+
try {
|
|
481
|
+
release?.();
|
|
482
|
+
}
|
|
483
|
+
finally {
|
|
484
|
+
scope.dispose();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
export async function runScheduled(env = process.env, execute = runCommand, host = "codex") {
|
|
489
|
+
const scope = interruptionScope(execute), command = scope.command;
|
|
490
|
+
let release, c;
|
|
491
|
+
try {
|
|
492
|
+
c = context(env, host);
|
|
493
|
+
let s = readState(c);
|
|
494
|
+
if (!s || !s.enabled)
|
|
495
|
+
return { code: 0, status: "disabled", message: "Schedule disabled; no update performed" };
|
|
496
|
+
verifyFiles(c, s);
|
|
497
|
+
release = lock(c);
|
|
498
|
+
s = readState(c);
|
|
499
|
+
if (!s?.enabled)
|
|
500
|
+
return { code: 0, status: "disabled", message: "Schedule disabled; no update performed" };
|
|
501
|
+
const startedAt = new Date().toISOString();
|
|
502
|
+
const record = (status, message) => atomic(c.log, JSON.stringify({ owner: OWNER, status, startedAt, finishedAt: new Date().toISOString(), message }));
|
|
503
|
+
record("running", `Checking the installed ${s.host} CCL assets`);
|
|
504
|
+
try {
|
|
505
|
+
if (s.host === "opencode") {
|
|
506
|
+
const message = await updateOpenCode(c, s, command);
|
|
507
|
+
record("success", message);
|
|
508
|
+
return { code: 0, status: "updated", message };
|
|
509
|
+
}
|
|
510
|
+
await validateCodex(s, command);
|
|
511
|
+
for (const args of [["marketplace", "upgrade", "ccl-skills"], ["add", REF]]) {
|
|
512
|
+
if (!readState(c)?.enabled)
|
|
513
|
+
throw new Error("Schedule was disabled; no further update commands performed");
|
|
514
|
+
const result = await command(s.binary, ["--no-daemon", "plugin", ...args], runtimeEnv(s), 120000);
|
|
515
|
+
if (result.failure || result.code !== 0)
|
|
516
|
+
throw new Error(`${args[0]} failed (${result.failure || `exit ${result.code}`}); inspect Codex and retry on the next scheduled run`);
|
|
517
|
+
await validateCodex(s, command);
|
|
518
|
+
}
|
|
519
|
+
record("success", "Git marketplace refreshed and ccl-skills plugin installed; restart Codex to load changes");
|
|
520
|
+
return { code: 0, status: "updated", message: "ccl-skills Git plugin updated" };
|
|
521
|
+
}
|
|
522
|
+
catch (error) {
|
|
523
|
+
const message = error instanceof Error ? error.message : "Scheduled update failed";
|
|
524
|
+
record("failed", message);
|
|
525
|
+
return { code: 5, status: "update-failed", message };
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch (error) {
|
|
529
|
+
const message = error instanceof Error ? error.message : "Scheduled update failed";
|
|
530
|
+
if (message.includes("already running"))
|
|
531
|
+
return { code: 0, status: "skipped", message };
|
|
532
|
+
if (release && c) {
|
|
533
|
+
try {
|
|
534
|
+
atomic(c.log, JSON.stringify({ owner: OWNER, status: "failed", finishedAt: new Date().toISOString(), message }));
|
|
535
|
+
}
|
|
536
|
+
catch { /* launchd last exit remains the independent failure signal */ }
|
|
537
|
+
}
|
|
538
|
+
return { code: 5, status: "auto-update-error", message };
|
|
539
|
+
}
|
|
540
|
+
finally {
|
|
541
|
+
try {
|
|
542
|
+
release?.();
|
|
543
|
+
}
|
|
544
|
+
finally {
|
|
545
|
+
scope.dispose();
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
if (process.argv[2] === "--run" && process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
550
|
+
runScheduled(process.env, runCommand, process.argv[3] || "codex").then(result => { process.exitCode = result.code; });
|
|
551
|
+
}
|
package/dist/cli.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Worker } from "node:worker_threads";
|
|
3
3
|
import type { Options, Result } from "./types.js";
|
|
4
|
+
import { type AutoUpdateAction } from "./auto-update.js";
|
|
4
5
|
type Parsed = {
|
|
5
6
|
direct?: {
|
|
6
7
|
code: number;
|
|
@@ -10,6 +11,7 @@ type Parsed = {
|
|
|
10
11
|
command?: string;
|
|
11
12
|
options?: Options;
|
|
12
13
|
json?: boolean;
|
|
14
|
+
autoUpdateAction?: AutoUpdateAction;
|
|
13
15
|
};
|
|
14
16
|
export declare function parseArgs(args: string[]): Parsed;
|
|
15
17
|
export type SupervisorDeps = {
|
package/dist/cli.js
CHANGED
|
@@ -5,8 +5,9 @@ import { Worker } from "node:worker_threads";
|
|
|
5
5
|
import { spawn, spawnSync } from "node:child_process";
|
|
6
6
|
import { paths } from "./paths.js";
|
|
7
7
|
import { cachePath, emitNotice } from "./update-notice.js";
|
|
8
|
+
import { autoUpdate } from "./auto-update.js";
|
|
8
9
|
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8"));
|
|
9
|
-
const HELP = `ccl-skills — npm snapshot installer for Claude Code, Codex, and OpenCode\n\nUsage:\n ccl-skills install [--host claude|codex|opencode] [--json]\n ccl-skills update [--host claude|codex|opencode] [--yes] [--allow-downgrade] [--json]\n ccl-skills doctor [--host claude|codex|opencode] [--json]\n ccl-skills uninstall [--host claude|codex|opencode] [--yes] [--json]\n
|
|
10
|
+
const HELP = `ccl-skills — npm snapshot installer for Claude Code, Codex, and OpenCode\n\nUsage:\n ccl-skills install [--host claude|codex|opencode] [--json]\n ccl-skills update [--host claude|codex|opencode] [--yes] [--allow-downgrade] [--json]\n ccl-skills doctor [--host claude|codex|opencode] [--json]\n ccl-skills uninstall [--host claude|codex|opencode] [--yes] [--json]\n ccl-skills auto-update enable|disable|status [--host codex|opencode] [--json]\n\nAuto-update schedules an existing Codex Git or npm-managed OpenCode CCL installation on macOS; default host: codex.\nFor install/update/doctor/uninstall, omitting --host selects every installed or package-owned host.\nUpdate and uninstall are dry-run unless --yes is supplied.\nBy default update --yes upgrades the global npm package to @latest before refreshing assets.\nSet CCL_SKILLS_SKIP_SELF_UPDATE=1 for an assets-only refresh; --allow-downgrade always uses the invoked package without installing @latest first.\n\nAn interactive run prints an update notice on stderr at most once a day.\nSet CCL_SKILLS_NO_UPDATE_NOTIFIER=1 (or NO_UPDATE_NOTIFIER) to silence it; it is\nalready silent under --json, in CI, and when output is not a terminal.`;
|
|
10
11
|
export function parseArgs(args) {
|
|
11
12
|
if (!args.length ||
|
|
12
13
|
args.includes("--help") ||
|
|
@@ -16,6 +17,27 @@ export function parseArgs(args) {
|
|
|
16
17
|
if (args.includes("--version"))
|
|
17
18
|
return { direct: { code: 0, stream: "stdout", text: pkg.version } };
|
|
18
19
|
const command = args[0], flags = args.slice(1);
|
|
20
|
+
if (command === "auto-update") {
|
|
21
|
+
const action = flags[0];
|
|
22
|
+
let host = "codex", json = false, seenHost = false, invalid = false;
|
|
23
|
+
for (let index = 1; index < flags.length; index++) {
|
|
24
|
+
if (flags[index] === "--json" && !json)
|
|
25
|
+
json = true;
|
|
26
|
+
else if (flags[index] === "--host" && !seenHost) {
|
|
27
|
+
seenHost = true;
|
|
28
|
+
const value = flags[++index];
|
|
29
|
+
if (value !== "codex" && value !== "opencode")
|
|
30
|
+
invalid = true;
|
|
31
|
+
else
|
|
32
|
+
host = value;
|
|
33
|
+
}
|
|
34
|
+
else
|
|
35
|
+
invalid = true;
|
|
36
|
+
}
|
|
37
|
+
if (!["enable", "disable", "status"].includes(action) || invalid)
|
|
38
|
+
return { direct: { code: 2, stream: "stderr", text: "Usage: ccl-skills auto-update enable|disable|status [--host codex|opencode] [--json] (macOS; default: codex)." } };
|
|
39
|
+
return { command, autoUpdateAction: action, options: { host }, json };
|
|
40
|
+
}
|
|
19
41
|
let host;
|
|
20
42
|
const normalizedFlags = [];
|
|
21
43
|
for (let index = 0; index < flags.length; index++) {
|
|
@@ -192,7 +214,10 @@ export async function main(args) {
|
|
|
192
214
|
}
|
|
193
215
|
const shouldSelfUpdate = parsed.command === "update" && parsed.options.yes && !parsed.options.allowDowngrade && process.env.CCL_SKILLS_SKIP_SELF_UPDATE !== "1";
|
|
194
216
|
let result;
|
|
195
|
-
if (
|
|
217
|
+
if (parsed.autoUpdateAction) {
|
|
218
|
+
result = await autoUpdate(parsed.autoUpdateAction, { host: parsed.options.host });
|
|
219
|
+
}
|
|
220
|
+
else if (shouldSelfUpdate) {
|
|
196
221
|
const preflight = await supervise("update", { ...parsed.options, yes: false });
|
|
197
222
|
result = preflight.code === 0 ? selfUpdate(parsed.options) : preflight;
|
|
198
223
|
}
|
|
@@ -204,7 +229,8 @@ export async function main(args) {
|
|
|
204
229
|
write(process.stdout, parsed.json
|
|
205
230
|
? JSON.stringify(result)
|
|
206
231
|
: `${result.status}: ${result.message}${result.plan ? `\n${result.plan.map((x) => `- ${x}`).join("\n")}` : ""}`);
|
|
207
|
-
|
|
232
|
+
if (!parsed.autoUpdateAction)
|
|
233
|
+
notifyUpdate(!!parsed.json);
|
|
208
234
|
return result.code;
|
|
209
235
|
}
|
|
210
236
|
export function isDirectEntrypoint(importMetaUrl, argv1) {
|
package/dist/opencode-adapter.js
CHANGED
|
@@ -330,7 +330,7 @@ function doctor(context) {
|
|
|
330
330
|
}
|
|
331
331
|
if (drift.length || reason)
|
|
332
332
|
return { code: 3, status: "shared-drift", message: "OpenCode shared files changed after npm install; they will never be auto-deleted", details: { drift, ...(reason ? { reason } : {}) } };
|
|
333
|
-
return { code: 0, status: "healthy", message: `OpenCode uses @ccoalm/ccl-skills ${manifest.version}
|
|
333
|
+
return { code: 0, status: "healthy", message: `OpenCode uses @ccoalm/ccl-skills ${manifest.version}`, details: { version: manifest.version, sourceKind: manifest.sourceKind } };
|
|
334
334
|
}
|
|
335
335
|
function installOrUpdate(commandName, options, context) {
|
|
336
336
|
const p = paths(context);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ccoalm/ccl-skills",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.8",
|
|
4
4
|
"description": "Reusable workflows that help coding agents plan, build, test, review, and release software — for Claude Code, Codex, and OpenCode",
|
|
5
5
|
"keywords": ["skills", "agent-skills", "claude", "claude-code", "codex", "opencode", "agent", "ai", "ai-agents", "cli", "anthropic", "developer-tools"],
|
|
6
6
|
"type": "module",
|