@lifeaitools/clauth 1.30.23 → 1.30.25

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.
Files changed (49) hide show
  1. package/.clauth-skill/SKILL.md +306 -275
  2. package/.clauth-skill/references/operator-guide.md +175 -148
  3. package/README.md +363 -315
  4. package/cli/api.classify.test.js +75 -75
  5. package/cli/assets/codevelop/launcher-active.cmd.template +20 -20
  6. package/cli/assets/codevelop/launcher-static.cmd.template +7 -7
  7. package/cli/assets/codevelop/windows-terminal.profiles.json +48 -48
  8. package/cli/assets/watchdog.ps1 +42 -42
  9. package/cli/commands/agent-cron.js +396 -396
  10. package/cli/commands/agent-pool.js +1962 -1962
  11. package/cli/commands/codevelop.js +1190 -1190
  12. package/cli/commands/doctor.js +302 -302
  13. package/cli/commands/install.js +10 -10
  14. package/cli/commands/invite.js +175 -175
  15. package/cli/commands/join.js +179 -179
  16. package/cli/commands/npm.js +182 -182
  17. package/cli/commands/ops-install.js +211 -0
  18. package/cli/commands/ops.js +69 -0
  19. package/cli/commands/scrub.js +327 -327
  20. package/cli/commands/scrub.test.js +115 -115
  21. package/cli/commands/serve.js +381 -98
  22. package/cli/commands/watchdog.js +209 -209
  23. package/cli/conf-path.js +21 -21
  24. package/cli/enrollment-script.js +82 -82
  25. package/cli/fingerprint.js +143 -143
  26. package/cli/index.js +1073 -1053
  27. package/cli/lib/fs-git.js +282 -282
  28. package/cli/ops/coolify-adapter.js +80 -0
  29. package/cli/ops/deployment-adapter.js +63 -0
  30. package/cli/ops/job-store.js +116 -0
  31. package/cli/ops/operation-policy.js +51 -0
  32. package/cli/ops/pm2-adapter.js +128 -0
  33. package/cli/ops/serialized-executor.js +9 -0
  34. package/cli/recovery.js +101 -101
  35. package/cli/studio-debug.js +1095 -1095
  36. package/cli/supervisor-registry.js +594 -589
  37. package/cli/supervisor-registry.test.js +397 -397
  38. package/cli/supervisor-ui.test.js +5 -83
  39. package/cli/watchdog-registry.js +237 -209
  40. package/cli/watchdog-registry.test.js +112 -89
  41. package/install.ps1 +21 -21
  42. package/package.json +4 -2
  43. package/scripts/bin/bootstrap-linux +0 -0
  44. package/scripts/bin/bootstrap-macos +0 -0
  45. package/scripts/bin/bootstrap-win.exe +0 -0
  46. package/supabase/migrations/001_clauth_schema.sql +12 -12
  47. package/supabase/migrations/003_clauth_config.sql +13 -13
  48. package/supabase/migrations/003_machine_enrollments.sql +39 -39
  49. package/cli/served-script-syntax.test.mjs +0 -54
@@ -1,182 +1,182 @@
1
- import fs from "fs";
2
- import os from "os";
3
- import path from "path";
4
- import { spawnSync } from "child_process";
5
-
6
- const CLAUTH_NPM_URL = "http://127.0.0.1:52437/v/npm";
7
-
8
- function run(cmd, args, opts = {}) {
9
- const useCmd = process.platform === "win32" && ["npm", "gh"].includes(cmd);
10
- const executable = useCmd ? "cmd.exe" : cmd;
11
- const finalArgs = useCmd ? ["/d", "/s", "/c", cmd, ...args] : args;
12
- const result = spawnSync(executable, finalArgs, {
13
- encoding: "utf8",
14
- ...opts,
15
- env: { ...process.env, ...(opts.env || {}) }
16
- });
17
- if (result.error) throw result.error;
18
- return result;
19
- }
20
-
21
- function redactNpmTokenList(text) {
22
- return String(text || "").replace(/npm_[A-Za-z0-9]+/g, token => `${token.slice(0, 9)}...${token.slice(-4)}`);
23
- }
24
-
25
- async function fetchNpmToken() {
26
- const response = await fetch(CLAUTH_NPM_URL);
27
- if (!response.ok) throw new Error(`failed to fetch npm token from clauth daemon: HTTP ${response.status}`);
28
- const token = (await response.text()).trim();
29
- if (!token) throw new Error("clauth npm token is empty");
30
- if (!token.startsWith("npm_")) throw new Error("clauth npm token does not look like an npm token");
31
- return token;
32
- }
33
-
34
- function withNpmAuth(token, fn) {
35
- const npmrc = path.join(os.tmpdir(), `clauth-npm-${process.pid}-${Date.now()}.npmrc`);
36
- try {
37
- fs.writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}`, { encoding: "utf8", mode: 0o600 });
38
- return fn(npmrc);
39
- } finally {
40
- try { fs.rmSync(npmrc, { force: true }); } catch {}
41
- }
42
- }
43
-
44
- function runNpmWithToken(token, args) {
45
- return withNpmAuth(token, npmrc => run("npm", ["--userconfig", npmrc, ...args]));
46
- }
47
-
48
- function printResult(result, { redact = true } = {}) {
49
- const stdout = redact ? redactNpmTokenList(result.stdout) : result.stdout;
50
- const stderr = redact ? redactNpmTokenList(result.stderr) : result.stderr;
51
- if (stdout) process.stdout.write(stdout);
52
- if (stderr) process.stderr.write(stderr);
53
- }
54
-
55
- function usage() {
56
- console.log(`clauth npm <action>
57
-
58
- Actions:
59
- whoami Verify the clauth npm token identity
60
- tokens List npm token metadata with token strings redacted
61
- set-local Write the clauth npm token to the user npm config
62
- sync-github-secret <repo> Set repo secret NPM_TOKEN from clauth, e.g. LIFEAI/rdc-skills
63
- rerun <run-id> --repo <repo> Rerun a failed GitHub Actions workflow
64
- `);
65
- }
66
-
67
- export async function runNpm(action = "help", opts = {}) {
68
- if (action === "help") {
69
- usage();
70
- return;
71
- }
72
-
73
- const token = await fetchNpmToken();
74
-
75
- if (action === "whoami") {
76
- const result = runNpmWithToken(token, ["whoami", "--registry=https://registry.npmjs.org/"]);
77
- printResult(result);
78
- if (result.status !== 0) process.exitCode = result.status;
79
- return;
80
- }
81
-
82
- if (action === "tokens") {
83
- const result = runNpmWithToken(token, ["token", "list", "--json", "--registry=https://registry.npmjs.org/"]);
84
- printResult(result);
85
- if (result.status !== 0) process.exitCode = result.status;
86
- return;
87
- }
88
-
89
- if (action === "set-local") {
90
- const result = run("npm", ["config", "set", "//registry.npmjs.org/:_authToken", token, "--location=user"]);
91
- if (result.status !== 0) {
92
- printResult(result);
93
- process.exitCode = result.status;
94
- return;
95
- }
96
- console.log("local npm auth updated from clauth service 'npm'");
97
- return;
98
- }
99
-
100
- if (action === "sync-github-secret") {
101
- const repo = opts.repo || opts.args?.[0];
102
- if (!repo) throw new Error("repo is required, e.g. clauth npm sync-github-secret LIFEAI/rdc-skills");
103
- const result = run("gh", ["secret", "set", "NPM_TOKEN", "--repo", repo], { input: token });
104
- printResult(result);
105
- if (result.status !== 0) process.exitCode = result.status;
106
- else console.log(`GitHub secret NPM_TOKEN updated for ${repo}`);
107
- return;
108
- }
109
-
110
- if (action === "rerun") {
111
- const runId = opts.args?.[0];
112
- const repo = opts.repo;
113
- if (!runId || !repo) throw new Error("usage: clauth npm rerun <run-id> --repo LIFEAI/rdc-skills");
114
- const result = run("gh", ["run", "rerun", runId, "--repo", repo, "--failed"]);
115
- printResult(result);
116
- if (result.status !== 0) process.exitCode = result.status;
117
- else console.log(`rerun requested for ${repo} run ${runId}`);
118
- return;
119
- }
120
-
121
- usage();
122
- throw new Error(`unknown clauth npm action: ${action}`);
123
- }
124
-
125
- // ── Guarded publish ──────────────────────────────────────────────────────────
126
- // Generic, package-agnostic publish that REFUSES to publish unless the package
127
- // is already committed and pushed to GitHub — so the npm tarball can never be
128
- // built from uncommitted "dev" code that isn't on the repo. Works for standalone
129
- // repos and monorepo subpackages (clean-check is scoped to the package's dir).
130
- export async function runPublish(target, opts = {}) {
131
- const pkgDir = path.resolve(target || process.cwd());
132
- const pkgJsonPath = path.join(pkgDir, "package.json");
133
- if (!fs.existsSync(pkgJsonPath)) throw new Error(`No package.json found at ${pkgDir}`);
134
- const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
135
- if (!pkg.name || !pkg.version) throw new Error(`package.json at ${pkgDir} is missing name or version`);
136
- if (pkg.private === true) throw new Error(`${pkg.name} is marked "private": true — refusing to publish`);
137
- const access = opts.access || pkg.publishConfig?.access || (pkg.name.startsWith("@") ? "public" : undefined);
138
-
139
- const gitRoot = run("git", ["rev-parse", "--show-toplevel"], { cwd: pkgDir }).stdout?.trim();
140
- if (!gitRoot) throw new Error(`${pkgDir} is not inside a git repository`);
141
- const rel = path.relative(gitRoot, pkgDir) || ".";
142
-
143
- // Guard 1 — nothing uncommitted in the package (else we'd pack dev code).
144
- const dirty = run("git", ["status", "--porcelain", "--", rel], { cwd: gitRoot }).stdout?.trim();
145
- if (dirty && !opts.allowDirty) {
146
- throw new Error(`Refusing to publish ${pkg.name}@${pkg.version}: uncommitted changes in ${rel} would be packed but are NOT on GitHub:\n${dirty}\n\nCommit + push first (or pass --allow-dirty to override).`);
147
- }
148
-
149
- // Guard 2 — HEAD is on a remote (actually pushed to GitHub).
150
- const head = run("git", ["rev-parse", "HEAD"], { cwd: gitRoot }).stdout?.trim();
151
- const onRemote = run("git", ["branch", "-r", "--contains", head], { cwd: gitRoot }).stdout?.trim();
152
- if (!onRemote && !opts.allowUnpushed) {
153
- throw new Error(`Refusing to publish ${pkg.name}@${pkg.version}: HEAD ${head.slice(0, 9)} is not on any remote branch — push to GitHub first (or pass --allow-unpushed to override).`);
154
- }
155
-
156
- // Traceability — warn (don't block) if there's no v<version> tag at HEAD.
157
- const tags = (run("git", ["tag", "--points-at", "HEAD"], { cwd: gitRoot }).stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
158
- if (!tags.includes(`v${pkg.version}`)) {
159
- console.log(`⚠ No git tag v${pkg.version} at HEAD (traceability only). Tags here: ${tags.join(", ") || "none"}`);
160
- }
161
-
162
- console.log(`${opts.dryRun ? "[dry-run] " : ""}Publishing ${pkg.name}@${pkg.version} from ${rel} @ ${head.slice(0, 9)} (pushed${access ? `, access=${access}` : ""})`);
163
-
164
- const token = await fetchNpmToken();
165
- const args = ["publish"];
166
- if (access) args.push("--access", access);
167
- if (opts.dryRun) args.push("--dry-run");
168
- const result = withNpmAuth(token, (npmrc) => run("npm", ["--userconfig", npmrc, ...args], { cwd: pkgDir }));
169
- printResult(result, { redact: true });
170
- if (result.status !== 0) { process.exitCode = result.status; throw new Error(`npm publish failed for ${pkg.name}`); }
171
- if (opts.dryRun) { console.log("Dry run complete — nothing published."); return; }
172
-
173
- // Verify on the registry directly (bypasses npm's local cache lag).
174
- try {
175
- const reg = await fetch(`https://registry.npmjs.org/${pkg.name}`);
176
- const meta = await reg.json();
177
- if (meta.versions?.[pkg.version]) console.log(`✓ Verified ${pkg.name}@${pkg.version} on the registry (latest: ${meta["dist-tags"]?.latest}).`);
178
- else console.log(`⚠ ${pkg.name}@${pkg.version} not visible on the registry yet (propagation lag) — re-check shortly.`);
179
- } catch (e) {
180
- console.log(`(could not verify on registry: ${e.message})`);
181
- }
182
- }
1
+ import fs from "fs";
2
+ import os from "os";
3
+ import path from "path";
4
+ import { spawnSync } from "child_process";
5
+
6
+ const CLAUTH_NPM_URL = "http://127.0.0.1:52437/v/npm";
7
+
8
+ function run(cmd, args, opts = {}) {
9
+ const useCmd = process.platform === "win32" && ["npm", "gh"].includes(cmd);
10
+ const executable = useCmd ? "cmd.exe" : cmd;
11
+ const finalArgs = useCmd ? ["/d", "/s", "/c", cmd, ...args] : args;
12
+ const result = spawnSync(executable, finalArgs, {
13
+ encoding: "utf8",
14
+ ...opts,
15
+ env: { ...process.env, ...(opts.env || {}) }
16
+ });
17
+ if (result.error) throw result.error;
18
+ return result;
19
+ }
20
+
21
+ function redactNpmTokenList(text) {
22
+ return String(text || "").replace(/npm_[A-Za-z0-9]+/g, token => `${token.slice(0, 9)}...${token.slice(-4)}`);
23
+ }
24
+
25
+ async function fetchNpmToken() {
26
+ const response = await fetch(CLAUTH_NPM_URL);
27
+ if (!response.ok) throw new Error(`failed to fetch npm token from clauth daemon: HTTP ${response.status}`);
28
+ const token = (await response.text()).trim();
29
+ if (!token) throw new Error("clauth npm token is empty");
30
+ if (!token.startsWith("npm_")) throw new Error("clauth npm token does not look like an npm token");
31
+ return token;
32
+ }
33
+
34
+ function withNpmAuth(token, fn) {
35
+ const npmrc = path.join(os.tmpdir(), `clauth-npm-${process.pid}-${Date.now()}.npmrc`);
36
+ try {
37
+ fs.writeFileSync(npmrc, `//registry.npmjs.org/:_authToken=${token}`, { encoding: "utf8", mode: 0o600 });
38
+ return fn(npmrc);
39
+ } finally {
40
+ try { fs.rmSync(npmrc, { force: true }); } catch {}
41
+ }
42
+ }
43
+
44
+ function runNpmWithToken(token, args) {
45
+ return withNpmAuth(token, npmrc => run("npm", ["--userconfig", npmrc, ...args]));
46
+ }
47
+
48
+ function printResult(result, { redact = true } = {}) {
49
+ const stdout = redact ? redactNpmTokenList(result.stdout) : result.stdout;
50
+ const stderr = redact ? redactNpmTokenList(result.stderr) : result.stderr;
51
+ if (stdout) process.stdout.write(stdout);
52
+ if (stderr) process.stderr.write(stderr);
53
+ }
54
+
55
+ function usage() {
56
+ console.log(`clauth npm <action>
57
+
58
+ Actions:
59
+ whoami Verify the clauth npm token identity
60
+ tokens List npm token metadata with token strings redacted
61
+ set-local Write the clauth npm token to the user npm config
62
+ sync-github-secret <repo> Set repo secret NPM_TOKEN from clauth, e.g. LIFEAI/rdc-skills
63
+ rerun <run-id> --repo <repo> Rerun a failed GitHub Actions workflow
64
+ `);
65
+ }
66
+
67
+ export async function runNpm(action = "help", opts = {}) {
68
+ if (action === "help") {
69
+ usage();
70
+ return;
71
+ }
72
+
73
+ const token = await fetchNpmToken();
74
+
75
+ if (action === "whoami") {
76
+ const result = runNpmWithToken(token, ["whoami", "--registry=https://registry.npmjs.org/"]);
77
+ printResult(result);
78
+ if (result.status !== 0) process.exitCode = result.status;
79
+ return;
80
+ }
81
+
82
+ if (action === "tokens") {
83
+ const result = runNpmWithToken(token, ["token", "list", "--json", "--registry=https://registry.npmjs.org/"]);
84
+ printResult(result);
85
+ if (result.status !== 0) process.exitCode = result.status;
86
+ return;
87
+ }
88
+
89
+ if (action === "set-local") {
90
+ const result = run("npm", ["config", "set", "//registry.npmjs.org/:_authToken", token, "--location=user"]);
91
+ if (result.status !== 0) {
92
+ printResult(result);
93
+ process.exitCode = result.status;
94
+ return;
95
+ }
96
+ console.log("local npm auth updated from clauth service 'npm'");
97
+ return;
98
+ }
99
+
100
+ if (action === "sync-github-secret") {
101
+ const repo = opts.repo || opts.args?.[0];
102
+ if (!repo) throw new Error("repo is required, e.g. clauth npm sync-github-secret LIFEAI/rdc-skills");
103
+ const result = run("gh", ["secret", "set", "NPM_TOKEN", "--repo", repo], { input: token });
104
+ printResult(result);
105
+ if (result.status !== 0) process.exitCode = result.status;
106
+ else console.log(`GitHub secret NPM_TOKEN updated for ${repo}`);
107
+ return;
108
+ }
109
+
110
+ if (action === "rerun") {
111
+ const runId = opts.args?.[0];
112
+ const repo = opts.repo;
113
+ if (!runId || !repo) throw new Error("usage: clauth npm rerun <run-id> --repo LIFEAI/rdc-skills");
114
+ const result = run("gh", ["run", "rerun", runId, "--repo", repo, "--failed"]);
115
+ printResult(result);
116
+ if (result.status !== 0) process.exitCode = result.status;
117
+ else console.log(`rerun requested for ${repo} run ${runId}`);
118
+ return;
119
+ }
120
+
121
+ usage();
122
+ throw new Error(`unknown clauth npm action: ${action}`);
123
+ }
124
+
125
+ // ── Guarded publish ──────────────────────────────────────────────────────────
126
+ // Generic, package-agnostic publish that REFUSES to publish unless the package
127
+ // is already committed and pushed to GitHub — so the npm tarball can never be
128
+ // built from uncommitted "dev" code that isn't on the repo. Works for standalone
129
+ // repos and monorepo subpackages (clean-check is scoped to the package's dir).
130
+ export async function runPublish(target, opts = {}) {
131
+ const pkgDir = path.resolve(target || process.cwd());
132
+ const pkgJsonPath = path.join(pkgDir, "package.json");
133
+ if (!fs.existsSync(pkgJsonPath)) throw new Error(`No package.json found at ${pkgDir}`);
134
+ const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf8"));
135
+ if (!pkg.name || !pkg.version) throw new Error(`package.json at ${pkgDir} is missing name or version`);
136
+ if (pkg.private === true) throw new Error(`${pkg.name} is marked "private": true — refusing to publish`);
137
+ const access = opts.access || pkg.publishConfig?.access || (pkg.name.startsWith("@") ? "public" : undefined);
138
+
139
+ const gitRoot = run("git", ["rev-parse", "--show-toplevel"], { cwd: pkgDir }).stdout?.trim();
140
+ if (!gitRoot) throw new Error(`${pkgDir} is not inside a git repository`);
141
+ const rel = path.relative(gitRoot, pkgDir) || ".";
142
+
143
+ // Guard 1 — nothing uncommitted in the package (else we'd pack dev code).
144
+ const dirty = run("git", ["status", "--porcelain", "--", rel], { cwd: gitRoot }).stdout?.trim();
145
+ if (dirty && !opts.allowDirty) {
146
+ throw new Error(`Refusing to publish ${pkg.name}@${pkg.version}: uncommitted changes in ${rel} would be packed but are NOT on GitHub:\n${dirty}\n\nCommit + push first (or pass --allow-dirty to override).`);
147
+ }
148
+
149
+ // Guard 2 — HEAD is on a remote (actually pushed to GitHub).
150
+ const head = run("git", ["rev-parse", "HEAD"], { cwd: gitRoot }).stdout?.trim();
151
+ const onRemote = run("git", ["branch", "-r", "--contains", head], { cwd: gitRoot }).stdout?.trim();
152
+ if (!onRemote && !opts.allowUnpushed) {
153
+ throw new Error(`Refusing to publish ${pkg.name}@${pkg.version}: HEAD ${head.slice(0, 9)} is not on any remote branch — push to GitHub first (or pass --allow-unpushed to override).`);
154
+ }
155
+
156
+ // Traceability — warn (don't block) if there's no v<version> tag at HEAD.
157
+ const tags = (run("git", ["tag", "--points-at", "HEAD"], { cwd: gitRoot }).stdout || "").split("\n").map((s) => s.trim()).filter(Boolean);
158
+ if (!tags.includes(`v${pkg.version}`)) {
159
+ console.log(`⚠ No git tag v${pkg.version} at HEAD (traceability only). Tags here: ${tags.join(", ") || "none"}`);
160
+ }
161
+
162
+ console.log(`${opts.dryRun ? "[dry-run] " : ""}Publishing ${pkg.name}@${pkg.version} from ${rel} @ ${head.slice(0, 9)} (pushed${access ? `, access=${access}` : ""})`);
163
+
164
+ const token = await fetchNpmToken();
165
+ const args = ["publish"];
166
+ if (access) args.push("--access", access);
167
+ if (opts.dryRun) args.push("--dry-run");
168
+ const result = withNpmAuth(token, (npmrc) => run("npm", ["--userconfig", npmrc, ...args], { cwd: pkgDir }));
169
+ printResult(result, { redact: true });
170
+ if (result.status !== 0) { process.exitCode = result.status; throw new Error(`npm publish failed for ${pkg.name}`); }
171
+ if (opts.dryRun) { console.log("Dry run complete — nothing published."); return; }
172
+
173
+ // Verify on the registry directly (bypasses npm's local cache lag).
174
+ try {
175
+ const reg = await fetch(`https://registry.npmjs.org/${pkg.name}`);
176
+ const meta = await reg.json();
177
+ if (meta.versions?.[pkg.version]) console.log(`✓ Verified ${pkg.name}@${pkg.version} on the registry (latest: ${meta["dist-tags"]?.latest}).`);
178
+ else console.log(`⚠ ${pkg.name}@${pkg.version} not visible on the registry yet (propagation lag) — re-check shortly.`);
179
+ } catch (e) {
180
+ console.log(`(could not verify on registry: ${e.message})`);
181
+ }
182
+ }
@@ -0,0 +1,211 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import pm2 from "pm2";
5
+
6
+ const DEFAULT_PORT = 52439;
7
+ const DEFAULT_NAME = "clauth-deployment";
8
+ const INSTALLER_MARKER = "clauth.ops.install.v1";
9
+ const OPS_ENV_KEYS = new Set([
10
+ "CLAUTH_OPS_INSTALLER_MARKER", "CLAUTH_OPS_ENABLED", "CLAUTH_OPS_APPLICATIONS", "CLAUTH_OPS_ADMIN_ENABLED",
11
+ "CLAUTH_OPS_ADMIN_APPLICATIONS", "CLAUTH_OPS_ALLOW_HOST_WIDE", "CLAUTH_OPS_DEPLOYMENTS", "CLAUTH_COOLIFY_PROMOTE_UUIDS",
12
+ ]);
13
+ const RUNTIME_ENV_KEYS = new Set(["PATH", "Path", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot", "SYSTEMROOT", "ComSpec", "TMP", "TEMP"]);
14
+ const RESTORABLE_PM2_KEYS = ["node_args", "exec_mode", "instances", "namespace", "watch", "kill_retry_time", "merge_logs", "windowsHide", "stop_exit_codes"];
15
+
16
+ function requiredString(value, label) {
17
+ if (typeof value !== "string" || !value.trim()) throw new Error(`${label} is required`);
18
+ return value.trim();
19
+ }
20
+
21
+ function stringList(value, label) {
22
+ if (value == null) return [];
23
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim())) throw new Error(`${label} must be an array of non-empty strings`);
24
+ return value.map((item) => item.trim());
25
+ }
26
+
27
+ function applicationPolicy(value, label) {
28
+ if (value == null) return {};
29
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object`);
30
+ return Object.fromEntries(Object.entries(value).map(([operation, targets]) => [operation, stringList(targets, `${label}.${operation}`)]));
31
+ }
32
+
33
+ /**
34
+ * An operation is only usable when it ALSO has a target allowlist.
35
+ *
36
+ * `createOperationPolicy` reads the allowlist from `applications[operation]` and
37
+ * rejects with `service_not_available` when it is empty (operation-policy.js,
38
+ * the `!allowed.length` guard). That code reads like the service is down, but
39
+ * it actually means "no targets configured" — so a config carrying only
40
+ * `agent_enabled` installs cleanly, passes the health and bearer-gate probes,
41
+ * and then rejects every authenticated call. That exact combination shipped to
42
+ * the canary and was misdiagnosed for a day as a missing credential.
43
+ *
44
+ * Read operations (`list`, `describe`, `ping`, `logs`, …) are affected too: the
45
+ * `!allowed.length` guard runs before the read/mutate distinction, so a read op
46
+ * with no allowlist is rejected just the same.
47
+ *
48
+ * Fail at install instead, naming the operations that would silently break.
49
+ */
50
+ function assertEnabledOperationsHaveTargets(enabled, applications, label) {
51
+ const missing = enabled.filter((operation) => !(applications[operation] || []).length);
52
+ if (!missing.length) return;
53
+ const applicationsLabel = label.replace("_enabled", "_applications");
54
+ throw new Error(
55
+ `${label}: ${missing.join(", ")} enabled but absent from ${applicationsLabel}. An enabled operation with no target allowlist is rejected at runtime with service_not_available. Add a target list (e.g. {"${missing[0]}": ["*"]}) or remove it from the enabled list.`,
56
+ );
57
+ }
58
+
59
+ export function loadOpsInstallConfig(configPath) {
60
+ const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
61
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new Error("ops installer config must be a JSON object");
62
+ const port = raw.port == null ? DEFAULT_PORT : Number(raw.port);
63
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error("port must be an integer from 1024 through 65535");
64
+ const pm2Name = raw.pm2_name == null ? DEFAULT_NAME : requiredString(raw.pm2_name, "pm2_name");
65
+ const operations = raw.operations && typeof raw.operations === "object" && !Array.isArray(raw.operations) ? raw.operations : {};
66
+
67
+ const enabled = stringList(operations.agent_enabled, "operations.agent_enabled");
68
+ const applications = applicationPolicy(operations.agent_applications, "operations.agent_applications");
69
+ const adminEnabled = stringList(operations.admin_enabled, "operations.admin_enabled");
70
+ const adminApplications = applicationPolicy(operations.admin_applications, "operations.admin_applications");
71
+
72
+ assertEnabledOperationsHaveTargets(enabled, applications, "operations.agent_enabled");
73
+ assertEnabledOperationsHaveTargets(adminEnabled, adminApplications, "operations.admin_enabled");
74
+
75
+ return {
76
+ port,
77
+ pm2Name,
78
+ enabled,
79
+ applications,
80
+ adminEnabled,
81
+ adminApplications,
82
+ allowHostWide: operations.allow_host_wide === true,
83
+ deployments: raw.deployments && typeof raw.deployments === "object" && !Array.isArray(raw.deployments) ? raw.deployments : {},
84
+ coolifyPromoteUuids: stringList(raw.coolify_promote_uuids, "coolify_promote_uuids"),
85
+ };
86
+ }
87
+
88
+ export function buildOpsServiceSpec({ config, rootDir }) {
89
+ const cli = path.join(rootDir, "cli", "index.js");
90
+ const runtimeEnv = Object.fromEntries([...RUNTIME_ENV_KEYS].flatMap((key) => process.env[key] == null ? [] : [[key, process.env[key]]]));
91
+ return {
92
+ name: config.pm2Name,
93
+ script: cli,
94
+ args: `serve foreground --port ${config.port} --isolated`,
95
+ cwd: rootDir,
96
+ autorestart: true,
97
+ restart_delay: 1000,
98
+ max_restarts: 20,
99
+ // Do not inherit the invoking agent or shell environment: PM2 otherwise
100
+ // stores every inherited value in its process metadata.
101
+ // PM2 only activates filtering for a non-empty array. Exclude every
102
+ // inherited key, then explicitly add the small runtime allowlist above.
103
+ filter_env: Object.keys(process.env),
104
+ env: {
105
+ ...runtimeEnv,
106
+ CLAUTH_OPS_INSTALLER_MARKER: INSTALLER_MARKER,
107
+ CLAUTH_OPS_ENABLED: [...config.enabled, ...(Object.keys(config.deployments).length ? ["deploy"] : []), ...(config.coolifyPromoteUuids.length ? ["coolify_promote"] : [])].join(","),
108
+ CLAUTH_OPS_APPLICATIONS: JSON.stringify(config.applications),
109
+ CLAUTH_OPS_ADMIN_ENABLED: config.adminEnabled.join(","),
110
+ CLAUTH_OPS_ADMIN_APPLICATIONS: JSON.stringify(config.adminApplications),
111
+ CLAUTH_OPS_ALLOW_HOST_WIDE: config.allowHostWide ? "1" : "0",
112
+ CLAUTH_OPS_DEPLOYMENTS: JSON.stringify(config.deployments),
113
+ CLAUTH_COOLIFY_PROMOTE_UUIDS: JSON.stringify(config.coolifyPromoteUuids),
114
+ },
115
+ };
116
+ }
117
+
118
+ function pm2Call(method, ...args) {
119
+ return new Promise((resolve, reject) => pm2[method](...args, (error, value) => error ? reject(error) : resolve(value)));
120
+ }
121
+
122
+ async function probe(endpoint) {
123
+ const health = await fetch(`${endpoint}/health`, { signal: AbortSignal.timeout(5000) });
124
+ const healthBody = health.ok ? await health.json() : null;
125
+ if (healthBody?.status !== "ok") throw new Error(`health probe failed at ${endpoint}`);
126
+ const catalog = await fetch(`${endpoint}/v1/ops/catalog`, { signal: AbortSignal.timeout(5000) });
127
+ if (catalog.status !== 401) throw new Error(`bearer gate probe failed: expected HTTP 401, received ${catalog.status}`);
128
+ return { health_status: health.status, bearer_gate_status: catalog.status, listening_port: healthBody.listening_port, process_id: healthBody.process_id };
129
+ }
130
+
131
+ function argsFor(entry) {
132
+ const value = entry?.pm2_env?.args ?? entry?.args ?? "";
133
+ return Array.isArray(value) ? value.join(" ") : String(value || "");
134
+ }
135
+
136
+ function ownedService(entry, spec) {
137
+ const env = entry?.pm2_env || {};
138
+ return env.CLAUTH_OPS_INSTALLER_MARKER === INSTALLER_MARKER
139
+ && path.resolve(env.pm_exec_path || "") === path.resolve(spec.script)
140
+ && path.resolve(env.pm_cwd || "") === path.resolve(spec.cwd)
141
+ && argsFor(entry) === spec.args;
142
+ }
143
+
144
+ export function previousSpec(entry) {
145
+ const env = entry.pm2_env || {};
146
+ const restored = {
147
+ name: env.name,
148
+ script: env.pm_exec_path,
149
+ args: argsFor(entry),
150
+ cwd: env.pm_cwd,
151
+ autorestart: env.autorestart !== false,
152
+ restart_delay: env.restart_delay || 1000,
153
+ max_restarts: env.max_restarts || 20,
154
+ filter_env: Object.keys(process.env),
155
+ env: Object.fromEntries(Object.entries(env).filter(([key]) => OPS_ENV_KEYS.has(key) || RUNTIME_ENV_KEYS.has(key))),
156
+ };
157
+ if (env.exec_interpreter !== undefined) restored.interpreter = env.exec_interpreter;
158
+ for (const key of RESTORABLE_PM2_KEYS) if (env[key] !== undefined) restored[key] = env[key];
159
+ return restored;
160
+ }
161
+
162
+ async function rollback(previous, name) {
163
+ try { await pm2Call("delete", name); } catch {}
164
+ if (previous) await pm2Call("start", previous);
165
+ }
166
+
167
+ export async function runOpsInstall(opts = {}) {
168
+ const configPath = path.resolve(requiredString(opts.config, "--config"));
169
+ const config = loadOpsInstallConfig(configPath);
170
+ const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
171
+ const spec = buildOpsServiceSpec({ config, rootDir });
172
+ const endpoint = `http://127.0.0.1:${config.port}`;
173
+ if (opts.dryRun) {
174
+ process.stdout.write(`${JSON.stringify({ action: "install", dry_run: true, platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, operations: Object.keys(config.applications).sort() }, null, 2)}\n`);
175
+ return;
176
+ }
177
+ await new Promise((resolve, reject) => pm2.connect(false, (error) => error ? reject(error) : resolve()));
178
+ let result;
179
+ let previous = null;
180
+ try {
181
+ const existing = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
182
+ // PM2 rejects an inline `env` object on restart-by-name. Replacing only
183
+ // this dedicated control-plane process is the supported way to apply a
184
+ // changed server policy without touching any managed application.
185
+ if (existing && !ownedService(existing, spec)) throw new Error(`refusing to replace non-installer PM2 process: ${config.pm2Name}`);
186
+ previous = existing ? previousSpec(existing) : null;
187
+ if (existing) await pm2Call("delete", config.pm2Name);
188
+ await pm2Call("start", spec);
189
+ for (let attempt = 0; attempt < 20; attempt += 1) {
190
+ try {
191
+ result = await probe(endpoint);
192
+ const current = (await pm2Call("list")).find((entry) => (entry.name || entry.pm2_env?.name) === config.pm2Name);
193
+ const pid = current?.pid ?? current?.pm2_env?.pm_pid;
194
+ const status = current?.pm2_env?.status ?? current?.status;
195
+ if (status !== "online" || !Number.isInteger(pid) || result.process_id !== pid || result.listening_port !== config.port) {
196
+ throw new Error("new PM2 process does not own the verified control-plane endpoint");
197
+ }
198
+ break;
199
+ } catch (error) {
200
+ if (attempt === 19) throw error;
201
+ await new Promise((resolve) => setTimeout(resolve, 250));
202
+ }
203
+ }
204
+ } catch (error) {
205
+ await rollback(previous, config.pm2Name);
206
+ throw error;
207
+ } finally {
208
+ await new Promise((resolve) => pm2.disconnect(() => resolve()));
209
+ }
210
+ process.stdout.write(`${JSON.stringify({ action: "install", platform: process.platform, endpoint, pm2_name: config.pm2Name, policy_file: configPath, health_status: result.health_status, bearer_gate_status: result.bearer_gate_status }, null, 2)}\n`);
211
+ }
@@ -0,0 +1,69 @@
1
+ const DEFAULT_LOCAL_CLAUTH = "http://127.0.0.1:52437";
2
+
3
+ function endpointFrom(opts) {
4
+ const approved = process.env.CLAUTH_OPS_APPROVED_ORIGIN;
5
+ if (!approved) throw new Error("Ops endpoint unavailable: CLAUTH_OPS_APPROVED_ORIGIN is required");
6
+ const approvedUrl = new URL(approved);
7
+ const requested = opts.endpoint ? new URL(opts.endpoint) : approvedUrl;
8
+ if (approvedUrl.protocol !== "https:" || requested.origin !== approvedUrl.origin) throw new Error("Ops endpoint is not the approved Vultr origin");
9
+ return approvedUrl.toString().replace(/\/$/, "");
10
+ }
11
+
12
+ async function bearer(service = process.env.CLAUTH_OPS_TOKEN_SERVICE || "vultr-ops-api-token") {
13
+ const response = await fetch(`${process.env.CLAUTH_LOCAL_URL || DEFAULT_LOCAL_CLAUTH}/v/${encodeURIComponent(service)}`);
14
+ if (!response.ok) throw new Error(`${service} unavailable from local clauth`);
15
+ const token = (await response.text()).trim();
16
+ if (!token) throw new Error("vultr-ops-api-token is empty");
17
+ return token;
18
+ }
19
+
20
+ export async function requestOps({ endpoint, token, method, path, body }) {
21
+ const response = await fetch(`${endpoint}${path}`, {
22
+ method,
23
+ headers: { Authorization: `Bearer ${token}`, Accept: "application/json", ...(body ? { "Content-Type": "application/json" } : {}) },
24
+ ...(body ? { body: JSON.stringify(body) } : {}),
25
+ });
26
+ const payload = await response.json().catch(() => ({ error: "invalid_json_response" }));
27
+ if (!response.ok) throw new Error(payload.error || `ops HTTP ${response.status}`);
28
+ return payload;
29
+ }
30
+
31
+ function print(value) {
32
+ process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
33
+ }
34
+
35
+ export async function runOps(action, opts = {}) {
36
+ const endpoint = endpointFrom(opts);
37
+ const token = await bearer(opts.tokenService);
38
+ const request = (method, path, body) => requestOps({ endpoint, token, method, path, body });
39
+ if (action === "catalog") return print(await request("GET", "/v1/ops/catalog"));
40
+ if (action === "list") return print(await request("GET", "/v1/ops/processes"));
41
+ if (action === "describe") {
42
+ if (!opts.target) throw new Error("--target is required for describe");
43
+ return print(await request("GET", `/v1/ops/processes/${encodeURIComponent(opts.target)}`));
44
+ }
45
+ if (action === "run") {
46
+ if (!opts.operation) throw new Error("--operation is required for run");
47
+ const input = {
48
+ ...(opts.target ? { target: opts.target } : {}),
49
+ ...(opts.script ? { script: opts.script } : {}),
50
+ ...(opts.instances ? { instances: Number(opts.instances) } : {}),
51
+ ...(opts.argsJson ? { args: JSON.parse(opts.argsJson) } : {}),
52
+ ...(opts.optionsJson ? { options: JSON.parse(opts.optionsJson) } : {}),
53
+ };
54
+ return print(await request("POST", "/v1/ops/operations", { operation: opts.operation, input }));
55
+ }
56
+ if (action === "promote") {
57
+ if (!opts.application) throw new Error("--application is required for promote");
58
+ return print(await request("POST", "/v1/ops/promotions", { application_uuid: opts.application }));
59
+ }
60
+ if (action === "deploy") {
61
+ if (!opts.application) throw new Error("--application is required for deploy");
62
+ return print(await request("POST", "/v1/ops/deployments", { application: opts.application, ...(opts.ref ? { ref: opts.ref } : {}) }));
63
+ }
64
+ if (action === "job") {
65
+ if (!opts.job) throw new Error("--job is required for job");
66
+ return print(await request("GET", `/v1/ops/jobs/${encodeURIComponent(opts.job)}`));
67
+ }
68
+ throw new Error(`unknown ops action: ${action}`);
69
+ }