@lifeaitools/clauth 1.31.1 → 2.0.1

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.
@@ -1,164 +1,164 @@
1
- // cli/commands/uninstall.js
2
- // clauth uninstall — full teardown: DB objects, Edge Function, secrets, skill, local config
3
- //
4
- // Reverses everything `clauth install` does:
5
- // 1. Drops clauth tables, policies, triggers, functions from Supabase
6
- // 2. Deletes auth-vault Edge Function
7
- // 3. Removes CLAUTH_* secrets
8
- // 4. Removes Claude skill directory
9
- // 5. Clears local config (Conf store)
10
-
11
- import { existsSync, rmSync } from 'fs';
12
- import { join } from 'path';
13
- import Conf from 'conf';
14
- import chalk from 'chalk';
15
- import ora from 'ora';
16
-
17
- const MGMT = 'https://api.supabase.com/v1';
18
- const SKILLS_DIR = process.env.CLAUTH_SKILLS_DIR ||
19
- (process.platform === 'win32'
20
- ? join(process.env.USERPROFILE || '', '.claude', 'skills')
21
- : join(process.env.HOME || '', '.claude', 'skills'));
22
-
23
- // ─────────────────────────────────────────────
24
- // Supabase Management API helper
25
- // ─────────────────────────────────────────────
26
- async function mgmt(pat, method, path, body) {
27
- const res = await fetch(`${MGMT}${path}`, {
28
- method,
29
- headers: { 'Authorization': `Bearer ${pat}`, 'Content-Type': 'application/json' },
30
- body: body ? JSON.stringify(body) : undefined,
31
- });
32
- if (!res.ok) {
33
- const text = await res.text().catch(() => res.statusText);
34
- throw new Error(`${method} ${path} → HTTP ${res.status}: ${text}`);
35
- }
36
- if (res.status === 204) return {};
37
- const text = await res.text();
38
- if (!text) return {};
39
- return JSON.parse(text);
40
- }
41
-
42
- // ─────────────────────────────────────────────
43
- // Main uninstall command
44
- // ─────────────────────────────────────────────
45
- export async function runUninstall(opts = {}) {
46
- console.log(chalk.red('\n🗑️ clauth uninstall\n'));
47
-
48
- const config = new Conf({ projectName: 'clauth' });
49
-
50
- // ── Collect credentials ────────────────────
51
- const ref = opts.ref || config.get('supabase_url')?.match(/https:\/\/(.+)\.supabase\.co/)?.[1];
52
- const pat = opts.pat;
53
-
54
- if (!ref) {
55
- console.log(chalk.red(' Cannot determine Supabase project ref.'));
56
- console.log(chalk.gray(' Use: clauth uninstall --ref <project-ref> --pat <personal-access-token>'));
57
- process.exit(1);
58
- }
59
- if (!pat) {
60
- console.log(chalk.red(' Supabase PAT required for teardown.'));
61
- console.log(chalk.gray(' Use: clauth uninstall --ref <project-ref> --pat <personal-access-token>'));
62
- process.exit(1);
63
- }
64
-
65
- console.log(chalk.gray(` Project: ${ref}\n`));
66
-
67
- // ── Step 1: Drop database objects ──────────
68
- const s1 = ora('Dropping clauth database objects...').start();
69
- const teardownSQL = `
70
- -- Drop triggers
71
- DROP TRIGGER IF EXISTS clauth_services_updated ON public.clauth_services;
72
-
73
- -- Drop tables (CASCADE drops policies automatically)
74
- DROP TABLE IF EXISTS public.clauth_audit CASCADE;
75
- DROP TABLE IF EXISTS public.clauth_machines CASCADE;
76
- DROP TABLE IF EXISTS public.clauth_services CASCADE;
77
-
78
- -- Drop functions
79
- DROP FUNCTION IF EXISTS public.clauth_touch_updated() CASCADE;
80
- DROP FUNCTION IF EXISTS public.clauth_upsert_vault_secret(text, text) CASCADE;
81
- DROP FUNCTION IF EXISTS public.clauth_get_vault_secret(text) CASCADE;
82
- DROP FUNCTION IF EXISTS public.clauth_delete_vault_secret(text) CASCADE;
83
- `;
84
-
85
- try {
86
- await mgmt(pat, 'POST', `/projects/${ref}/database/query`, { query: teardownSQL });
87
- s1.succeed('Database objects dropped (tables, triggers, functions, policies)');
88
- } catch (e) {
89
- s1.fail(`Database teardown failed: ${e.message}`);
90
- console.log(chalk.yellow(' You may need to drop objects manually via SQL editor.'));
91
- }
92
-
93
- // ── Step 2: Delete Edge Function ───────────
94
- const s2 = ora('Deleting auth-vault Edge Function...').start();
95
- try {
96
- const res = await fetch(`${MGMT}/projects/${ref}/functions/auth-vault`, {
97
- method: 'DELETE',
98
- headers: { 'Authorization': `Bearer ${pat}` },
99
- });
100
- if (res.ok || res.status === 404) {
101
- s2.succeed(res.status === 404
102
- ? 'Edge Function not found (already deleted)'
103
- : 'Edge Function deleted');
104
- } else {
105
- const text = await res.text().catch(() => res.statusText);
106
- throw new Error(`HTTP ${res.status}: ${text}`);
107
- }
108
- } catch (e) {
109
- s2.fail(`Edge Function delete failed: ${e.message}`);
110
- console.log(chalk.yellow(' Delete manually: Supabase Dashboard → Edge Functions → auth-vault → Delete'));
111
- }
112
-
113
- // ── Step 3: Remove secrets ─────────────────
114
- const s3 = ora('Removing clauth secrets...').start();
115
- try {
116
- // Supabase Management API: DELETE /projects/{ref}/secrets with body listing secret names
117
- const res = await fetch(`${MGMT}/projects/${ref}/secrets`, {
118
- method: 'DELETE',
119
- headers: { 'Authorization': `Bearer ${pat}`, 'Content-Type': 'application/json' },
120
- body: JSON.stringify(['CLAUTH_HMAC_SALT', 'CLAUTH_ADMIN_BOOTSTRAP_TOKEN']),
121
- });
122
- if (res.ok) {
123
- s3.succeed('Secrets removed (CLAUTH_HMAC_SALT, CLAUTH_ADMIN_BOOTSTRAP_TOKEN)');
124
- } else {
125
- const text = await res.text().catch(() => res.statusText);
126
- throw new Error(`HTTP ${res.status}: ${text}`);
127
- }
128
- } catch (e) {
129
- s3.warn(`Secret removal failed: ${e.message}`);
130
- console.log(chalk.yellow(' Remove manually: Supabase → Settings → Edge Functions → Secrets'));
131
- }
132
-
133
- // ── Step 4: Remove Claude skill ────────────
134
- const s4 = ora('Removing Claude skill...').start();
135
- const skillDir = join(SKILLS_DIR, 'clauth');
136
- if (existsSync(skillDir)) {
137
- try {
138
- rmSync(skillDir, { recursive: true, force: true });
139
- s4.succeed(`Skill removed: ${skillDir}`);
140
- } catch (e) {
141
- s4.warn(`Could not remove skill: ${e.message}`);
142
- }
143
- } else {
144
- s4.succeed('Skill directory not found (already removed)');
145
- }
146
-
147
- // ── Step 5: Clear local config ─────────────
148
- const s5 = ora('Clearing local config...').start();
149
- try {
150
- config.clear();
151
- s5.succeed('Local config cleared');
152
- } catch (e) {
153
- s5.warn(`Could not clear config: ${e.message}`);
154
- }
155
-
156
- // ── Done ───────────────────────────────────
157
- console.log('');
158
- console.log(chalk.red('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
159
- console.log(chalk.yellow(' ✓ clauth fully uninstalled'));
160
- console.log(chalk.red('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
161
- console.log('');
162
- console.log(chalk.gray(' To reinstall: npx @lifeaitools/clauth install'));
163
- console.log('');
164
- }
1
+ // cli/commands/uninstall.js
2
+ // clauth uninstall — full teardown: DB objects, Edge Function, secrets, skill, local config
3
+ //
4
+ // Reverses everything `clauth install` does:
5
+ // 1. Drops clauth tables, policies, triggers, functions from Supabase
6
+ // 2. Deletes auth-vault Edge Function
7
+ // 3. Removes CLAUTH_* secrets
8
+ // 4. Removes Claude skill directory
9
+ // 5. Clears local config (Conf store)
10
+
11
+ import { existsSync, rmSync } from 'fs';
12
+ import { join } from 'path';
13
+ import Conf from 'conf';
14
+ import chalk from 'chalk';
15
+ import ora from 'ora';
16
+
17
+ const MGMT = 'https://api.supabase.com/v1';
18
+ const SKILLS_DIR = process.env.CLAUTH_SKILLS_DIR ||
19
+ (process.platform === 'win32'
20
+ ? join(process.env.USERPROFILE || '', '.claude', 'skills')
21
+ : join(process.env.HOME || '', '.claude', 'skills'));
22
+
23
+ // ─────────────────────────────────────────────
24
+ // Supabase Management API helper
25
+ // ─────────────────────────────────────────────
26
+ async function mgmt(pat, method, path, body) {
27
+ const res = await fetch(`${MGMT}${path}`, {
28
+ method,
29
+ headers: { 'Authorization': `Bearer ${pat}`, 'Content-Type': 'application/json' },
30
+ body: body ? JSON.stringify(body) : undefined,
31
+ });
32
+ if (!res.ok) {
33
+ const text = await res.text().catch(() => res.statusText);
34
+ throw new Error(`${method} ${path} → HTTP ${res.status}: ${text}`);
35
+ }
36
+ if (res.status === 204) return {};
37
+ const text = await res.text();
38
+ if (!text) return {};
39
+ return JSON.parse(text);
40
+ }
41
+
42
+ // ─────────────────────────────────────────────
43
+ // Main uninstall command
44
+ // ─────────────────────────────────────────────
45
+ export async function runUninstall(opts = {}) {
46
+ console.log(chalk.red('\n🗑️ clauth uninstall\n'));
47
+
48
+ const config = new Conf({ projectName: 'clauth' });
49
+
50
+ // ── Collect credentials ────────────────────
51
+ const ref = opts.ref || config.get('supabase_url')?.match(/https:\/\/(.+)\.supabase\.co/)?.[1];
52
+ const pat = opts.pat;
53
+
54
+ if (!ref) {
55
+ console.log(chalk.red(' Cannot determine Supabase project ref.'));
56
+ console.log(chalk.gray(' Use: clauth uninstall --ref <project-ref> --pat <personal-access-token>'));
57
+ process.exit(1);
58
+ }
59
+ if (!pat) {
60
+ console.log(chalk.red(' Supabase PAT required for teardown.'));
61
+ console.log(chalk.gray(' Use: clauth uninstall --ref <project-ref> --pat <personal-access-token>'));
62
+ process.exit(1);
63
+ }
64
+
65
+ console.log(chalk.gray(` Project: ${ref}\n`));
66
+
67
+ // ── Step 1: Drop database objects ──────────
68
+ const s1 = ora('Dropping clauth database objects...').start();
69
+ const teardownSQL = `
70
+ -- Drop triggers
71
+ DROP TRIGGER IF EXISTS clauth_services_updated ON public.clauth_services;
72
+
73
+ -- Drop tables (CASCADE drops policies automatically)
74
+ DROP TABLE IF EXISTS public.clauth_audit CASCADE;
75
+ DROP TABLE IF EXISTS public.clauth_machines CASCADE;
76
+ DROP TABLE IF EXISTS public.clauth_services CASCADE;
77
+
78
+ -- Drop functions
79
+ DROP FUNCTION IF EXISTS public.clauth_touch_updated() CASCADE;
80
+ DROP FUNCTION IF EXISTS public.clauth_upsert_vault_secret(text, text) CASCADE;
81
+ DROP FUNCTION IF EXISTS public.clauth_get_vault_secret(text) CASCADE;
82
+ DROP FUNCTION IF EXISTS public.clauth_delete_vault_secret(text) CASCADE;
83
+ `;
84
+
85
+ try {
86
+ await mgmt(pat, 'POST', `/projects/${ref}/database/query`, { query: teardownSQL });
87
+ s1.succeed('Database objects dropped (tables, triggers, functions, policies)');
88
+ } catch (e) {
89
+ s1.fail(`Database teardown failed: ${e.message}`);
90
+ console.log(chalk.yellow(' You may need to drop objects manually via SQL editor.'));
91
+ }
92
+
93
+ // ── Step 2: Delete Edge Function ───────────
94
+ const s2 = ora('Deleting auth-vault Edge Function...').start();
95
+ try {
96
+ const res = await fetch(`${MGMT}/projects/${ref}/functions/auth-vault`, {
97
+ method: 'DELETE',
98
+ headers: { 'Authorization': `Bearer ${pat}` },
99
+ });
100
+ if (res.ok || res.status === 404) {
101
+ s2.succeed(res.status === 404
102
+ ? 'Edge Function not found (already deleted)'
103
+ : 'Edge Function deleted');
104
+ } else {
105
+ const text = await res.text().catch(() => res.statusText);
106
+ throw new Error(`HTTP ${res.status}: ${text}`);
107
+ }
108
+ } catch (e) {
109
+ s2.fail(`Edge Function delete failed: ${e.message}`);
110
+ console.log(chalk.yellow(' Delete manually: Supabase Dashboard → Edge Functions → auth-vault → Delete'));
111
+ }
112
+
113
+ // ── Step 3: Remove secrets ─────────────────
114
+ const s3 = ora('Removing clauth secrets...').start();
115
+ try {
116
+ // Supabase Management API: DELETE /projects/{ref}/secrets with body listing secret names
117
+ const res = await fetch(`${MGMT}/projects/${ref}/secrets`, {
118
+ method: 'DELETE',
119
+ headers: { 'Authorization': `Bearer ${pat}`, 'Content-Type': 'application/json' },
120
+ body: JSON.stringify(['CLAUTH_HMAC_SALT', 'CLAUTH_ADMIN_BOOTSTRAP_TOKEN']),
121
+ });
122
+ if (res.ok) {
123
+ s3.succeed('Secrets removed (CLAUTH_HMAC_SALT, CLAUTH_ADMIN_BOOTSTRAP_TOKEN)');
124
+ } else {
125
+ const text = await res.text().catch(() => res.statusText);
126
+ throw new Error(`HTTP ${res.status}: ${text}`);
127
+ }
128
+ } catch (e) {
129
+ s3.warn(`Secret removal failed: ${e.message}`);
130
+ console.log(chalk.yellow(' Remove manually: Supabase → Settings → Edge Functions → Secrets'));
131
+ }
132
+
133
+ // ── Step 4: Remove Claude skill ────────────
134
+ const s4 = ora('Removing Claude skill...').start();
135
+ const skillDir = join(SKILLS_DIR, 'clauth');
136
+ if (existsSync(skillDir)) {
137
+ try {
138
+ rmSync(skillDir, { recursive: true, force: true });
139
+ s4.succeed(`Skill removed: ${skillDir}`);
140
+ } catch (e) {
141
+ s4.warn(`Could not remove skill: ${e.message}`);
142
+ }
143
+ } else {
144
+ s4.succeed('Skill directory not found (already removed)');
145
+ }
146
+
147
+ // ── Step 5: Clear local config ─────────────
148
+ const s5 = ora('Clearing local config...').start();
149
+ try {
150
+ config.clear();
151
+ s5.succeed('Local config cleared');
152
+ } catch (e) {
153
+ s5.warn(`Could not clear config: ${e.message}`);
154
+ }
155
+
156
+ // ── Done ───────────────────────────────────
157
+ console.log('');
158
+ console.log(chalk.red('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
159
+ console.log(chalk.yellow(' ✓ clauth fully uninstalled'));
160
+ console.log(chalk.red('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'));
161
+ console.log('');
162
+ console.log(chalk.gray(' To reinstall: npx @lifeaitools/clauth install'));
163
+ console.log('');
164
+ }
@@ -131,7 +131,7 @@ export async function runWatchdog(action, opts = {}) {
131
131
  console.log("Usage: clauth watchdog restart <service-id>");
132
132
  return;
133
133
  }
134
- const result = restartWatchdogService(serviceId);
134
+ const result = await restartWatchdogService(serviceId);
135
135
  console.log(JSON.stringify(result, null, 2));
136
136
  return;
137
137
  }
package/cli/index.js CHANGED
@@ -149,7 +149,10 @@ program
149
149
  import { runInstall } from './commands/install.js';
150
150
  import { runUninstall } from './commands/uninstall.js';
151
151
  import { runScrub } from './commands/scrub.js';
152
- import { runServe } from './commands/serve.js';
152
+ import { runServe, MCP_TOOLS } from './commands/serve.js';
153
+ import { deregisterPlugin, listPlugins, registerPlugin, syncPluginsFromRepos, SYNC_REPO_NAMES, SYNC_SKIP_STATES } from './supervisor-registry.js';
154
+ import { runOps } from './commands/ops.js';
155
+ import { runOpsInstall } from './commands/ops-install.js';
153
156
  import { runCodevelop } from './commands/codevelop.js';
154
157
  import { runNpm, runPublish } from './commands/npm.js';
155
158
  import { runLogin } from './commands/login.js';
@@ -960,6 +963,149 @@ tunnelCmd
960
963
  }
961
964
  });
962
965
 
966
+ // ──────────────────────────────────────────────
967
+ // clauth mcp list
968
+ // ──────────────────────────────────────────────
969
+ // Known MCP-server plugin ids in the managed fleet. Positive allowlist
970
+ // rather than a naming heuristic (credential-name conventions and health
971
+ // route "kind" vary across these) — dev-center and any future non-MCP
972
+ // pm2-managed surface stay excluded by construction. Deliberately distinct
973
+ // from `clauth list` (vault credential services); this never touches those.
974
+ const MCP_SERVER_PLUGIN_IDS = new Set([
975
+ "fs-mcp",
976
+ "web-research",
977
+ "regen-media",
978
+ "regen-media-local",
979
+ "codeflow-mcp",
980
+ "rdc-skills",
981
+ ]);
982
+
983
+ const mcpCmd = program.command("mcp").description("Inspect clauth's own MCP tool catalog and managed MCP-server surfaces");
984
+
985
+ mcpCmd
986
+ .command("list")
987
+ .description("List clauth's advertised MCP tools and MCP-server surfaces (never vault credential services)")
988
+ .action(() => {
989
+ console.log(chalk.cyan(`\n clauth's own MCP tools (${MCP_TOOLS.length}):\n`));
990
+ for (const tool of MCP_TOOLS) {
991
+ console.log(` ${chalk.white(tool.name)} ${chalk.gray(tool.description || "")}`);
992
+ }
993
+ const mcpPlugins = listPlugins().filter((p) => MCP_SERVER_PLUGIN_IDS.has(p.id));
994
+ console.log(chalk.cyan(`\n Managed MCP-server surfaces (${mcpPlugins.length}):\n`));
995
+ if (!mcpPlugins.length) {
996
+ console.log(chalk.gray(" none discovered — is the daemon running? clauth serve"));
997
+ }
998
+ for (const plugin of mcpPlugins) {
999
+ const state = plugin.enabled ? plugin.state : "disabled";
1000
+ const icon = state === "current" ? "✓" : state === "disabled" ? "○" : "⚠";
1001
+ console.log(` ${icon} ${chalk.white(plugin.id)} ${chalk.gray(state)}`);
1002
+ for (const surface of plugin.surfaces || []) {
1003
+ console.log(` ${surface.id} ${chalk.gray(surface.health || surface.routes?.[0]?.url || "no health route")}`);
1004
+ }
1005
+ }
1006
+ console.log("");
1007
+ });
1008
+
1009
+ // ──────────────────────────────────────────────
1010
+ // clauth plugin register <manifest-path>
1011
+ // The self-registration entry point for PLUGIN-ARCHITECTURE-DECISION.md:
1012
+ // a product repo's own install/deploy step calls this after its
1013
+ // clauth-plugin.json is in place — same role as clauth's own
1014
+ // scripts/postinstall.js, for repos that have no npm-install lifecycle
1015
+ // hook of their own (monorepo workspace members, not standalone packages).
1016
+ // ──────────────────────────────────────────────
1017
+ const pluginCmd = program.command('plugin').description("Register and manage clauth-supervised plugin manifests");
1018
+
1019
+ pluginCmd
1020
+ .command('register <manifestPath>')
1021
+ .description('Validate a clauth-plugin.json and register it with the local supervisor, then run discovery')
1022
+ .action((manifestPath) => {
1023
+ const resolved = path.resolve(process.cwd(), manifestPath);
1024
+ const receipt = registerPlugin(resolved, 'cli');
1025
+ const ok = receipt.resulting_state?.ok;
1026
+ const state = receipt.resulting_state?.state;
1027
+ if (!ok) {
1028
+ const pluginState = receipt.resulting_state?.plugin_state;
1029
+ const detail = receipt.resulting_state?.error || (pluginState ? `plugin_state=${pluginState}` : 'registration failed');
1030
+ console.error(` ✗ ${state}: ${detail}`);
1031
+ process.exitCode = 1;
1032
+ return;
1033
+ }
1034
+ console.log(` ✓ ${chalk.white(receipt.target?.plugin_id || '?')} ${state} — surfaces: ${(receipt.resulting_state?.surfaces || []).join(', ') || 'none'}`);
1035
+ });
1036
+
1037
+ // clauth plugin sync — a SWEEP, NOT A CATALOG. It reads the ORIGINAL
1038
+ // clauth-plugin.json in each product repo; it stores no inventory of what
1039
+ // exists. See the comment above syncPluginsFromRepos in supervisor-registry.js
1040
+ // for why re-consolidating this into a catalog file undoes a deliberate change.
1041
+ pluginCmd
1042
+ .command('sync')
1043
+ .description('Sweep the known product-repo clauth-plugin.json manifests and register each one. Reads the ORIGINAL manifest in each repo — stores no catalog, no inventory, no manifest copy, no port assignments. A missing repo or manifest warns and continues.')
1044
+ .option(
1045
+ '--repo-root <path>',
1046
+ `Override a product-repo root as <path> (applies to regen-root) or <name>=<path> where <name> is one of: ${SYNC_REPO_NAMES.join(', ')}. Repeatable.`,
1047
+ (value, previous) => [...(previous || []), value],
1048
+ [],
1049
+ )
1050
+ .action((opts) => {
1051
+ const repoRoots = {};
1052
+ for (const entry of opts.repoRoot || []) {
1053
+ const match = /^([a-zA-Z0-9_.-]+)=(.+)$/.exec(entry);
1054
+ if (match) repoRoots[match[1]] = match[2];
1055
+ else repoRoots['regen-root'] = entry;
1056
+ }
1057
+ let receipts = [];
1058
+ let threw = null;
1059
+ try {
1060
+ receipts = syncPluginsFromRepos(repoRoots, 'cli');
1061
+ } catch (error) {
1062
+ // syncPluginsFromRepos is contracted not to throw; if it ever does, still
1063
+ // print whatever the operator needs rather than a raw Node stack.
1064
+ threw = error;
1065
+ }
1066
+ const skipped = new Set(SYNC_SKIP_STATES);
1067
+ let failures = 0;
1068
+ for (const receipt of receipts) {
1069
+ if (receipt.ok) {
1070
+ console.log(` ✓ ${chalk.white(receipt.id || '?')} ${receipt.state} — ${chalk.gray(receipt.path || receipt.repo)}`);
1071
+ } else if (skipped.has(receipt.state)) {
1072
+ console.warn(` ⚠ ${receipt.state}: ${chalk.gray(receipt.path || receipt.repo)}`);
1073
+ } else {
1074
+ failures += 1;
1075
+ console.error(` ✗ ${receipt.state}: ${receipt.error || receipt.path || receipt.repo}`);
1076
+ }
1077
+ }
1078
+ if (threw) {
1079
+ console.error(` ✗ sync_failed: ${threw instanceof Error ? threw.message : String(threw)}`);
1080
+ failures += 1;
1081
+ }
1082
+ if (failures > 0) process.exitCode = 1;
1083
+ });
1084
+
1085
+ pluginCmd
1086
+ .command('deregister <id>')
1087
+ .description('Remove one managed plugin directory by id and re-run discovery. Removing an unregistered id is a safe no-op.')
1088
+ .option('--dry-run', 'Resolve and print the directory that would be deleted, without deleting it')
1089
+ .action((id, opts) => {
1090
+ let receipt;
1091
+ try {
1092
+ receipt = deregisterPlugin(id, 'cli', { dryRun: Boolean(opts.dryRun) });
1093
+ } catch (error) {
1094
+ console.error(` ✗ deregister_failed: ${error instanceof Error ? error.message : String(error)}`);
1095
+ process.exitCode = 1;
1096
+ return;
1097
+ }
1098
+ const ok = receipt.resulting_state?.ok;
1099
+ const state = receipt.resulting_state?.state;
1100
+ if (!ok) {
1101
+ console.error(` ✗ ${state}: ${receipt.resulting_state?.error || 'deregistration failed'}`);
1102
+ process.exitCode = 1;
1103
+ return;
1104
+ }
1105
+ const target = receipt.resulting_state?.target_dir;
1106
+ console.log(` ✓ ${chalk.white(receipt.target?.plugin_id || '?')} ${state} — surfaces: ${(receipt.resulting_state?.surfaces || []).join(', ') || 'none'}${target ? ` — ${chalk.gray(target)}` : ''}`);
1107
+ });
1108
+
963
1109
  // ──────────────────────────────────────────────
964
1110
  // clauth chitchat --session <id>
965
1111
  // ──────────────────────────────────────────────
@@ -1079,4 +1225,22 @@ Examples:
1079
1225
  await runServe({ ...opts, action: resolvedAction });
1080
1226
  });
1081
1227
 
1228
+ program
1229
+ .command("ops <action>")
1230
+ .description("Call the bearer-authenticated PM2 and Coolify operations control plane")
1231
+ .option("--endpoint <url>", "HTTPS control-plane endpoint (or CLAUTH_OPS_ENDPOINT)")
1232
+ .option("--target <name>", "PM2 process name or id")
1233
+ .option("--script <path>", "PM2 script path for start")
1234
+ .option("--instances <n>", "PM2 scale target")
1235
+ .option("--operation <name>", "PM2 operation for run")
1236
+ .option("--args-json <json>", "JSON positional arguments for a raw pm2_* operation")
1237
+ .option("--options-json <json>", "JSON PM2 options for a typed operation")
1238
+ .option("--application <uuid>", "registered Coolify application UUID for promote")
1239
+ .option("--ref <name>", "registered Git ref for deploy")
1240
+ .option("--job <id>", "job id for status lookup")
1241
+ .option("--config <path>", "server-side JSON policy for ops install")
1242
+ .option("--dry-run", "validate and print ops install configuration without changing PM2")
1243
+ .addHelpText("after", `\nActions: catalog | list | describe | run | deploy | promote | job | install\n\nInstall: clauth ops install --config /etc/clauth/ops-control-plane.json\nThe installer creates or updates a PM2-managed local control plane, then proves /health and the bearer gate without reading a token.\n\nThe bearer is retrieved only from local clauth service vultr-ops-api-token and is never printed.\n`)
1244
+ .action(async (action, opts) => { if (action === "install") await runOpsInstall(opts); else await runOps(action, opts); });
1245
+
1082
1246
  program.parse(process.argv);
@@ -0,0 +1,80 @@
1
+ const TERMINAL = new Map([
2
+ ["finished", "succeeded"], ["success", "succeeded"], ["successful", "succeeded"],
3
+ ["failed", "failed"], ["error", "failed"], ["cancelled", "failed"],
4
+ ]);
5
+
6
+ function required(value, name) {
7
+ if (!value) throw new Error(`${name} is required`);
8
+ return value;
9
+ }
10
+
11
+ export function normalizeCoolifyState(value) {
12
+ const raw = String(value || "unknown").toLowerCase();
13
+ return TERMINAL.get(raw) || (raw.includes("progress") || raw.includes("queue") || raw.includes("running") ? "running" : "unknown");
14
+ }
15
+
16
+ /**
17
+ * Extract the deployment UUID from a Coolify deploy response.
18
+ *
19
+ * Coolify's `/api/v1/deploy` does NOT return the UUID at the top level — it
20
+ * answers with a `deployments` ARRAY:
21
+ *
22
+ * { "deployments": [ { "message": "...", "resource_uuid": "...",
23
+ * "deployment_uuid": "ha9xjqcldy7duf2diyjdc20p" } ] }
24
+ *
25
+ * Reading only `body.deployment_uuid` therefore yields undefined, the caller
26
+ * concludes the promotion never started, and the job is marked failed — while
27
+ * Coolify is in fact building. That happened on a real life.ai promote:
28
+ * deployment ha9xjqcldy7duf2diyjdc20p was created at the same second the job
29
+ * reported failure. A failed job invites a retry, so the bug turns one
30
+ * production deploy into several.
31
+ *
32
+ * Accepts the array shape first, then the flat shapes, so a future/simplified
33
+ * response still resolves.
34
+ */
35
+ export function deploymentUuidFrom(body) {
36
+ if (!body || typeof body !== "object") return null;
37
+ const list = Array.isArray(body.deployments) ? body.deployments : [];
38
+ for (const entry of list) {
39
+ if (!entry || typeof entry !== "object") continue;
40
+ const nested = entry.deployment_uuid || entry.uuid || entry.id;
41
+ if (typeof nested === "string" && nested) return nested;
42
+ }
43
+ const flat = body.deployment_uuid || body.uuid || body.id;
44
+ return typeof flat === "string" && flat ? flat : null;
45
+ }
46
+
47
+ export function createCoolifyAdapter({ baseUrl, getToken, fetchImpl = globalThis.fetch } = {}) {
48
+ const api = String(required(baseUrl, "baseUrl")).replace(/\/$/, "");
49
+ if (typeof getToken !== "function") throw new Error("getToken is required");
50
+ async function request(path, options = {}) {
51
+ const token = await getToken();
52
+ if (!token) throw new Error("coolify credential unavailable");
53
+ const headers = new Headers(options.headers || {});
54
+ headers.set("Accept", "application/json");
55
+ headers.set("Authorization", `Bearer ${token}`);
56
+ const response = await fetchImpl(`${api}${path}`, {
57
+ ...options,
58
+ headers,
59
+ });
60
+ if (!response.ok) throw new Error(`Coolify HTTP ${response.status}`);
61
+ return response.json();
62
+ }
63
+ return {
64
+ inspect(applicationUuid) { return request(`/api/v1/applications/${encodeURIComponent(required(applicationUuid, "applicationUuid"))}`); },
65
+ async promote(applicationUuid, options = {}) {
66
+ const uuid = encodeURIComponent(required(applicationUuid, "applicationUuid"));
67
+ return request(`/api/v1/deploy?uuid=${uuid}&force=true`, options);
68
+ },
69
+ async deployment(deploymentUuid) { return request(`/api/v1/deployments/${encodeURIComponent(required(deploymentUuid, "deploymentUuid"))}`); },
70
+ async poll(deploymentUuid, { attempts = 30, delay = async () => {} } = {}) {
71
+ for (let attempt = 1; attempt <= attempts; attempt++) {
72
+ const deployment = await this.deployment(deploymentUuid);
73
+ const state = normalizeCoolifyState(deployment.status || deployment.state);
74
+ if (state === "succeeded" || state === "failed") return { state, deployment, attempts: attempt };
75
+ await delay(attempt);
76
+ }
77
+ return { state: "timed_out", deployment: null, attempts };
78
+ },
79
+ };
80
+ }