@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.
- package/.clauth-skill/references/keys-guide.md +270 -270
- package/cli/api.js +238 -238
- package/cli/commands/install.js +396 -396
- package/cli/commands/serve.js +174 -2
- package/cli/commands/uninstall.js +164 -164
- package/cli/index.js +73 -1
- package/cli/supervisor-registry.js +291 -0
- package/cli/supervisor-registry.test.js +345 -4
- package/cli/supervisor-ui.test.js +436 -0
- package/install.ps1 +102 -102
- package/install.sh +49 -49
- package/package.json +2 -2
- package/scripts/bootstrap.cjs +121 -121
- package/supabase/functions/auth-vault/index.ts +350 -350
- package/supabase/migrations/001_clauth_schema.sql +94 -94
- package/supabase/migrations/002_vault_helpers.sql +90 -90
- package/supabase/migrations/20260317_lockout.sql +26 -26
package/cli/commands/serve.js
CHANGED
|
@@ -662,7 +662,9 @@ function openBrowser(url) {
|
|
|
662
662
|
}
|
|
663
663
|
|
|
664
664
|
// ── Dashboard HTML ───────────────────────────────────────────
|
|
665
|
-
|
|
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("
|
|
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
|
// ──────────────────────────────────────────────
|