@lifeaitools/clauth 1.30.26 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ }
package/cli/index.js CHANGED
@@ -149,11 +149,41 @@ 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 { listPlugins, registerPlugin } from './supervisor-registry.js';
153
154
  import { runOps } from './commands/ops.js';
154
155
  import { runOpsInstall } from './commands/ops-install.js';
155
156
  import { runCodevelop } from './commands/codevelop.js';
156
157
  import { runNpm, runPublish } from './commands/npm.js';
158
+ import { runLogin } from './commands/login.js';
159
+
160
+ // ──────────────────────────────────────────────
161
+ // clauth login <target>
162
+ // THE global login directive — the one sanctioned way to SSH into any
163
+ // clauth-managed box. Never hand-fetch a private key (clauth get <ssh-service>)
164
+ // and hand-roll ssh-agent/ssh yourself; add the box to LOGIN_TARGETS in
165
+ // cli/commands/login.js instead and use this command.
166
+ // ──────────────────────────────────────────────
167
+ program
168
+ .command('login [target]')
169
+ .description('SSH into a clauth-managed box by name (e.g. vultr) — run with no target to list them')
170
+ .option('-c, --command <cmd>', 'Run one command instead of opening an interactive shell')
171
+ .option('--host <user@host>', 'Override the target\'s default host')
172
+ .option('--native-windows', 'Human-only: use native Windows OpenSSH instead of Git Bash (blocked for agent sessions)')
173
+ .addHelpText('after', `
174
+ Examples:
175
+ clauth login List known login targets
176
+ clauth login vultr Interactive shell on the Vultr dev box
177
+ clauth login vultr -c "pm2 list" Run one command, non-interactive
178
+ clauth login vultr --host root@1.2.3.4 Override the default host for this target
179
+ `)
180
+ .action(async (target, opts) => {
181
+ await runLogin(target, {
182
+ command: opts.command,
183
+ host: opts.host,
184
+ nativeWindows: opts.nativeWindows,
185
+ });
186
+ });
157
187
 
158
188
  program
159
189
  .command('install')
@@ -933,6 +963,77 @@ tunnelCmd
933
963
  }
934
964
  });
935
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
+
936
1037
  // ──────────────────────────────────────────────
937
1038
  // clauth chitchat --session <id>
938
1039
  // ──────────────────────────────────────────────
@@ -167,7 +167,26 @@ function normalizeCommand(command, field) {
167
167
  const [cmd, ...args] = command;
168
168
  if (typeof cmd !== "string" || !cmd.trim()) throw new Error(`${field}[0] is required`);
169
169
  if (/[;&|<>]/.test(cmd)) throw new Error(`${field}[0] must be an executable path/name, not shell syntax`);
170
- return [cmd, ...args.map(String)];
170
+ const normalizedArgs = args.map(String);
171
+ // shell:true (Windows-only, see execute() in runSurfaceAction) hands each
172
+ // arg to cmd.exe verbatim — a shell metacharacter in an arg is exactly as
173
+ // exploitable as one in cmd[0]. A legitimate CLI arg for the pm2/node
174
+ // invocations this schema targets never needs raw shell syntax.
175
+ for (const [i, arg] of normalizedArgs.entries()) {
176
+ if (/[;&|<>]/.test(arg)) throw new Error(`${field}[${i + 1}] must not contain shell syntax`);
177
+ }
178
+ return [cmd, ...normalizedArgs];
179
+ }
180
+
181
+ // Quotes a value for cmd.exe /c when shell:true is active and the value
182
+ // contains whitespace — spawnSync does NOT auto-quote in that mode, so an
183
+ // absolute path like "C:\Program Files\nodejs\node.exe" (or any arg with a
184
+ // space) breaks at the first space unless the caller quotes it. Idempotent:
185
+ // an already-quoted value is left as-is rather than double-quoted.
186
+ export function shellQuote(value, useShell) {
187
+ if (!useShell || !/\s/.test(value)) return value;
188
+ if (value.startsWith('"') && value.endsWith('"')) return value;
189
+ return `"${value}"`;
171
190
  }
172
191
 
173
192
  function expandPathToken(value) {
@@ -229,7 +248,7 @@ function localhostHealth(pathOrUrl, port) {
229
248
  function normalizeSurface(surface, plugin) {
230
249
  if (!surface || typeof surface !== "object") throw new Error("surface must be an object");
231
250
  const id = String(surface.id || "").trim();
232
- if (!/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error("surface.id may contain only letters, numbers, dot, underscore, and dash");
251
+ if (!/^[a-zA-Z0-9_.-]+$/.test(id) || /^\.+$/.test(id)) throw new Error("surface.id may contain only letters, numbers, dot, underscore, and dash, and may not be all dots");
233
252
  const destination = normalizeDestination(surface.destination || plugin.destination);
234
253
  const lifecycle_owner = normalizeLifecycleOwner(surface.lifecycle_owner || plugin.lifecycle_owner);
235
254
  const port = surface.port === undefined || surface.port === null || surface.port === "auto" ? surface.port ?? null : Number(surface.port);
@@ -254,7 +273,7 @@ export function validatePluginManifest(manifest, sourcePath = "") {
254
273
  if (!manifest || typeof manifest !== "object") throw new Error("manifest must be an object");
255
274
  if (manifest.schema !== SCHEMA) throw new Error(`schema must be ${SCHEMA}`);
256
275
  const id = String(manifest.id || "").trim();
257
- if (!/^[a-zA-Z0-9_.-]+$/.test(id)) throw new Error("id may contain only letters, numbers, dot, underscore, and dash");
276
+ if (!/^[a-zA-Z0-9_.-]+$/.test(id) || /^\.+$/.test(id)) throw new Error("id may contain only letters, numbers, dot, underscore, and dash, and may not be all dots");
258
277
  const version = String(manifest.version || "").trim();
259
278
  if (!version) throw new Error("version is required");
260
279
  const plugin = {
@@ -306,6 +325,17 @@ function findManifestFiles(root) {
306
325
  for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
307
326
  const full = path.join(root, entry.name);
308
327
  if (entry.isDirectory()) {
328
+ // A scoped npm package (@lifeaitools/fs-mcp) installs two levels deep
329
+ // under node_modules/@scope/pkg-name/ — descend one extra level only
330
+ // for @scope directories. Unscoped layout (one level) is unchanged.
331
+ if (entry.name.startsWith("@")) {
332
+ for (const scopedEntry of fs.readdirSync(full, { withFileTypes: true })) {
333
+ if (!scopedEntry.isDirectory()) continue;
334
+ const scopedCandidate = path.join(full, scopedEntry.name, "clauth-plugin.json");
335
+ if (fs.existsSync(scopedCandidate)) out.push(scopedCandidate);
336
+ }
337
+ continue;
338
+ }
309
339
  const candidate = path.join(full, "clauth-plugin.json");
310
340
  if (fs.existsSync(candidate)) out.push(candidate);
311
341
  } else if (entry.isFile() && entry.name === "clauth-plugin.json") {
@@ -394,6 +424,65 @@ export function discoverPlugins() {
394
424
  return { plugins, surfaces, events };
395
425
  }
396
426
 
427
+ // Registers one plugin manifest into the managed-plugins root, then runs
428
+ // discovery so it's picked up immediately. This is the entry point a product
429
+ // repo's own install/deploy step calls to self-register — the mechanism the
430
+ // PLUGIN-ARCHITECTURE-DECISION.md "each MCP ships its own clauth-plugin.json"
431
+ // model needs for a monorepo workspace member (no npm install lifecycle hook
432
+ // to piggyback on, unlike a standalone published package with postinstall.js).
433
+ // Idempotent: re-registering unchanged content is a safe no-op re-affirm.
434
+ export function registerPlugin(manifestPath, actor = "localhost") {
435
+ let raw;
436
+ try {
437
+ raw = fs.readFileSync(manifestPath, "utf8");
438
+ } catch (error) {
439
+ return operation("plugin.register", { manifest_path: manifestPath }, null, {
440
+ ok: false, state: "manifest_unreadable", error: error instanceof Error ? error.message : String(error),
441
+ }, actor);
442
+ }
443
+ let manifest;
444
+ try {
445
+ manifest = validatePluginManifest(JSON.parse(raw), manifestPath);
446
+ } catch (error) {
447
+ return operation("plugin.register", { manifest_path: manifestPath }, null, {
448
+ ok: false, state: "manifest_invalid", error: error instanceof Error ? error.message : String(error),
449
+ }, actor);
450
+ }
451
+ const [{ root: managedRoot }] = rootEntries();
452
+ const resolvedRoot = path.resolve(managedRoot);
453
+ const targetDir = path.resolve(managedRoot, manifest.id);
454
+ // Belt-and-braces: the id regex already rejects traversal-shaped ids, but
455
+ // this asserts containment at the actual write site so a future regex
456
+ // relaxation can't silently reopen a path escape out of the managed root —
457
+ // this is a credential vault writing files from parsed manifest content.
458
+ if (targetDir !== resolvedRoot && !targetDir.startsWith(resolvedRoot + path.sep)) {
459
+ return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
460
+ ok: false, state: "manifest_invalid", error: "plugin id escapes the managed plugin root",
461
+ }, actor);
462
+ }
463
+ const targetPath = path.join(targetDir, "clauth-plugin.json");
464
+ const priorRaw = fs.existsSync(targetPath) ? fs.readFileSync(targetPath, "utf8") : null;
465
+ const unchanged = priorRaw !== null && sha256(priorRaw) === sha256(raw);
466
+ if (!unchanged) {
467
+ try {
468
+ fs.mkdirSync(targetDir, { recursive: true });
469
+ fs.writeFileSync(targetPath, raw, "utf8");
470
+ } catch (error) {
471
+ return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
472
+ ok: false, state: "write_failed", error: error instanceof Error ? error.message : String(error),
473
+ }, actor);
474
+ }
475
+ }
476
+ const discovery = discoverPlugins();
477
+ const registered = discovery.plugins.find((plugin) => plugin.id === manifest.id);
478
+ return operation("plugin.register", { manifest_path: manifestPath, plugin_id: manifest.id }, null, {
479
+ ok: Boolean(registered) && registered.state !== "manifest_invalid",
480
+ state: unchanged ? "unchanged" : "registered",
481
+ plugin_state: registered?.state || "not_found",
482
+ surfaces: registered?.surfaces?.map((surface) => surface.id) || [],
483
+ }, actor);
484
+ }
485
+
397
486
  export function listPlugins() {
398
487
  return loadSupervisorState().plugins || [];
399
488
  }
@@ -422,7 +511,7 @@ export function readSupervisorEvents(limit = 100) {
422
511
  });
423
512
  }
424
513
 
425
- function operation(action, target, prior, result, actor = "localhost") {
514
+ export function operation(action, target, prior, result, actor = "localhost") {
426
515
  const receipt = {
427
516
  operationId: crypto.randomUUID(),
428
517
  actor,
@@ -522,10 +611,22 @@ export function runSurfaceAction(id, action, actor = "localhost") {
522
611
  }
523
612
  const execute = (selectedCommand) => {
524
613
  const [cmd, ...args] = selectedCommand;
525
- return spawnSync(cmd, args, {
614
+ const useShell = process.platform === "win32";
615
+ // With shell:true on Windows, spawnSync hands cmd/args to cmd.exe /c
616
+ // verbatim and does NOT auto-quote — an absolute path or arg containing
617
+ // a space (e.g. "C:\Program Files\nodejs\node.exe") breaks at the first
618
+ // space unless quoted. Bare shim names (pm2, npm) never contain spaces,
619
+ // so this only ever affects absolute-path values, and only on Windows.
620
+ const resolvedCmd = shellQuote(cmd, useShell);
621
+ const resolvedArgs = args.map((arg) => shellQuote(arg, useShell));
622
+ return spawnSync(resolvedCmd, resolvedArgs, {
526
623
  cwd: surface.cwd || undefined,
527
624
  env: { ...process.env, CLAUTH_PM2_HOME: getClauthPm2Home(), PM2_HOME: getClauthPm2Home() },
528
625
  windowsHide: true,
626
+ // Windows resolves CLI shims (pm2, npm, etc.) to .cmd files that
627
+ // spawnSync cannot exec directly without a shell — ENOENT otherwise.
628
+ // POSIX targets (Vultr/Coolify) need no shell and keep prior behavior.
629
+ shell: useShell,
529
630
  encoding: "utf8",
530
631
  timeout: Number(surface.timeoutMs || 30000),
531
632
  });