@lifeaitools/clauth 2.0.0 → 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.
@@ -662,7 +662,9 @@ function openBrowser(url) {
662
662
  }
663
663
 
664
664
  // ── Dashboard HTML ───────────────────────────────────────────
665
- function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null) {
665
+ // Exported so cli/supervisor-ui.test.js can extract the served dashboard script
666
+ // and drive its real functions, instead of asserting on source text.
667
+ export function dashboardHtml(port, whitelist, isStaged = false, initWriteToken = null) {
666
668
  return `<!DOCTYPE html>
667
669
  <html lang="en">
668
670
  <head>
@@ -1369,10 +1371,130 @@ function renderSetPanel(serviceOrName) {
1369
1371
  let writeToken = ${JSON.stringify(initWriteToken)};
1370
1372
 
1371
1373
  function writeHeaders(extra) {
1372
- if (!writeToken) throw new Error("Write access requires password unlock in this browser session.");
1374
+ if (!writeToken) throw new Error("Writes are locked click 🔓 Unlock Writes to enable saving.");
1373
1375
  return { ...(extra || {}), "X-Clauth-Write-Token": writeToken };
1374
1376
  }
1375
1377
 
1378
+ // ── Write-access choke point ────────────────
1379
+ // ONE mechanism sits in front of every write action.
1380
+ //
1381
+ // A button on this dashboard IS the human being present — they are past the
1382
+ // lock screen already. So a click must never demand a second password. Before
1383
+ // each action the page silently acquires a current write token from
1384
+ // POST /write-token, which succeeds whenever the vault is unlocked. The
1385
+ // password modal is the LOCKED-VAULT fallback only, and the common case never
1386
+ // reaches it.
1387
+ //
1388
+ // Acquiring per action (rather than trusting the token injected at page load)
1389
+ // is what makes staleness structurally impossible: the server session has a
1390
+ // 10-minute TTL while a dashboard tab can stay open for hours. That gap is the
1391
+ // actual defect behind "adding a service says I need write unlock" — the page
1392
+ // kept sending a token the daemon had already expired, and writeGuard answered
1393
+ // 403 "write token required". One loopback round-trip per click closes it.
1394
+ //
1395
+ // If the vault really is locked, the action is parked, the modal opens, and a
1396
+ // successful unlock re-fires it exactly once. Cancelling clears the park, so no
1397
+ // delayed write can fire later.
1398
+ //
1399
+ // writeHeaders() above still throws, but only as a backstop for a write path
1400
+ // that never went through here — WRITE_ACTIONS is asserted complete against
1401
+ // every writeHeaders() call site by cli/supervisor-ui.test.js.
1402
+ let pendingWriteAction = null;
1403
+
1404
+ // Returns true when writeToken now holds a token the daemon will accept.
1405
+ // False means the vault is genuinely locked (or the daemon is unreachable),
1406
+ // which is the only case that deserves a password prompt.
1407
+ async function ensureWriteAccess() {
1408
+ try {
1409
+ const r = await fetch(BASE + "/write-token", { method: "POST" }).then(res => res.json());
1410
+ if (r && r.write_token) {
1411
+ writeToken = r.write_token;
1412
+ refreshWriteLockUi();
1413
+ return true;
1414
+ }
1415
+ } catch {}
1416
+ return false;
1417
+ }
1418
+
1419
+ // Every dashboard function whose body calls writeHeaders(). Adding a write
1420
+ // action without adding it here fails the registry-completeness test.
1421
+ const WRITE_ACTIONS = [
1422
+ "rescanSupervisorPlugins",
1423
+ "runSupervisorSurface",
1424
+ "rotateKey",
1425
+ "setExpiry",
1426
+ "saveProject",
1427
+ "saveLabel",
1428
+ "deleteService",
1429
+ "saveKey",
1430
+ "toggleService",
1431
+ "changePassword",
1432
+ "addService",
1433
+ "enrollMachine",
1434
+ "submitMount",
1435
+ "deleteMount",
1436
+ "wizSubmitCfToken",
1437
+ ];
1438
+
1439
+ function withWriteAccess(name, fn) {
1440
+ // async, so every path returns a thenable — a caller doing action(x).catch(…)
1441
+ // must not TypeError on the no-write-access path only.
1442
+ const guarded = async function (...args) {
1443
+ if (!(await ensureWriteAccess())) {
1444
+ // Vault is locked. Park the WHOLE call — nothing has run yet, so a
1445
+ // confirm() inside the action is asked once, on the retry, rather than
1446
+ // before a no-op.
1447
+ pendingWriteAction = { name, fn, self: this, args };
1448
+ openWriteUnlockModal();
1449
+ return undefined;
1450
+ }
1451
+ return fn.apply(this, args);
1452
+ };
1453
+ guarded.__writeGuarded = true;
1454
+ guarded.__unguarded = fn;
1455
+ return guarded;
1456
+ }
1457
+
1458
+ function clearPendingWriteAction() { pendingWriteAction = null; }
1459
+
1460
+ // Read-only seams so tests (and the console) can observe park state without
1461
+ // reaching into a module-scoped binding.
1462
+ function hasPendingWriteAction() { return pendingWriteAction !== null; }
1463
+ function pendingWriteActionName() { return pendingWriteAction ? pendingWriteAction.name : null; }
1464
+
1465
+ function takePendingWriteAction() {
1466
+ const pending = pendingWriteAction;
1467
+ pendingWriteAction = null;
1468
+ return pending;
1469
+ }
1470
+
1471
+ function installWriteAccessGuards(scope) {
1472
+ for (const name of WRITE_ACTIONS) {
1473
+ const fn = scope[name];
1474
+ if (typeof fn === "function" && fn.__writeGuarded) continue;
1475
+ if (typeof fn !== "function") {
1476
+ // Fail loud, not silent. A registered name that is not a global function
1477
+ // is almost always a write action refactored from a function declaration
1478
+ // to a const/arrow binding: const/let create no global property, so
1479
+ // scope[name] is undefined, the guard would no-op, and the inline
1480
+ // onclick="" would resolve through the lexical binding straight to the
1481
+ // UNGUARDED function — silently restoring the write-lock defect for that
1482
+ // action. Skipping it here is how that regression would ship green.
1483
+ throw new Error(
1484
+ "clauth write-guard install failed: '" + name + "' is registered in WRITE_ACTIONS but is not a " +
1485
+ "global function. Declare it as 'function " + name + "(...)' — a const/let/arrow binding cannot be guarded."
1486
+ );
1487
+ }
1488
+ scope[name] = withWriteAccess(name, fn);
1489
+ }
1490
+ }
1491
+
1492
+ // Safe to run here: every dashboard function is a top-level function
1493
+ // declaration, so all of them are hoisted and initialised before this
1494
+ // statement executes. Inline onclick="" handlers resolve through the same
1495
+ // global properties, so they get the guarded versions.
1496
+ installWriteAccessGuards(typeof window !== "undefined" ? window : globalThis);
1497
+
1376
1498
  async function boot() {
1377
1499
  try {
1378
1500
  const ping = await fetch(BASE + "/ping").then(r => r.json());
@@ -1681,6 +1803,9 @@ async function unlock() {
1681
1803
  }
1682
1804
 
1683
1805
  writeToken = r.write_token || null;
1806
+ // Full-vault unlock is a fresh page state, not a retry of a parked write.
1807
+ // Kept symmetric with unlockWrites() so no path can carry a stale park.
1808
+ clearPendingWriteAction();
1684
1809
  input.value = "";
1685
1810
  const ping = await fetch(BASE + "/ping").then(r => r.json());
1686
1811
  showMain(ping);
@@ -1706,6 +1831,9 @@ async function lockVault() {
1706
1831
  // Needed when the daemon auto-unlocks via --pw/boot.key: the page never sees the
1707
1832
  // unlock screen, so it holds no write token. POST /auth mints one (10-min TTL).
1708
1833
  function unlockWrites() {
1834
+ // Manual unlock from the toolbar — nothing is parked, so make sure a stale
1835
+ // park from an earlier dismissed attempt cannot ride along on this unlock.
1836
+ clearPendingWriteAction();
1709
1837
  openWriteUnlockModal();
1710
1838
  }
1711
1839
 
@@ -1720,6 +1848,9 @@ function openWriteUnlockModal() {
1720
1848
  }
1721
1849
 
1722
1850
  function closeWriteUnlockModal() {
1851
+ // Dismissing the modal must disarm the parked write. submitWriteUnlock()
1852
+ // takes the park before it calls this, so a successful unlock still retries.
1853
+ clearPendingWriteAction();
1723
1854
  const overlay = document.getElementById("write-unlock-overlay");
1724
1855
  if (overlay) overlay.style.display = "none";
1725
1856
  }
@@ -1743,8 +1874,19 @@ async function submitWriteUnlock() {
1743
1874
  return;
1744
1875
  }
1745
1876
  writeToken = r.write_token || null;
1877
+ // Take the park BEFORE closing — closeWriteUnlockModal() clears it.
1878
+ const pending = takePendingWriteAction();
1746
1879
  refreshWriteLockUi();
1747
1880
  closeWriteUnlockModal();
1881
+ if (pending && writeToken) {
1882
+ // Re-fire the ORIGINAL unguarded action, exactly once. Using the
1883
+ // unguarded reference means it can never re-park itself into a loop.
1884
+ try {
1885
+ await pending.fn.apply(pending.self, pending.args);
1886
+ } catch (retryErr) {
1887
+ console.error("[clauth] write action '" + pending.name + "' failed after unlock:", retryErr);
1888
+ }
1889
+ }
1748
1890
  } catch (e) {
1749
1891
  if (err) err.textContent = "Unlock error: " + (e.message || e);
1750
1892
  } finally {
@@ -6063,6 +6205,36 @@ function createServer(initPassword, whitelist, port, tunnelHostnameInit = null,
6063
6205
  }
6064
6206
  }
6065
6207
 
6208
+ // POST /write-token — hand the dashboard a current write token for an
6209
+ // ALREADY-UNLOCKED vault, with no second password prompt.
6210
+ //
6211
+ // A human looking at the dashboard has already passed the lock screen (or
6212
+ // the daemon was auto-unlocked via --pw/boot.key). Making them re-enter the
6213
+ // password to press a button is ceremony, not security: GET / at the route
6214
+ // above already embeds a write token on exactly this condition. This route
6215
+ // just lets the page RENEW it, which GET / could only do on a full reload.
6216
+ //
6217
+ // Why the write token still exists at all: every request reaching this
6218
+ // server is already loopback-only (hard 403 above), but cloudflared proxies
6219
+ // the public tunnel FROM localhost, so a remote request and a local one are
6220
+ // indistinguishable by address. The token is what a page-driven write has
6221
+ // and a blind remote POST does not, so the gate stays; only the prompt goes.
6222
+ //
6223
+ // Mint-or-reuse, mirroring GET /: reusing a still-valid session keeps
6224
+ // multiple open tabs working instead of each one invalidating the last.
6225
+ if (method === "POST" && reqPath === "/write-token") {
6226
+ if (!password) {
6227
+ res.writeHead(403, { "Content-Type": "application/json", ...CORS });
6228
+ return res.end(JSON.stringify({ error: "vault_locked", locked: true }));
6229
+ }
6230
+ if (!writeSession || Date.now() > writeSession.expiresAt) writeSession = makeWriteToken();
6231
+ return ok(res, {
6232
+ ok: true,
6233
+ write_token: writeSession.token,
6234
+ write_expires_at: new Date(writeSession.expiresAt).toISOString(),
6235
+ });
6236
+ }
6237
+
6066
6238
  // POST /auth — unlock the vault with a password (verifies against Edge Function)
6067
6239
  if (method === "POST" && reqPath === "/auth") {
6068
6240
  let body;
@@ -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
@@ -150,7 +150,7 @@ import { runInstall } from './commands/install.js';
150
150
  import { runUninstall } from './commands/uninstall.js';
151
151
  import { runScrub } from './commands/scrub.js';
152
152
  import { runServe, MCP_TOOLS } from './commands/serve.js';
153
- import { listPlugins, registerPlugin } from './supervisor-registry.js';
153
+ import { deregisterPlugin, listPlugins, registerPlugin, syncPluginsFromRepos, SYNC_REPO_NAMES, SYNC_SKIP_STATES } from './supervisor-registry.js';
154
154
  import { runOps } from './commands/ops.js';
155
155
  import { runOpsInstall } from './commands/ops-install.js';
156
156
  import { runCodevelop } from './commands/codevelop.js';
@@ -1034,6 +1034,78 @@ pluginCmd
1034
1034
  console.log(` ✓ ${chalk.white(receipt.target?.plugin_id || '?')} ${state} — surfaces: ${(receipt.resulting_state?.surfaces || []).join(', ') || 'none'}`);
1035
1035
  });
1036
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
+
1037
1109
  // ──────────────────────────────────────────────
1038
1110
  // clauth chitchat --session <id>
1039
1111
  // ──────────────────────────────────────────────