@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.
- package/GUIDE-CLI.md +322 -0
- package/GUIDE-PROMPTS.md +112 -0
- package/LICENSE +21 -0
- package/README.md +337 -62
- package/bin/crosstalk.js +88 -54
- package/commands/agent.js +69 -0
- package/commands/auth.js +273 -0
- package/commands/channel.js +54 -44
- package/commands/chat.js +107 -71
- package/commands/daemon.js +121 -0
- package/commands/logs.js +108 -19
- package/commands/message.js +125 -0
- package/commands/server.js +153 -0
- package/commands/settings.js +49 -0
- package/commands/token.js +136 -0
- package/commands/transport.js +272 -0
- package/commands/version.js +3 -3
- package/commands/workflow.js +234 -0
- package/deploy/crosstalk@.service +62 -0
- package/deploy/install.sh +82 -0
- package/lib/api-client.js +77 -22
- package/lib/credentials.js +207 -0
- package/lib/nativeServer.js +174 -0
- package/lib/resolve.js +95 -43
- package/package.json +27 -4
- package/src/activation.ts +104 -0
- package/src/api.ts +1716 -0
- package/src/auth/enforce.ts +68 -0
- package/src/auth/handlers.ts +266 -0
- package/src/auth/middleware.ts +132 -0
- package/src/auth/setup.ts +263 -0
- package/src/auth/tokens.ts +285 -0
- package/src/auth/users.ts +267 -0
- package/src/channel.ts +202 -0
- package/src/dispatch.ts +492 -0
- package/src/dispatchers.ts +91 -0
- package/src/filenames.ts +28 -0
- package/src/frontmatter.ts +26 -0
- package/src/init.ts +116 -0
- package/src/invoke.ts +201 -0
- package/src/log-buffer.ts +67 -0
- package/src/models.ts +283 -0
- package/src/replies.ts +73 -0
- package/src/resolve.ts +100 -0
- package/src/run.ts +250 -0
- package/src/state.ts +190 -0
- package/src/status.ts +84 -0
- package/src/stop.ts +37 -0
- package/src/transport.ts +243 -0
- package/src/web/auth-pages.ts +160 -0
- package/src/web/channels.ts +395 -0
- package/src/web/chat-page.ts +636 -0
- package/src/web/chat-pty.ts +238 -0
- package/src/web/dashboard.ts +129 -0
- package/src/web/layout.ts +237 -0
- package/src/web/stubs.ts +510 -0
- package/src/web/workflows.ts +490 -0
- package/src/workflow.ts +470 -0
- package/template/CLAUDE.md +10 -0
- package/template/CROSSTALK-VERSION +1 -0
- package/template/CROSSTALK.md +258 -0
- package/template/PROTOCOL.md +66 -0
- package/template/README.md +64 -0
- package/template/auth/.gitkeep +0 -0
- package/template/auth/README.md +224 -0
- package/template/data/crosstalk.yaml +196 -0
- package/template/gitignore +4 -0
- package/commands/down.js +0 -40
- package/commands/init.js +0 -241
- package/commands/pull.js +0 -22
- package/commands/replies.js +0 -40
- package/commands/restart.js +0 -29
- package/commands/rm.js +0 -109
- package/commands/run.js +0 -115
- 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);
|
package/src/transport.ts
ADDED
|
@@ -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
|
+
}
|