@lifeaitools/clauth 2.0.0 → 2.0.2

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.
@@ -2,11 +2,447 @@ import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
+ import vm from 'node:vm';
5
6
  import { fileURLToPath } from 'node:url';
6
7
 
8
+ import { dashboardHtml } from './commands/serve.js';
9
+
7
10
  const here = path.dirname(fileURLToPath(import.meta.url));
8
11
  const serveSource = fs.readFileSync(path.join(here, 'commands', 'serve.js'), 'utf8');
9
12
 
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ // Functional harness
15
+ //
16
+ // The dashboard JS ships inside a served HTML template, so there is nothing to
17
+ // import. This harness renders the real page with dashboardHtml(), lifts its
18
+ // script into a vm realm with a stub DOM + stub fetch, and then calls the real
19
+ // controller seams (runSupervisorSurface, addService, submitWriteUnlock, …)
20
+ // with simulated inputs — per the global UI functional-harness rule. No pixels,
21
+ // no browser automation: assertions are on state, command and payload.
22
+ //
23
+ // The requirement these tests encode: a dashboard button IS the human being
24
+ // present. On an unlocked vault a click must proceed with no password prompt.
25
+ // The prompt exists only for a genuinely locked vault.
26
+ // ─────────────────────────────────────────────────────────────────────────────
27
+
28
+ function makeElement(id) {
29
+ const classes = new Set();
30
+ return {
31
+ id,
32
+ value: '',
33
+ textContent: '',
34
+ innerHTML: '',
35
+ className: '',
36
+ title: '',
37
+ disabled: false,
38
+ dataset: {},
39
+ style: {},
40
+ scrollHeight: 0,
41
+ scrollTop: 0,
42
+ clientHeight: 0,
43
+ classList: {
44
+ add: (c) => classes.add(c),
45
+ remove: (c) => classes.delete(c),
46
+ toggle: (c, on) => (on ? classes.add(c) : classes.delete(c)),
47
+ contains: (c) => classes.has(c),
48
+ },
49
+ addEventListener() {},
50
+ removeEventListener() {},
51
+ appendChild() {},
52
+ focus() {},
53
+ click() {},
54
+ querySelectorAll: () => [],
55
+ };
56
+ }
57
+
58
+ function bootDashboard({ initWriteToken = null, route } = {}) {
59
+ const html = dashboardHtml(53999, [], false, initWriteToken);
60
+ const start = html.indexOf('<script>');
61
+ const end = html.lastIndexOf('</script>');
62
+ assert.ok(start !== -1 && end > start, 'dashboard HTML must contain exactly one script block');
63
+ const script = html.slice(start + '<script>'.length, end);
64
+
65
+ const elements = new Map();
66
+ const fetchCalls = [];
67
+ const confirmCalls = [];
68
+
69
+ const sandbox = {
70
+ console: { log() {}, warn() {}, error() {} },
71
+ JSON,
72
+ Date,
73
+ Math,
74
+ setTimeout: () => 0,
75
+ clearTimeout: () => {},
76
+ setInterval: () => 0,
77
+ clearInterval: () => {},
78
+ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
79
+ navigator: { clipboard: { writeText: async () => {} } },
80
+ confirm: (msg) => { confirmCalls.push(msg); return true; },
81
+ alert: () => {},
82
+ document: {
83
+ getElementById(id) {
84
+ if (!elements.has(id)) elements.set(id, makeElement(id));
85
+ return elements.get(id);
86
+ },
87
+ createElement: (tag) => makeElement(tag),
88
+ querySelectorAll: () => [],
89
+ addEventListener() {},
90
+ body: makeElement('body'),
91
+ },
92
+ async fetch(url, options) {
93
+ const opts = options || {};
94
+ fetchCalls.push({ url: String(url), options: opts });
95
+ const body = route ? route(String(url), opts) : null;
96
+ // Default: vault reports itself locked, so boot() lands on the lock
97
+ // screen and issues no follow-up requests. Keeps the realm deterministic.
98
+ const json = body !== null && body !== undefined ? body : { locked: true, hard_locked: false };
99
+ return {
100
+ ok: true,
101
+ status: 200,
102
+ json: async () => json,
103
+ text: async () => JSON.stringify(json),
104
+ };
105
+ },
106
+ };
107
+ sandbox.location = { href: '', reload() {} };
108
+ sandbox.open = () => {};
109
+
110
+ vm.createContext(sandbox);
111
+ sandbox.window = sandbox;
112
+ sandbox.globalThis = sandbox;
113
+ vm.runInContext(script, sandbox, { filename: 'dashboard.js' });
114
+
115
+ return {
116
+ ctx: sandbox,
117
+ elements,
118
+ fetchCalls,
119
+ confirmCalls,
120
+ el: (id) => sandbox.document.getElementById(id),
121
+ /** Requests that actually carried write authority — i.e. real writes. */
122
+ writeCalls: () =>
123
+ fetchCalls.filter((c) => c.options.headers && c.options.headers['X-Clauth-Write-Token']),
124
+ tokenAcquisitions: () => fetchCalls.filter((c) => c.url.endsWith('/write-token')),
125
+ authCalls: () => fetchCalls.filter((c) => c.url.endsWith('/auth')),
126
+ settle: () => new Promise((r) => setImmediate(r)),
127
+ };
128
+ }
129
+
130
+ const SURFACE_ID = 'factory-test-plugin:primary';
131
+ const ACTIONS_PATH = '/v1/surfaces/' + encodeURIComponent(SURFACE_ID) + '/actions';
132
+
133
+ /** Vault unlocked: /write-token always hands back a current token. */
134
+ function unlockedRoute(url, options) {
135
+ if (url.endsWith('/write-token')) return { ok: true, write_token: 'wt-fresh' };
136
+ if (url.includes('/v1/surfaces/') && url.includes('/actions')) {
137
+ return { operationId: 'op-42', resulting_state: { state: 'started', ok: true } };
138
+ }
139
+ if (url.endsWith('/add-service')) return { ok: true };
140
+ if (url.endsWith('/ping')) return { locked: true, hard_locked: false };
141
+ return {};
142
+ }
143
+
144
+ /** Vault genuinely locked: /write-token refuses, /auth can still unlock it. */
145
+ function lockedRoute(url, options) {
146
+ if (url.endsWith('/write-token')) return { error: 'vault_locked', locked: true };
147
+ if (url.endsWith('/auth')) return { write_token: 'wt-after-password' };
148
+ if (url.includes('/v1/surfaces/') && url.includes('/actions')) {
149
+ return { operationId: 'op-43', resulting_state: { state: 'started', ok: true } };
150
+ }
151
+ if (url.endsWith('/add-service')) return { ok: true };
152
+ if (url.endsWith('/ping')) return { locked: true, hard_locked: false };
153
+ return {};
154
+ }
155
+
156
+ // ── 1. The requirement: a button on an unlocked vault just works ─────────────
157
+
158
+ test('unlocked vault, page holds no token: the action proceeds with NO password prompt', async () => {
159
+ // This is the --pw / boot.key auto-unlock case — the page never saw the lock
160
+ // screen, so dashboardHtml() injected no token. Dave's reported flow.
161
+ const h = bootDashboard({ initWriteToken: null, route: unlockedRoute });
162
+ await h.settle();
163
+
164
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'restart');
165
+ await h.settle();
166
+
167
+ assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex', 'no password modal may open');
168
+ assert.equal(h.ctx.hasPendingWriteAction(), false, 'nothing may be parked');
169
+ assert.deepEqual(h.authCalls(), [], 'no password round-trip may occur');
170
+
171
+ const writes = h.writeCalls();
172
+ assert.equal(writes.length, 1, 'the action must fire, once');
173
+ assert.ok(writes[0].url.endsWith(ACTIONS_PATH), 'got ' + writes[0].url);
174
+ assert.equal(writes[0].options.headers['X-Clauth-Write-Token'], 'wt-fresh');
175
+ assert.deepEqual(JSON.parse(writes[0].options.body), { action: 'restart' });
176
+ });
177
+
178
+ test('unlocked vault, page holds a STALE token: the write uses a freshly acquired one', async () => {
179
+ // The literal defect: the server write session has a 10-minute TTL while a
180
+ // dashboard tab stays open for hours, so the page kept sending a token the
181
+ // daemon had already expired and writeGuard answered 403 "write token
182
+ // required". Acquiring per action makes that unreachable.
183
+ const h = bootDashboard({ initWriteToken: 'wt-expired-hours-ago', route: unlockedRoute });
184
+ await h.settle();
185
+
186
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'stop');
187
+ await h.settle();
188
+
189
+ const writes = h.writeCalls();
190
+ assert.equal(writes.length, 1);
191
+ assert.equal(
192
+ writes[0].options.headers['X-Clauth-Write-Token'],
193
+ 'wt-fresh',
194
+ 'must send the refreshed token, never the stale page-load one',
195
+ );
196
+ assert.equal(h.tokenAcquisitions().length, 1, 'exactly one token acquisition per action');
197
+ assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex');
198
+ });
199
+
200
+ test('Add Service on an unlocked vault creates the service with no prompt', async () => {
201
+ const h = bootDashboard({ initWriteToken: null, route: unlockedRoute });
202
+ await h.settle();
203
+
204
+ h.el('add-name').value = 'my-new-service';
205
+ h.el('add-label').value = 'My New Service';
206
+ h.el('add-description').value = '';
207
+ h.el('add-project').value = '';
208
+ h.el('add-type').value = 'token';
209
+
210
+ await h.ctx.addService();
211
+ await h.settle();
212
+
213
+ assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex', 'no modal for Add Service');
214
+ assert.deepEqual(h.authCalls(), [], 'no password round-trip');
215
+
216
+ const writes = h.writeCalls();
217
+ assert.equal(writes.length, 1, 'Add Service must fire exactly once');
218
+ assert.ok(writes[0].url.endsWith('/add-service'), 'got ' + writes[0].url);
219
+ assert.equal(writes[0].options.method, 'POST');
220
+ assert.equal(writes[0].options.headers['X-Clauth-Write-Token'], 'wt-fresh');
221
+ assert.deepEqual(JSON.parse(writes[0].options.body), {
222
+ name: 'my-new-service',
223
+ key_type: 'token',
224
+ label: 'My New Service',
225
+ });
226
+ assert.match(h.el('add-msg').textContent, /my-new-service created/);
227
+ });
228
+
229
+ test('every write action reaches the acquire path, not just the two under test', async () => {
230
+ // Spot-check the breadth of the choke point: each of these is a distinct
231
+ // call site that previously threw its own opaque string.
232
+ for (const [name, args] of [
233
+ ['rescanSupervisorPlugins', []],
234
+ ['deleteService', ['some-service']],
235
+ ['toggleService', ['some-service']],
236
+ ['submitMount', []],
237
+ ['deleteMount', ['some-mount']],
238
+ ]) {
239
+ const h = bootDashboard({ initWriteToken: null, route: unlockedRoute });
240
+ await h.settle();
241
+ // submitMount reads two inputs and returns early when either is blank.
242
+ h.el('mount-name').value = 'm';
243
+ h.el('mount-path').value = 'C:/tmp';
244
+ h.el('badge-some-service').classList.add('on');
245
+
246
+ await h.ctx[name](...args);
247
+ await h.settle();
248
+
249
+ assert.equal(h.tokenAcquisitions().length, 1, name + ' must acquire write access');
250
+ assert.notEqual(h.el('write-unlock-overlay').style.display, 'flex', name + ' must not prompt');
251
+ assert.equal(h.ctx.hasPendingWriteAction(), false, name + ' must not park');
252
+ }
253
+ });
254
+
255
+ // ── 2. Locked-vault fallback — the only case that may prompt ─────────────────
256
+
257
+ test('genuinely locked vault: the action parks, prompts, and performs no write', async () => {
258
+ const h = bootDashboard({ initWriteToken: null, route: lockedRoute });
259
+ await h.settle();
260
+
261
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'restart');
262
+ await h.settle();
263
+
264
+ assert.equal(h.el('write-unlock-overlay').style.display, 'flex', 'locked vault must prompt');
265
+ assert.equal(h.ctx.hasPendingWriteAction(), true);
266
+ assert.equal(h.ctx.pendingWriteActionName(), 'runSupervisorSurface');
267
+ assert.deepEqual(h.writeCalls(), [], 'no write may be attempted against a locked vault');
268
+ });
269
+
270
+ test('locked vault: after the password unlock the parked action fires exactly once', async () => {
271
+ const h = bootDashboard({ initWriteToken: null, route: lockedRoute });
272
+ await h.settle();
273
+
274
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'restart');
275
+ await h.settle();
276
+ assert.deepEqual(h.writeCalls(), []);
277
+
278
+ h.el('write-unlock-input').value = 'correct-horse';
279
+ await h.ctx.submitWriteUnlock();
280
+ await h.settle();
281
+
282
+ const writes = h.writeCalls();
283
+ assert.equal(writes.length, 1, 'exactly once — not zero, not twice');
284
+ assert.ok(writes[0].url.endsWith(ACTIONS_PATH), 'got ' + writes[0].url);
285
+ assert.deepEqual(JSON.parse(writes[0].options.body), { action: 'restart' }, 'arguments preserved');
286
+ assert.equal(h.ctx.hasPendingWriteAction(), false, 'park consumed');
287
+ assert.equal(h.el('write-unlock-overlay').style.display, 'none', 'modal closed');
288
+ });
289
+
290
+ test('locked vault: cancelling clears the parked action, and no write fires later', async () => {
291
+ const h = bootDashboard({ initWriteToken: null, route: lockedRoute });
292
+ await h.settle();
293
+
294
+ await h.ctx.runSupervisorSurface(SURFACE_ID, 'stop');
295
+ await h.settle();
296
+ assert.equal(h.ctx.hasPendingWriteAction(), true);
297
+
298
+ h.ctx.closeWriteUnlockModal();
299
+ assert.equal(h.ctx.hasPendingWriteAction(), false, 'cancel must disarm the parked write');
300
+ assert.deepEqual(h.writeCalls(), []);
301
+
302
+ h.ctx.unlockWrites();
303
+ h.el('write-unlock-input').value = 'correct-horse';
304
+ await h.ctx.submitWriteUnlock();
305
+ await h.settle();
306
+
307
+ assert.deepEqual(h.writeCalls(), [], 'a cancelled action must never fire, then or later');
308
+ });
309
+
310
+ // ── 3. Registry completeness — the guard against a per-call-site regression ──
311
+
312
+ /**
313
+ * The reviewed write-action surface. Hand-maintained on purpose: growing it
314
+ * must be a decision someone made, not a number that drifts upward. A floor
315
+ * like `callers.size >= 15` cannot do this job — on a passing run the two
316
+ * set-differences below already force callers.size === registered.size, so a
317
+ * floor only ever catches the registry SHRINKING.
318
+ */
319
+ const EXPECTED_WRITE_ACTIONS = [
320
+ 'addService',
321
+ 'changePassword',
322
+ 'deleteMount',
323
+ 'deleteService',
324
+ 'enrollMachine',
325
+ 'rescanSupervisorPlugins',
326
+ 'rotateKey',
327
+ 'runSupervisorSurface',
328
+ 'saveKey',
329
+ 'saveLabel',
330
+ 'saveProject',
331
+ 'setExpiry',
332
+ 'submitMount',
333
+ 'toggleService',
334
+ 'wizSubmitCfToken',
335
+ ];
336
+
337
+ function registeredWriteActions(source) {
338
+ const literal = source.match(/const WRITE_ACTIONS = \[([\s\S]*?)\];/);
339
+ assert.ok(literal, 'WRITE_ACTIONS registry must exist in the dashboard script');
340
+ return new Set([...literal[1].matchAll(/"([A-Za-z0-9_$]+)"/g)].map((m) => m[1]));
341
+ }
342
+
343
+ /**
344
+ * Every top-level dashboard function whose body calls writeHeaders().
345
+ *
346
+ * Recognises const/let/var function-expression and arrow bindings as well as
347
+ * function declarations. Without that, converting a write action to
348
+ * `const foo = async () => {…}` would attribute its writeHeaders() call to the
349
+ * PRECEDING declaration — and when that neighbour is itself registered, the
350
+ * call silently lands on an already-present name and this test passes on a
351
+ * genuinely unguarded action.
352
+ *
353
+ * Comment lines are skipped: prose mentioning writeHeaders() is not a call
354
+ * site, and counting it as one produced a false "bypasses the choke point"
355
+ * failure the first time a doc comment landed after a function declaration.
356
+ */
357
+ function writeHeaderCallers(source) {
358
+ const callers = new Set();
359
+ let current = null;
360
+ for (const line of source.split(/\r?\n/)) {
361
+ const decl = line.match(/^(?:async\s+)?function\s+([A-Za-z0-9_$]+)\s*\(/);
362
+ const bound = line.match(
363
+ /^(?:const|let|var)\s+([A-Za-z0-9_$]+)\s*=\s*(?:async\s*)?(?:function\b|\(|[A-Za-z0-9_$]+\s*=>)/,
364
+ );
365
+ if (decl) current = decl[1];
366
+ else if (bound) current = bound[1];
367
+
368
+ const code = line.trim();
369
+ if (code.startsWith('//') || code.startsWith('*') || code.startsWith('/*')) continue;
370
+ if (line.includes('writeHeaders(') && current && current !== 'writeHeaders') {
371
+ callers.add(current);
372
+ }
373
+ }
374
+ return callers;
375
+ }
376
+
377
+ test('WRITE_ACTIONS matches the reviewed write-action surface exactly', () => {
378
+ const registered = [...registeredWriteActions(serveSource)].sort();
379
+ assert.deepEqual(
380
+ registered,
381
+ EXPECTED_WRITE_ACTIONS,
382
+ 'WRITE_ACTIONS changed — update EXPECTED_WRITE_ACTIONS deliberately, and confirm the new action is a function declaration',
383
+ );
384
+ });
385
+
386
+ test('WRITE_ACTIONS covers every function that calls writeHeaders()', () => {
387
+ const registered = registeredWriteActions(serveSource);
388
+ const callers = writeHeaderCallers(serveSource);
389
+
390
+ const unguarded = [...callers].filter((n) => !registered.has(n));
391
+ assert.deepEqual(unguarded, [], 'these write actions bypass the choke point: ' + unguarded.join(', '));
392
+
393
+ const stale = [...registered].filter((n) => !callers.has(n));
394
+ assert.deepEqual(stale, [], 'these registry entries no longer write: ' + stale.join(', '));
395
+
396
+ assert.equal(callers.size, registered.size);
397
+ });
398
+
399
+ test('every registered write action is actually wrapped at install time', () => {
400
+ const h = bootDashboard({ initWriteToken: 'wt-preexisting', route: unlockedRoute });
401
+ const registered = [...registeredWriteActions(serveSource)];
402
+
403
+ const notWrapped = registered.filter((name) => h.ctx[name]?.__writeGuarded !== true);
404
+ assert.deepEqual(
405
+ notWrapped,
406
+ [],
407
+ 'registered but not guarded in the live realm (a const/arrow binding cannot be guarded): ' + notWrapped.join(', '),
408
+ );
409
+ assert.equal(registered.length, EXPECTED_WRITE_ACTIONS.length);
410
+ });
411
+
412
+ test('installWriteAccessGuards throws on a registered name that cannot be guarded', () => {
413
+ assert.match(serveSource, /clauth write-guard install failed/);
414
+ assert.doesNotMatch(
415
+ serveSource,
416
+ /if \(typeof fn !== "function" \|\| fn\.__writeGuarded\) continue;/,
417
+ 'the old silent-skip guard must not come back',
418
+ );
419
+ });
420
+
421
+ test('the write-access choke point is a single mechanism, not per-call-site guards', () => {
422
+ const parkSites = [...serveSource.matchAll(/pendingWriteAction = \{/g)];
423
+ assert.equal(parkSites.length, 1, 'exactly one place may park a write action');
424
+ const acquireSites = [...serveSource.matchAll(/BASE \+ "\/write-token"/g)];
425
+ assert.equal(acquireSites.length, 1, 'exactly one place may acquire write access');
426
+ assert.match(serveSource, /function installWriteAccessGuards\(/);
427
+ });
428
+
429
+ // ── 4. Server contract: the gate stays, the prompt goes ──────────────────────
430
+
431
+ test('POST /write-token refuses a locked vault and never weakens the write gate', () => {
432
+ assert.match(serveSource, /reqPath === "\/write-token"/, 'the acquire route must exist');
433
+ assert.match(
434
+ serveSource,
435
+ /reqPath === "\/write-token"\)\s*\{\s*\n\s*if \(!password\) \{/,
436
+ 'a locked vault must be refused before any token is minted',
437
+ );
438
+ // writeGuard is what makes a page-driven write distinguishable from a blind
439
+ // remote POST arriving through the cloudflared tunnel as loopback. It stays.
440
+ assert.match(serveSource, /function writeGuard\(req, res\) \{[\s\S]*?validateWriteToken\(req, writeSession\)/);
441
+ assert.match(serveSource, /if \(!isLocal\) \{[\s\S]*?strike\(res, 403/, 'non-loopback must stay hard-rejected');
442
+ });
443
+
444
+ // ── Pre-existing coverage — lifecycle actions and receipt feedback ───────────
445
+
10
446
  test('clauth supervisor UI exposes every lifecycle action and receipt feedback', () => {
11
447
  for (const action of ['start', 'stop', 'restart', 'reconcile', 'test', 'promote', 'rollback']) {
12
448
  assert.match(serveSource, new RegExp('"' + action + '"'));
package/install.ps1 CHANGED
@@ -1,105 +1,105 @@
1
- # clauth installer - Windows
2
- # Private repo - run locally:
3
- # cd C:\Dev\clauth && git pull && .\install.ps1
4
-
5
- $ErrorActionPreference = "Stop"
6
- $REPO = "https://github.com/LIFEAI/clauth.git"
7
- $DIR = "$env:USERPROFILE\.clauth"
8
-
9
- # Check git
10
- try { git --version | Out-Null } catch {
11
- Write-Host ""
12
- Write-Host " x git is required." -ForegroundColor Red
13
- Write-Host " Install from https://git-scm.com then re-run this script."
14
- Write-Host ""
15
- exit 1
16
- }
17
-
18
- # Check Node
19
- try { node --version | Out-Null } catch {
20
- Write-Host ""
21
- Write-Host " x Node.js v18+ is required." -ForegroundColor Red
22
- Write-Host " Install from https://nodejs.org then re-run this script."
23
- Write-Host ""
24
- exit 1
25
- }
26
-
27
- # Check cloudflared (soft warning - not required for install)
28
- if (-not (Get-Command cloudflared -ErrorAction SilentlyContinue)) {
29
- Write-Host " Note: cloudflared not found. Install after setup for claude.ai web integration." -ForegroundColor Yellow
30
- Write-Host " winget install Cloudflare.cloudflared" -ForegroundColor Gray
31
- Write-Host ""
32
- }
33
-
34
- # Skip bootstrap if CLAUTH npm package is already installed globally
35
- $alreadyInstalled = $false
36
- try {
37
- $ver = & npm list -g @lifeaitools/clauth --depth=0 2>$null | Select-String "@lifeaitools/clauth"
38
- if ($ver) { $alreadyInstalled = $true }
39
- } catch { }
40
-
41
- if ($alreadyInstalled) {
42
- Write-Host " clauth already installed globally - skipping bootstrap." -ForegroundColor Green
43
- } else {
44
- # Clone or update
45
- if (Test-Path "$DIR\.git") {
46
- Write-Host " Updating clauth..."
47
- Set-Location $DIR; git pull --quiet
48
- } else {
49
- Write-Host " Cloning clauth..."
50
- git clone --quiet $REPO $DIR
51
- }
52
-
53
- # Run compiled bootstrap binary
54
- $bootstrap = "$DIR\scripts\bin\bootstrap-win.exe"
55
- if (-not (Test-Path $bootstrap)) {
56
- Write-Host " x Bootstrap binary not found at $bootstrap" -ForegroundColor Red
57
- exit 1
58
- }
59
-
60
- Set-Location $DIR
61
- & $bootstrap
62
- }
63
-
64
- # Register autostart - idempotent, runs every install/update
65
- # Primary: Task Scheduler (preferred - supports delay and OS-managed restarts)
66
- # Fallback: Startup folder shortcut (fully functional - autostart.ps1 has its own crash-recovery loop)
67
- $autostartScript = "$env:APPDATA\clauth\autostart.ps1"
68
- $taskName = "CLAUTH Daemon"
69
-
70
- if (Test-Path $autostartScript) {
71
- Write-Host ""
72
- Write-Host " Registering autostart..." -ForegroundColor Cyan
73
-
74
- $registered = $false
75
-
76
- # Try Task Scheduler first
77
- try {
78
- Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
79
- $action = New-ScheduledTaskAction -Execute "powershell.exe" `
80
- -Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$autostartScript`""
81
- $trigger = New-ScheduledTaskTrigger -AtLogOn
82
- $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
83
- Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
84
- -Settings $settings -RunLevel Limited -Force -ErrorAction Stop | Out-Null
85
- Write-Host " + Task Scheduler: '$taskName' registered (triggers at logon)." -ForegroundColor Green
86
- $registered = $true
87
- } catch { }
88
-
89
- # Fallback: Startup folder - valid solution, autostart.ps1 handles crash recovery internally
90
- if (-not $registered) {
91
- $startupDir = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
92
- $shortcut = "$startupDir\clauth-autostart.lnk"
93
- $wsh = New-Object -ComObject WScript.Shell
94
- $lnk = $wsh.CreateShortcut($shortcut)
95
- $lnk.TargetPath = "powershell.exe"
96
- # 15s delay baked in so profile is loaded before daemon starts
97
- $lnk.Arguments = "-WindowStyle Hidden -ExecutionPolicy Bypass -Command `"Start-Sleep 15; & '$autostartScript'`""
98
- $lnk.WorkingDirectory = "$env:APPDATA\clauth"
99
- $lnk.WindowStyle = 7
100
- $lnk.Save()
101
- Write-Host " + Autostart registered via Startup folder (15s delay, crash-recovery loop active)." -ForegroundColor Green
102
- }
1
+ # clauth installer - Windows
2
+ # Private repo - run locally:
3
+ # cd C:\Dev\clauth && git pull && .\install.ps1
4
+
5
+ $ErrorActionPreference = "Stop"
6
+ $REPO = "https://github.com/LIFEAI/clauth.git"
7
+ $DIR = "$env:USERPROFILE\.clauth"
8
+
9
+ # Check git
10
+ try { git --version | Out-Null } catch {
11
+ Write-Host ""
12
+ Write-Host " x git is required." -ForegroundColor Red
13
+ Write-Host " Install from https://git-scm.com then re-run this script."
14
+ Write-Host ""
15
+ exit 1
16
+ }
17
+
18
+ # Check Node
19
+ try { node --version | Out-Null } catch {
20
+ Write-Host ""
21
+ Write-Host " x Node.js v18+ is required." -ForegroundColor Red
22
+ Write-Host " Install from https://nodejs.org then re-run this script."
23
+ Write-Host ""
24
+ exit 1
25
+ }
26
+
27
+ # Check cloudflared (soft warning - not required for install)
28
+ if (-not (Get-Command cloudflared -ErrorAction SilentlyContinue)) {
29
+ Write-Host " Note: cloudflared not found. Install after setup for claude.ai web integration." -ForegroundColor Yellow
30
+ Write-Host " winget install Cloudflare.cloudflared" -ForegroundColor Gray
31
+ Write-Host ""
32
+ }
33
+
34
+ # Skip bootstrap if CLAUTH npm package is already installed globally
35
+ $alreadyInstalled = $false
36
+ try {
37
+ $ver = & npm list -g @lifeaitools/clauth --depth=0 2>$null | Select-String "@lifeaitools/clauth"
38
+ if ($ver) { $alreadyInstalled = $true }
39
+ } catch { }
40
+
41
+ if ($alreadyInstalled) {
42
+ Write-Host " clauth already installed globally - skipping bootstrap." -ForegroundColor Green
43
+ } else {
44
+ # Clone or update
45
+ if (Test-Path "$DIR\.git") {
46
+ Write-Host " Updating clauth..."
47
+ Set-Location $DIR; git pull --quiet
48
+ } else {
49
+ Write-Host " Cloning clauth..."
50
+ git clone --quiet $REPO $DIR
51
+ }
52
+
53
+ # Run compiled bootstrap binary
54
+ $bootstrap = "$DIR\scripts\bin\bootstrap-win.exe"
55
+ if (-not (Test-Path $bootstrap)) {
56
+ Write-Host " x Bootstrap binary not found at $bootstrap" -ForegroundColor Red
57
+ exit 1
58
+ }
59
+
60
+ Set-Location $DIR
61
+ & $bootstrap
62
+ }
63
+
64
+ # Register autostart - idempotent, runs every install/update
65
+ # Primary: Task Scheduler (preferred - supports delay and OS-managed restarts)
66
+ # Fallback: Startup folder shortcut (fully functional - autostart.ps1 has its own crash-recovery loop)
67
+ $autostartScript = "$env:APPDATA\clauth\autostart.ps1"
68
+ $taskName = "CLAUTH Daemon"
69
+
70
+ if (Test-Path $autostartScript) {
71
+ Write-Host ""
72
+ Write-Host " Registering autostart..." -ForegroundColor Cyan
73
+
74
+ $registered = $false
75
+
76
+ # Try Task Scheduler first
77
+ try {
78
+ Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
79
+ $action = New-ScheduledTaskAction -Execute "powershell.exe" `
80
+ -Argument "-WindowStyle Hidden -ExecutionPolicy Bypass -File `"$autostartScript`""
81
+ $trigger = New-ScheduledTaskTrigger -AtLogOn
82
+ $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit 0 -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1)
83
+ Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger `
84
+ -Settings $settings -RunLevel Limited -Force -ErrorAction Stop | Out-Null
85
+ Write-Host " + Task Scheduler: '$taskName' registered (triggers at logon)." -ForegroundColor Green
86
+ $registered = $true
87
+ } catch { }
88
+
89
+ # Fallback: Startup folder - valid solution, autostart.ps1 handles crash recovery internally
90
+ if (-not $registered) {
91
+ $startupDir = "$env:APPDATA\Microsoft\Windows\Start Menu\Programs\Startup"
92
+ $shortcut = "$startupDir\clauth-autostart.lnk"
93
+ $wsh = New-Object -ComObject WScript.Shell
94
+ $lnk = $wsh.CreateShortcut($shortcut)
95
+ $lnk.TargetPath = "powershell.exe"
96
+ # 15s delay baked in so profile is loaded before daemon starts
97
+ $lnk.Arguments = "-WindowStyle Hidden -ExecutionPolicy Bypass -Command `"Start-Sleep 15; & '$autostartScript'`""
98
+ $lnk.WorkingDirectory = "$env:APPDATA\clauth"
99
+ $lnk.WindowStyle = 7
100
+ $lnk.Save()
101
+ Write-Host " + Autostart registered via Startup folder (15s delay, crash-recovery loop active)." -ForegroundColor Green
102
+ }
103
103
  } else {
104
104
  Write-Host ""
105
105
  Write-Host " ! autostart.ps1 not found - skipping autostart registration." -ForegroundColor Yellow