@cordfuse/crosstalk 7.0.0-alpha.16 → 7.0.0-alpha.18

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.
Files changed (75) hide show
  1. package/GUIDE-CLI.md +322 -0
  2. package/GUIDE-PROMPTS.md +112 -0
  3. package/LICENSE +21 -0
  4. package/README.md +337 -62
  5. package/bin/crosstalk.js +88 -54
  6. package/commands/agent.js +69 -0
  7. package/commands/auth.js +273 -0
  8. package/commands/channel.js +54 -44
  9. package/commands/chat.js +107 -71
  10. package/commands/daemon.js +121 -0
  11. package/commands/logs.js +108 -19
  12. package/commands/message.js +125 -0
  13. package/commands/server.js +153 -0
  14. package/commands/settings.js +49 -0
  15. package/commands/token.js +136 -0
  16. package/commands/transport.js +272 -0
  17. package/commands/version.js +3 -3
  18. package/commands/workflow.js +234 -0
  19. package/deploy/crosstalk@.service +62 -0
  20. package/deploy/install.sh +82 -0
  21. package/lib/api-client.js +77 -22
  22. package/lib/credentials.js +207 -0
  23. package/lib/nativeServer.js +174 -0
  24. package/lib/resolve.js +95 -43
  25. package/package.json +27 -4
  26. package/src/activation.ts +104 -0
  27. package/src/api.ts +1716 -0
  28. package/src/auth/enforce.ts +68 -0
  29. package/src/auth/handlers.ts +266 -0
  30. package/src/auth/middleware.ts +132 -0
  31. package/src/auth/setup.ts +263 -0
  32. package/src/auth/tokens.ts +285 -0
  33. package/src/auth/users.ts +267 -0
  34. package/src/channel.ts +202 -0
  35. package/src/dispatch.ts +492 -0
  36. package/src/dispatchers.ts +91 -0
  37. package/src/filenames.ts +28 -0
  38. package/src/frontmatter.ts +26 -0
  39. package/src/init.ts +116 -0
  40. package/src/invoke.ts +201 -0
  41. package/src/log-buffer.ts +67 -0
  42. package/src/models.ts +283 -0
  43. package/src/replies.ts +73 -0
  44. package/src/resolve.ts +100 -0
  45. package/src/run.ts +250 -0
  46. package/src/state.ts +190 -0
  47. package/src/status.ts +84 -0
  48. package/src/stop.ts +37 -0
  49. package/src/transport.ts +243 -0
  50. package/src/web/auth-pages.ts +160 -0
  51. package/src/web/channels.ts +395 -0
  52. package/src/web/chat-page.ts +636 -0
  53. package/src/web/chat-pty.ts +238 -0
  54. package/src/web/dashboard.ts +129 -0
  55. package/src/web/layout.ts +237 -0
  56. package/src/web/stubs.ts +510 -0
  57. package/src/web/workflows.ts +490 -0
  58. package/src/workflow.ts +470 -0
  59. package/template/CLAUDE.md +10 -0
  60. package/template/CROSSTALK-VERSION +1 -0
  61. package/template/CROSSTALK.md +258 -0
  62. package/template/PROTOCOL.md +66 -0
  63. package/template/README.md +64 -0
  64. package/template/auth/.gitkeep +0 -0
  65. package/template/auth/README.md +224 -0
  66. package/template/data/crosstalk.yaml +196 -0
  67. package/template/gitignore +4 -0
  68. package/commands/down.js +0 -40
  69. package/commands/init.js +0 -241
  70. package/commands/pull.js +0 -22
  71. package/commands/replies.js +0 -40
  72. package/commands/restart.js +0 -29
  73. package/commands/rm.js +0 -109
  74. package/commands/run.js +0 -115
  75. package/commands/up.js +0 -133
package/src/status.ts ADDED
@@ -0,0 +1,84 @@
1
+ // crosstalkd status — transport + dispatcher health at a glance.
2
+
3
+ import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
4
+ import { resolve, join } from 'path';
5
+ import { discoverChannels } from './transport.js';
6
+ import { stateDir, readCursor, readHeartbeat, countErrors } from './state.js';
7
+ import { loadRegistry } from './models.js';
8
+
9
+ const transportRoot = resolve(process.cwd());
10
+
11
+ const versionFile = join(transportRoot, 'CROSSTALK-VERSION');
12
+ const protocolVersion = existsSync(versionFile)
13
+ ? readFileSync(versionFile, 'utf-8').trim()
14
+ : '(missing)';
15
+
16
+ console.log(`Crosstalk transport: ${transportRoot}`);
17
+ console.log(`Protocol version: ${protocolVersion}`);
18
+ console.log(`Machine state dir: ${stateDir(transportRoot)}`);
19
+ console.log('');
20
+
21
+ try {
22
+ const registry = loadRegistry(transportRoot);
23
+ console.log(`Models in registry: ${registry.all.size}`);
24
+ console.log(`Claimed on this machine: ${registry.claimed.size}`);
25
+ if (registry.claimed.size > 0) {
26
+ for (const name of registry.claimed.keys()) {
27
+ console.log(` - ${name}`);
28
+ }
29
+ } else {
30
+ console.log(' (no model CLIs found on PATH — install one or edit data/crosstalk.yaml)');
31
+ }
32
+ } catch (err) {
33
+ console.log(`Models: ERROR — ${(err as Error).message}`);
34
+ }
35
+
36
+ console.log('');
37
+
38
+ const hb = readHeartbeat(transportRoot);
39
+ if (hb) {
40
+ const ageS = Math.floor((Date.now() - new Date(hb.ts).getTime()) / 1000);
41
+ const label = ageS < 120 ? 'LIVE' : ageS > 300 ? 'STALE' : 'idle';
42
+ console.log(`Dispatch heartbeat: ${label} (pid ${hb.pid}, last tick ${ageS}s ago, ${hb.version})`);
43
+ } else {
44
+ console.log('Dispatch heartbeat: never (no dispatch has run on this machine)');
45
+ }
46
+
47
+ const cursor = readCursor(transportRoot);
48
+ if (cursor) {
49
+ console.log(`Cursor: ${cursor.slice(0, 12)} (global, machine-local)`);
50
+ } else {
51
+ console.log('Cursor: (none yet — first tick will seed to HEAD)');
52
+ }
53
+
54
+ console.log('');
55
+
56
+ const channels = discoverChannels(transportRoot);
57
+ console.log(`Channels: ${channels.length}`);
58
+ for (const ch of channels) {
59
+ const channelDir = join(transportRoot, 'data', 'channels', ch);
60
+ let name = '(no CHANNEL.md)';
61
+ let parent: string | null = null;
62
+ const chPath = join(channelDir, 'CHANNEL.md');
63
+ if (existsSync(chPath)) {
64
+ const raw = readFileSync(chPath, 'utf-8');
65
+ const nameMatch = raw.match(/^name:\s*(.+)$/m);
66
+ if (nameMatch) name = nameMatch[1]!.trim();
67
+ const parentMatch = raw.match(/^parent:\s*(.+)$/m);
68
+ if (parentMatch) parent = parentMatch[1]!.trim();
69
+ }
70
+ let msgCount = 0;
71
+ const walk = (d: string): void => {
72
+ for (const e of readdirSync(d)) {
73
+ const p = join(d, e);
74
+ if (statSync(p).isDirectory()) walk(p);
75
+ else if (e.endsWith('.md') && e !== 'CHANNEL.md') msgCount++;
76
+ }
77
+ };
78
+ try { walk(channelDir); } catch { /* ignore */ }
79
+ const parentSuffix = parent ? ` [child of ${parent.slice(0, 8)}]` : '';
80
+ console.log(` ${ch.slice(0, 8)}... — ${name} (${msgCount} msgs)${parentSuffix}`);
81
+ }
82
+
83
+ console.log('');
84
+ console.log(`Infrastructure errors logged: ${countErrors(transportRoot)}`);
package/src/stop.ts ADDED
@@ -0,0 +1,37 @@
1
+ // crosstalkd stop — send SIGTERM to the running dispatcher and wait for it to exit.
2
+
3
+ import { resolve } from 'path';
4
+ import { spawnSync } from 'child_process';
5
+ import { readPidfile, removePidfile } from './state.js';
6
+
7
+ const transportRoot = resolve(process.cwd());
8
+
9
+ function processRunning(pid: number): boolean {
10
+ try { process.kill(pid, 0); return true; } catch { return false; }
11
+ }
12
+
13
+ const pid = readPidfile(transportRoot);
14
+ if (pid === null) {
15
+ console.error('crosstalkd stop: no dispatcher.pid found — is dispatch running?');
16
+ process.exit(1);
17
+ }
18
+
19
+ if (!processRunning(pid)) {
20
+ console.error(`crosstalkd stop: pid ${pid} is not running — removing stale pidfile`);
21
+ removePidfile(transportRoot);
22
+ process.exit(1);
23
+ }
24
+
25
+ process.kill(pid, 'SIGTERM');
26
+
27
+ const deadline = Date.now() + 5_000;
28
+ while (Date.now() < deadline) {
29
+ if (!processRunning(pid)) {
30
+ console.log(`crosstalkd stop: dispatcher (pid ${pid}) stopped`);
31
+ process.exit(0);
32
+ }
33
+ spawnSync('sleep', ['0.1']);
34
+ }
35
+
36
+ console.error(`crosstalkd stop: pid ${pid} did not exit within 5s — try: kill -9 ${pid}`);
37
+ process.exit(1);
@@ -0,0 +1,243 @@
1
+ // Git transport layer. The dispatcher's commits contain ONLY data/ —
2
+ // machine-local state lives in the state dir (state.ts), so there is
3
+ // nothing to exclude, untrack, or heal. Push rejection means another
4
+ // machine won the race: pull --rebase and retry at the call site.
5
+
6
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { spawnSync } from 'child_process';
9
+ import { parseFrontmatter } from './frontmatter.js';
10
+ import { logError } from './state.js';
11
+
12
+ export interface ChannelMessage {
13
+ relPath: string;
14
+ fullPath: string;
15
+ data: Record<string, unknown>;
16
+ body: string;
17
+ }
18
+
19
+ export interface GitResult {
20
+ ok: boolean;
21
+ error?: string;
22
+ }
23
+
24
+ export interface GitPushResult {
25
+ ok: boolean;
26
+ committed: boolean;
27
+ pushed: boolean;
28
+ error?: string;
29
+ }
30
+
31
+ function captureGit(cwd: string, args: string[]): { status: number; stdout: string; stderr: string } {
32
+ const r = spawnSync('git', args, { cwd, encoding: 'utf-8' });
33
+ return { status: r.status ?? 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
34
+ }
35
+
36
+ // Detect and abort an interrupted rebase/merge left by a killed process.
37
+ // Returns true if recovery was performed.
38
+ export function recoverInterruptedGit(transportRoot: string): boolean {
39
+ const halfStates: { dir: string; abortArgs: string[] }[] = [
40
+ { dir: '.git/rebase-merge', abortArgs: ['rebase', '--abort'] },
41
+ { dir: '.git/rebase-apply', abortArgs: ['rebase', '--abort'] },
42
+ { dir: '.git/MERGE_HEAD', abortArgs: ['merge', '--abort'] },
43
+ { dir: '.git/CHERRY_PICK_HEAD', abortArgs: ['cherry-pick', '--abort'] },
44
+ ];
45
+ for (const { dir, abortArgs } of halfStates) {
46
+ if (existsSync(join(transportRoot, dir))) {
47
+ const r = captureGit(transportRoot, abortArgs);
48
+ logError(
49
+ transportRoot,
50
+ `recovered from interrupted git state at ${dir} via 'git ${abortArgs.join(' ')}' (exit=${r.status})`,
51
+ );
52
+ return true;
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+
58
+ // The commit cursors anchor to. Use local HEAD: gitPull (called first
59
+ // in dispatchTick) already rebased origin commits into HEAD, so HEAD
60
+ // includes both remote and local-unpushed work. Using origin/HEAD
61
+ // would have stranded the dispatcher whenever push broke (commits
62
+ // pile up on HEAD past stale origin/HEAD; cursor never advances past
63
+ // origin/HEAD; activation pass thinks nothing's new). Caught
64
+ // 2026-06-22 — push failed silently due to missing ssh key, workflows
65
+ // got stuck in FANOUT forever.
66
+ //
67
+ // origin/* fallback only kicks in when local HEAD doesn't resolve
68
+ // (truly empty repo, no branch checked out — defensive only).
69
+ export function cursorBaseline(transportRoot: string): string | null {
70
+ for (const ref of ['HEAD', 'origin/HEAD', 'origin/main']) {
71
+ const r = captureGit(transportRoot, ['rev-parse', ref]);
72
+ if (r.status === 0) return r.stdout.trim();
73
+ }
74
+ return null;
75
+ }
76
+
77
+ // Repo-relative paths of message files added between `sinceCommit` and
78
+ // HEAD. Returns null when the commit is unknown to this clone (state dir
79
+ // copied across transports, history rewritten) — caller falls back to a
80
+ // full channel scan.
81
+ export function newFilesSince(transportRoot: string, sinceCommit: string): string[] | null {
82
+ const r = captureGit(transportRoot, [
83
+ 'diff', '--name-only', '--diff-filter=A', `${sinceCommit}..HEAD`, '--', 'data/channels/',
84
+ ]);
85
+ if (r.status !== 0) return null;
86
+ return r.stdout.split('\n').filter(Boolean);
87
+ }
88
+
89
+ export function gitPull(transportRoot: string): GitResult {
90
+ recoverInterruptedGit(transportRoot);
91
+ const fetch = captureGit(transportRoot, ['fetch', 'origin', '--quiet']);
92
+ if (fetch.status !== 0) {
93
+ // No origin remote configured → not an error. Single-machine and
94
+ // local-only transports are valid; the dispatcher just operates on
95
+ // local commits.
96
+ const stderr = fetch.stderr.trim();
97
+ if (stderr.includes("does not appear to be a git repository") ||
98
+ stderr.includes("Could not read from remote") ||
99
+ stderr.includes("origin") && stderr.includes("does not appear")) {
100
+ return { ok: true };
101
+ }
102
+ return { ok: false, error: (fetch.stderr || fetch.stdout).trim().slice(0, 500) };
103
+ }
104
+ const rebase = captureGit(transportRoot, ['rebase', 'origin/main']);
105
+ if (rebase.status !== 0) {
106
+ // No origin/main (single-machine, no remote) → not an error.
107
+ if (rebase.stderr.includes('unknown revision') || rebase.stderr.includes('not a valid object name')) {
108
+ return { ok: true };
109
+ }
110
+ return { ok: false, error: (rebase.stderr || rebase.stdout).trim().slice(0, 500) };
111
+ }
112
+ return { ok: true };
113
+ }
114
+
115
+ // Stage data/ only, commit, push. On push rejection, one pull --rebase +
116
+ // re-push — collision-free filenames make the rebase trivially clean.
117
+ // In single-machine transports with no remote, the push step is a no-op
118
+ // (gracefully — git push fails fast on "No configured push destination",
119
+ // which we treat as success since the commit is already local).
120
+ export function gitCommitAndPush(transportRoot: string, message: string): GitPushResult {
121
+ const status = captureGit(transportRoot, ['status', '--porcelain', '--', 'data/']);
122
+ if (status.status !== 0) {
123
+ return { ok: false, committed: false, pushed: false, error: status.stderr.trim().slice(0, 500) };
124
+ }
125
+ if (!status.stdout.trim()) {
126
+ return { ok: true, committed: false, pushed: false };
127
+ }
128
+
129
+ const add = captureGit(transportRoot, ['add', '--', 'data/']);
130
+ if (add.status !== 0) {
131
+ return { ok: false, committed: false, pushed: false, error: add.stderr.trim().slice(0, 500) };
132
+ }
133
+
134
+ const commit = captureGit(transportRoot, ['commit', '-m', message, '--', 'data/']);
135
+ if (commit.status !== 0) {
136
+ const noop = commit.stdout.includes('nothing to commit') ||
137
+ commit.stderr.includes('nothing to commit');
138
+ if (noop) return { ok: true, committed: false, pushed: false };
139
+ return { ok: false, committed: false, pushed: false, error: commit.stderr.trim().slice(0, 500) };
140
+ }
141
+
142
+ // Push rejection is NORMAL under concurrent writers — git is the
143
+ // arbiter and collision-free filenames make every rebase clean. Retry
144
+ // with jitter; many writers racing one origin converge within a few
145
+ // rounds. v6 used turnq as an advisory lock to reduce churn; v7 dropped
146
+ // it — rebase-retry is the correctness mechanism, turnq was overhead.
147
+ let push = captureGit(transportRoot, ['push', '--quiet']);
148
+ if (push.status !== 0) {
149
+ // No remote configured → push fails with "No configured push destination".
150
+ // Treat as success: commits stay local, which is the intended state
151
+ // for single-machine transports.
152
+ if (push.stderr.includes('No configured push destination') ||
153
+ push.stderr.includes("does not appear to be a git repository")) {
154
+ return { ok: true, committed: true, pushed: false };
155
+ }
156
+ }
157
+ for (let attempt = 0; push.status !== 0 && attempt < 5; attempt++) {
158
+ spawnSync('sleep', [(0.05 + Math.random() * 0.3 * (attempt + 1)).toFixed(2)]);
159
+ const pull = gitPull(transportRoot);
160
+ if (!pull.ok) continue;
161
+ push = captureGit(transportRoot, ['push', '--quiet']);
162
+ }
163
+ if (push.status !== 0) {
164
+ return {
165
+ ok: false,
166
+ committed: true,
167
+ pushed: false,
168
+ error: (push.stderr || push.stdout).trim().slice(0, 500),
169
+ };
170
+ }
171
+ return { ok: true, committed: true, pushed: true };
172
+ }
173
+
174
+ export function discoverChannels(transportRoot: string): string[] {
175
+ const channelsDir = join(transportRoot, 'data', 'channels');
176
+ if (!existsSync(channelsDir)) return [];
177
+ let entries: string[];
178
+ try {
179
+ entries = readdirSync(channelsDir);
180
+ } catch (err) {
181
+ logError(transportRoot, `discoverChannels readdir failed on ${channelsDir}: ${(err as Error).message}`);
182
+ return [];
183
+ }
184
+ return entries.filter((name) => {
185
+ try {
186
+ return statSync(join(channelsDir, name)).isDirectory();
187
+ } catch {
188
+ return false;
189
+ }
190
+ });
191
+ }
192
+
193
+ // v7 frontmatter contract (see transport/CROSSTALK.md §Messages):
194
+ // required: from (string), to (string), timestamp (string)
195
+ // optional: re, as, type ('workflow' only), failed, error, child_channel
196
+ // v6 required `type: text` on every message; v7 omits `type:` on regular
197
+ // messages. Readers must ignore UNKNOWN fields, but known fields with
198
+ // invalid values are protocol violations and rejected at parse time.
199
+ // This catches LLM models that revert to v6 muscle memory (writing
200
+ // `type: text` in hand-crafted frontmatter) — a real failure mode
201
+ // surfaced by the S10 fan-out workflow test.
202
+ function isValidMessageFrontmatter(data: Record<string, unknown>): boolean {
203
+ if (typeof data['from'] !== 'string') return false;
204
+ if (typeof data['to'] !== 'string' && !Array.isArray(data['to'])) return false;
205
+ if (typeof data['timestamp'] !== 'string') return false;
206
+ // `type:` is optional; only 'workflow' is valid in v7. Reject v6's
207
+ // `type: text` and any other invalid value.
208
+ if (data['type'] !== undefined && data['type'] !== 'workflow') return false;
209
+ return true;
210
+ }
211
+
212
+ export function listChannelMessages(transportRoot: string, channelUuid: string): ChannelMessage[] {
213
+ const channelDir = join(transportRoot, 'data', 'channels', channelUuid);
214
+ if (!existsSync(channelDir)) return [];
215
+ const results: ChannelMessage[] = [];
216
+ const walk = (dir: string, prefix: string): void => {
217
+ for (const entry of readdirSync(dir)) {
218
+ const full = join(dir, entry);
219
+ const rel = prefix ? `${prefix}/${entry}` : entry;
220
+ let stat;
221
+ try { stat = statSync(full); } catch { continue; }
222
+ if (stat.isDirectory()) {
223
+ walk(full, rel);
224
+ } else if (entry.endsWith('.md') && entry !== 'CHANNEL.md') {
225
+ const raw = readFileSync(full, 'utf-8');
226
+ let parsed;
227
+ try {
228
+ parsed = parseFrontmatter(raw);
229
+ } catch (err) {
230
+ logError(transportRoot, `frontmatter parse failed in ${channelUuid}/${rel}: ${(err as Error).message}`, 'warn');
231
+ continue;
232
+ }
233
+ if (!isValidMessageFrontmatter(parsed.data)) {
234
+ logError(transportRoot, `invalid message frontmatter in ${channelUuid}/${rel}: missing required field(s) (from, to, timestamp)`, 'warn');
235
+ continue;
236
+ }
237
+ results.push({ relPath: rel, fullPath: full, data: parsed.data, body: parsed.body });
238
+ }
239
+ }
240
+ };
241
+ walk(channelDir, '');
242
+ return results.sort((a, b) => a.relPath.localeCompare(b.relPath));
243
+ }
@@ -0,0 +1,160 @@
1
+ // Setup + login web pages for crosstalk's v8 auth.
2
+ //
3
+ // Visual consistency with the rest of the web UI (page() in layout.ts).
4
+ // Ported from @cordfuse/llmux v2/web/setup.ts + login.ts with the chrome
5
+ // adapted to crosstalk's layout helper.
6
+
7
+ import { page, escapeHtml, TOAST_HELPER } from './layout.js';
8
+
9
+ export interface SetupPageData {
10
+ setupToken: string;
11
+ host: string;
12
+ alias: string;
13
+ }
14
+
15
+ export function setupPage(d: SetupPageData): string {
16
+ return page({
17
+ title: `crosstalk on ${d.host} · First-run setup`,
18
+ host: d.host,
19
+ alias: d.alias,
20
+ pageTitle: 'First-run setup',
21
+ extraCss: `
22
+ .centered-card{max-width:480px;margin:24px auto 0}
23
+ .footer-note{margin-top:18px;color:#7a7f87;font-size:11px;text-align:center;line-height:1.6}
24
+ .footer-note code{color:#c9d1d9;background:#0b0c10;border:1px solid #1f2329;padding:1px 6px;border-radius:3px}
25
+ .field .help{font-size:11px;color:#7a7f87;line-height:1.4}
26
+ .field .err{font-size:11px;color:#f85149;line-height:1.4}
27
+ `,
28
+ body: `
29
+ <div class="centered-card">
30
+ <div class="card">
31
+ <h3>Set the operator passphrase</h3>
32
+ <p class="sub">This account becomes the engine admin. Pick a passphrase you'll remember — there's no email recovery.</p>
33
+ <form id="setup-form" autocomplete="off" novalidate>
34
+ <input type="hidden" name="setupToken" value="${escapeHtml(d.setupToken)}">
35
+ <div class="field">
36
+ <label for="su-name">Display name</label>
37
+ <input id="su-name" name="name" type="text" autocomplete="name" required>
38
+ </div>
39
+ <div class="field">
40
+ <label for="su-username">Username</label>
41
+ <input id="su-username" name="username" type="text" pattern="[a-z0-9_-]+" maxlength="32" required>
42
+ <span class="help">Lowercase letters, digits, dashes, underscores. Application-layer only — no OS user required.</span>
43
+ </div>
44
+ <div class="field">
45
+ <label for="su-passphrase">Passphrase</label>
46
+ <input id="su-passphrase" name="passphrase" type="password" minlength="8" autocomplete="new-password" required>
47
+ <span class="help">Minimum 8 characters.</span>
48
+ </div>
49
+ <div class="field">
50
+ <label for="su-passphrase2">Confirm passphrase</label>
51
+ <input id="su-passphrase2" name="passphrase2" type="password" minlength="8" autocomplete="new-password" required>
52
+ <span class="err" id="su-pp-err" hidden>Passphrases don't match.</span>
53
+ </div>
54
+ <div class="actions">
55
+ <button type="submit" class="primary" id="su-submit">Create admin account</button>
56
+ </div>
57
+ </form>
58
+ </div>
59
+ <p class="footer-note">The engine's HTTP API stays open on localhost without auth (matches the v7 model — operators on the host are trusted). Only the web UI gates non-public pages behind this account.</p>
60
+ </div>`,
61
+ inlineScript: `${TOAST_HELPER}
62
+ (() => {
63
+ const form = document.getElementById('setup-form');
64
+ const pp = document.getElementById('su-passphrase');
65
+ const pp2 = document.getElementById('su-passphrase2');
66
+ const err = document.getElementById('su-pp-err');
67
+ const submit = document.getElementById('su-submit');
68
+ pp2.addEventListener('input', () => { err.hidden = !pp2.value || pp.value === pp2.value; });
69
+ form.addEventListener('submit', async (ev) => {
70
+ ev.preventDefault();
71
+ if (pp.value !== pp2.value) { err.hidden = false; pp2.focus(); return; }
72
+ submit.disabled = true;
73
+ const fd = new FormData(form);
74
+ const body = { setupToken: fd.get('setupToken'), name: fd.get('name'), username: fd.get('username'), passphrase: fd.get('passphrase') };
75
+ try {
76
+ const r = await fetch('/api/setup', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
77
+ const data = await r.json().catch(() => ({}));
78
+ if (r.ok && data.ok) {
79
+ window.showToast('Setup complete — redirecting.', 'ok');
80
+ setTimeout(() => location.assign('/'), 600);
81
+ } else {
82
+ window.showToast(data.error || ('setup failed (' + r.status + ')'), 'err');
83
+ submit.disabled = false;
84
+ }
85
+ } catch (e) {
86
+ window.showToast('network error: ' + e.message, 'err');
87
+ submit.disabled = false;
88
+ }
89
+ });
90
+ })();`,
91
+ });
92
+ }
93
+
94
+ export interface LoginPageData {
95
+ host: string;
96
+ alias: string;
97
+ returnTo?: string;
98
+ }
99
+
100
+ export function loginPage(d: LoginPageData): string {
101
+ const returnToSafe = d.returnTo ? escapeHtml(d.returnTo) : '/';
102
+ return page({
103
+ title: `crosstalk on ${d.host} · Sign in`,
104
+ host: d.host,
105
+ alias: d.alias,
106
+ pageTitle: 'Sign in',
107
+ extraCss: `
108
+ .centered-card{max-width:420px;margin:48px auto 0}
109
+ .footer-note{margin-top:18px;color:#7a7f87;font-size:11px;text-align:center;line-height:1.6}
110
+ .footer-note code{color:#c9d1d9;background:#0b0c10;border:1px solid #1f2329;padding:1px 6px;border-radius:3px}
111
+ `,
112
+ body: `
113
+ <div class="centered-card">
114
+ <div class="card">
115
+ <h3>Sign in</h3>
116
+ <form id="login-form" autocomplete="on" novalidate>
117
+ <input type="hidden" name="returnTo" value="${returnToSafe}">
118
+ <div class="field">
119
+ <label for="li-username">Username</label>
120
+ <input id="li-username" name="username" type="text" autocomplete="username" pattern="[a-z0-9_-]+" required autofocus>
121
+ </div>
122
+ <div class="field">
123
+ <label for="li-passphrase">Passphrase</label>
124
+ <input id="li-passphrase" name="passphrase" type="password" autocomplete="current-password" minlength="8" required>
125
+ </div>
126
+ <div class="actions">
127
+ <button type="submit" class="primary" id="li-submit">Sign in</button>
128
+ </div>
129
+ </form>
130
+ </div>
131
+ <p class="footer-note">Forgot your passphrase? Delete <code>users.json</code> from the transport state dir and re-run setup. (Yes, that wipes the operator account; no email recovery on this tier.)</p>
132
+ </div>`,
133
+ inlineScript: `${TOAST_HELPER}
134
+ (() => {
135
+ const form = document.getElementById('login-form');
136
+ const submit = document.getElementById('li-submit');
137
+ form.addEventListener('submit', async (ev) => {
138
+ ev.preventDefault();
139
+ submit.disabled = true;
140
+ const fd = new FormData(form);
141
+ const body = { username: fd.get('username'), passphrase: fd.get('passphrase') };
142
+ try {
143
+ const r = await fetch('/api/auth/login', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(body) });
144
+ const data = await r.json().catch(() => ({}));
145
+ if (r.ok && data.token) {
146
+ window.showToast('Signed in — redirecting.', 'ok');
147
+ const dest = fd.get('returnTo') || '/';
148
+ setTimeout(() => location.assign(dest), 400);
149
+ } else {
150
+ window.showToast(data.error || ('sign-in failed (' + r.status + ')'), 'err');
151
+ submit.disabled = false;
152
+ }
153
+ } catch (e) {
154
+ window.showToast('network error: ' + e.message, 'err');
155
+ submit.disabled = false;
156
+ }
157
+ });
158
+ })();`,
159
+ });
160
+ }