threadroom-pi 0.1.0-beta.0
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/LICENSE +21 -0
- package/README.md +110 -0
- package/extensions/README.md +15 -0
- package/extensions/index.ts +280 -0
- package/extensions/native/index.ts +614 -0
- package/extensions/native/presentation.ts +30 -0
- package/extensions/native/receipt.ts +25 -0
- package/extensions/native/ui.ts +167 -0
- package/extensions/presentation/renderers.ts +185 -0
- package/extensions/questions/README.md +41 -0
- package/extensions/questions/compose.ts +82 -0
- package/extensions/questions/external-editor.ts +24 -0
- package/extensions/questions/host.ts +415 -0
- package/extensions/questions/index.ts +6 -0
- package/extensions/questions/model.ts +175 -0
- package/extensions/questions/stream.ts +299 -0
- package/extensions/questions/text.ts +10 -0
- package/extensions/questions/tool.ts +309 -0
- package/extensions/questions/types.ts +40 -0
- package/extensions/questions/view.ts +221 -0
- package/node_modules/threadroom-service/README.md +73 -0
- package/node_modules/threadroom-service/bin/threadroom-service.js +9 -0
- package/node_modules/threadroom-service/dist/public/app.js +349 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-bloom.svg +17 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-drift.svg +16 -0
- package/node_modules/threadroom-service/dist/public/assets/mist-prowler.svg +15 -0
- package/node_modules/threadroom-service/dist/public/client.js +30 -0
- package/node_modules/threadroom-service/dist/public/index.html +54 -0
- package/node_modules/threadroom-service/dist/public/presentations.js +194 -0
- package/node_modules/threadroom-service/dist/public/routes.js +15 -0
- package/node_modules/threadroom-service/dist/public/styles.css +263 -0
- package/node_modules/threadroom-service/dist/src/live.js +170 -0
- package/node_modules/threadroom-service/dist/src/main.js +33 -0
- package/node_modules/threadroom-service/dist/src/presentations.js +116 -0
- package/node_modules/threadroom-service/dist/src/server.js +143 -0
- package/node_modules/threadroom-service/dist/src/site.js +53 -0
- package/node_modules/threadroom-service/dist/src/store.js +459 -0
- package/node_modules/threadroom-service/dist/src/ui-main.js +14 -0
- package/node_modules/threadroom-service/lib/cli.js +188 -0
- package/node_modules/threadroom-service/lib/ensure.js +157 -0
- package/node_modules/threadroom-service/lib/paths.js +19 -0
- package/node_modules/threadroom-service/package.json +19 -0
- package/package.json +50 -0
- package/scripts/stage-service.js +32 -0
- package/scripts/verify-packed.js +85 -0
- package/scripts/verify-release.js +79 -0
- package/src/client.js +135 -0
- package/src/config.js +57 -0
- package/src/http-transport.js +44 -0
- package/src/participation.js +211 -0
- package/src/service-runtime.js +43 -0
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import { mkdirSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve, sep } from 'node:path';
|
|
4
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
|
|
8
|
+
const now = () => new Date().toISOString();
|
|
9
|
+
const parse = (value, fallback = null) => value ? JSON.parse(value) : fallback;
|
|
10
|
+
const hashJson = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
11
|
+
const canonicalJson = (value) => {
|
|
12
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
13
|
+
if (value && typeof value === 'object') {
|
|
14
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalJson(value[key])]));
|
|
15
|
+
}
|
|
16
|
+
return value;
|
|
17
|
+
};
|
|
18
|
+
const fingerprint = (value) => hashJson(canonicalJson(value));
|
|
19
|
+
// request_hash existed before canonical fingerprints. Accept the legacy hash
|
|
20
|
+
// for an unchanged retry so upgrading does not invalidate durable receipts.
|
|
21
|
+
const legacyFingerprint = hashJson;
|
|
22
|
+
const assetsDir = fileURLToPath(new URL('../public/assets/', import.meta.url));
|
|
23
|
+
|
|
24
|
+
// One node primitive. Requests, response context, and authored content are independent capabilities.
|
|
25
|
+
export class ThreadStore {
|
|
26
|
+
constructor(filename) {
|
|
27
|
+
this.changes = new EventEmitter();
|
|
28
|
+
this.changes.setMaxListeners(0);
|
|
29
|
+
if (filename !== ':memory:') mkdirSync(dirname(filename), { recursive: true });
|
|
30
|
+
this.db = new DatabaseSync(filename);
|
|
31
|
+
this.db.exec('PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL;');
|
|
32
|
+
this.db.exec(`
|
|
33
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
34
|
+
id TEXT PRIMARY KEY,
|
|
35
|
+
parent_id TEXT REFERENCES nodes(id),
|
|
36
|
+
expects_answer INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
title TEXT NOT NULL,
|
|
38
|
+
body TEXT NOT NULL DEFAULT '',
|
|
39
|
+
author_json TEXT NOT NULL,
|
|
40
|
+
status TEXT,
|
|
41
|
+
presentation_json TEXT,
|
|
42
|
+
response_json TEXT,
|
|
43
|
+
idempotency_key TEXT UNIQUE,
|
|
44
|
+
request_hash TEXT,
|
|
45
|
+
created_at TEXT NOT NULL,
|
|
46
|
+
updated_at TEXT NOT NULL
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS nodes_parent_idx ON nodes(parent_id, created_at);
|
|
49
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
50
|
+
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
51
|
+
id TEXT NOT NULL UNIQUE,
|
|
52
|
+
type TEXT NOT NULL,
|
|
53
|
+
thread_id TEXT NOT NULL,
|
|
54
|
+
payload_json TEXT NOT NULL,
|
|
55
|
+
created_at TEXT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
`);
|
|
58
|
+
this.#migrateNodeKinds();
|
|
59
|
+
this.#migrateLegacy();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
close() { this.db.close(); }
|
|
63
|
+
|
|
64
|
+
listNodes() {
|
|
65
|
+
return this.db.prepare('SELECT * FROM nodes ORDER BY created_at, rowid').all().map((row) => this.#node(row));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
getNode(nodeId) {
|
|
69
|
+
const row = this.db.prepare('SELECT * FROM nodes WHERE id = ?').get(nodeId);
|
|
70
|
+
if (!row) throw new NotFoundError('node not found');
|
|
71
|
+
const node = this.#node(row);
|
|
72
|
+
const ancestors = [];
|
|
73
|
+
let parentId = row.parent_id;
|
|
74
|
+
while (parentId) {
|
|
75
|
+
const parent = this.db.prepare('SELECT * FROM nodes WHERE id = ?').get(parentId);
|
|
76
|
+
if (!parent) break;
|
|
77
|
+
// Navigation context stays bounded: don't echo every ancestor's HTML/images back to a publisher.
|
|
78
|
+
ancestors.unshift({ id: parent.id, parentId: parent.parent_id, title: parent.title,
|
|
79
|
+
author: parse(parent.author_json, {}), expectsAnswer: !!parent.expects_answer, status: parent.status });
|
|
80
|
+
parentId = parent.parent_id;
|
|
81
|
+
}
|
|
82
|
+
const children = this.db.prepare('SELECT * FROM nodes WHERE parent_id = ? ORDER BY created_at, rowid').all(nodeId).map((child) => this.#node(child));
|
|
83
|
+
return { node, ancestors, children, counts: this.#counts(nodeId), url: `/threads/${encodeURIComponent(nodeId)}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
createNode(input, key) {
|
|
87
|
+
const normalized = this.#normalizeInput(input);
|
|
88
|
+
const requestHash = fingerprint(normalized);
|
|
89
|
+
// Previously published receipts survive both canonical hashing and the
|
|
90
|
+
// removal of the structural kind field.
|
|
91
|
+
const { expectsAnswer, ...content } = normalized;
|
|
92
|
+
const oldHashes = (expectsAnswer ? ['question'] : ['thread', 'note'])
|
|
93
|
+
.flatMap((kind) => [fingerprint({ kind, ...content }), legacyFingerprint({ kind, ...content })]);
|
|
94
|
+
const existing = this.#existing(key, requestHash, legacyFingerprint(normalized), ...oldHashes);
|
|
95
|
+
if (existing) return { ...this.getNode(existing.id), createdAncestorIds: [], deduplicated: true };
|
|
96
|
+
let createdAncestorIds = [];
|
|
97
|
+
let id;
|
|
98
|
+
this.#atomic(() => {
|
|
99
|
+
let parentId = normalized.parentId || null;
|
|
100
|
+
if (normalized.path) {
|
|
101
|
+
const resolved = this.#resolvePath(normalized.path, normalized.author);
|
|
102
|
+
parentId = resolved.parentId;
|
|
103
|
+
createdAncestorIds = resolved.createdIds;
|
|
104
|
+
}
|
|
105
|
+
if (parentId) this.#require(parentId);
|
|
106
|
+
id = this.#insert({ ...normalized, presentation: normalized.presentation ? this.#presentation(normalized.presentation) : null, parentId, key, requestHash });
|
|
107
|
+
this.#event('node.created', id, { nodeId: id, parentId, createdAncestorIds });
|
|
108
|
+
});
|
|
109
|
+
return { ...this.getNode(id), createdAncestorIds, deduplicated: false };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
respond(nodeId, input, key) {
|
|
113
|
+
const target = this.#require(nodeId);
|
|
114
|
+
const kind = input.kind || 'answer';
|
|
115
|
+
if (!['answer', 'clarification', 'defer', 'reject', 'team_reply'].includes(kind)) throw new InputError('invalid response kind');
|
|
116
|
+
const body = text(input.body, 'body', false);
|
|
117
|
+
const selections = Array.isArray(input.selections) ? input.selections : [];
|
|
118
|
+
if (kind !== 'defer' && !body && selections.length === 0 && !input.presentation) throw new InputError('a response needs feedback, a selection, or an authored presentation');
|
|
119
|
+
if (['clarification', 'reject', 'team_reply'].includes(kind) && !body) throw new InputError(`${kind} needs written context`);
|
|
120
|
+
const author = input.author || { name: kind === 'team_reply' ? 'AI teammate' : 'Scott' };
|
|
121
|
+
const expectsAnswer = answerRequest(input);
|
|
122
|
+
const requestedTitle = text(input.title, 'title', false);
|
|
123
|
+
const title = requestedTitle || body.split('\n')[0].slice(0, 100) || selections.map((selection) => selection.label || selection.id).join(', ') || (kind === 'defer' ? 'Deferred for later' : 'Authored response');
|
|
124
|
+
const content = { nodeId, kind, body, selections, author, presentation: input.presentation || null };
|
|
125
|
+
const normalized = { ...content, expectsAnswer, title };
|
|
126
|
+
const existing = this.#existing(key, fingerprint(normalized), legacyFingerprint(normalized),
|
|
127
|
+
fingerprint(content), legacyFingerprint(content),
|
|
128
|
+
!expectsAnswer ? `legacy:${fingerprint({ nodeId, kind, body, selections })}` : null,
|
|
129
|
+
!expectsAnswer ? `legacy:${legacyFingerprint({ nodeId, kind, body, selections })}` : null);
|
|
130
|
+
if (existing) {
|
|
131
|
+
if (!!existing.expects_answer !== expectsAnswer || (requestedTitle && existing.title !== title)) throw new ConflictError('Idempotency-Key was already used for different content');
|
|
132
|
+
return { ...this.getNode(existing.id), responseId: existing.id, deduplicated: true, target: this.#node(this.#require(nodeId)) };
|
|
133
|
+
}
|
|
134
|
+
if (kind === 'team_reply' && target.status !== 'waiting_on_team') throw new InputError('team_reply targets a question waiting on the team');
|
|
135
|
+
const nextStatus = { answer: 'answered', clarification: 'waiting_on_team', defer: 'deferred', reject: 'rejected', team_reply: 'outstanding' }[kind];
|
|
136
|
+
const revision = parse(target.presentation_json)?.revision || null;
|
|
137
|
+
let responseId;
|
|
138
|
+
this.#atomic(() => {
|
|
139
|
+
responseId = this.#insert({
|
|
140
|
+
parentId: nodeId, expectsAnswer,
|
|
141
|
+
title, body, author, presentation: input.presentation ? this.#presentation(input.presentation) : null,
|
|
142
|
+
response: { kind, selections, presentationRevision: revision, targetId: nodeId },
|
|
143
|
+
key, requestHash: fingerprint(normalized)
|
|
144
|
+
});
|
|
145
|
+
if (target.expects_answer) this.db.prepare('UPDATE nodes SET status = ?, updated_at = ? WHERE id = ?').run(nextStatus, now(), nodeId);
|
|
146
|
+
this.#event('response.created', this.#legacyThreadId(nodeId), { nodeId: responseId, parentId: nodeId, threadId: this.#legacyThreadId(nodeId), questionId: nodeId, responseId, kind, questionStatus: target.expects_answer ? nextStatus : null });
|
|
147
|
+
});
|
|
148
|
+
return { ...this.getNode(responseId), responseId, deduplicated: false, target: this.#node(this.#require(nodeId)) };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// First-spike adapters use the same authority. No project/question hierarchy is imposed on nodes.
|
|
152
|
+
createThread(input) {
|
|
153
|
+
routableId(input, 'thread id');
|
|
154
|
+
const title = text(input.title, 'title');
|
|
155
|
+
const project = text(input.project, 'project');
|
|
156
|
+
const questions = input.questions?.length ? input.questions : [input.question];
|
|
157
|
+
for (const question of questions) {
|
|
158
|
+
routableId(question, 'question id');
|
|
159
|
+
text(question?.prompt, 'question prompt');
|
|
160
|
+
}
|
|
161
|
+
let id;
|
|
162
|
+
this.#atomic(() => {
|
|
163
|
+
const { parentId } = this.#resolvePath([project], input.author);
|
|
164
|
+
id = this.#insert({ id: input.id, title, parentId, body: input.summary || '', author: input.author });
|
|
165
|
+
const questionIds = questions.map((question) => this.#insert({
|
|
166
|
+
id: question.id, parentId: id, expectsAnswer: true, title: question.prompt, body: question.context || '',
|
|
167
|
+
author: input.author, presentation: this.#presentation(question.presentation || {})
|
|
168
|
+
}));
|
|
169
|
+
this.#event('thread.created', id, { threadId: id, questionIds });
|
|
170
|
+
});
|
|
171
|
+
return this.getThread(id);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
addQuestion(threadId, input) {
|
|
175
|
+
this.#require(threadId);
|
|
176
|
+
routableId(input, 'question id');
|
|
177
|
+
text(input.prompt, 'prompt');
|
|
178
|
+
this.#atomic(() => {
|
|
179
|
+
const id = this.#insert({ id: input.id, parentId: threadId, expectsAnswer: true, title: input.prompt,
|
|
180
|
+
body: input.context || '', author: this.getNode(threadId).node.author, presentation: this.#presentation(input.presentation || {}) });
|
|
181
|
+
this.#event('question.created', threadId, { threadId, questionId: id });
|
|
182
|
+
});
|
|
183
|
+
return this.getThread(threadId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
addResponse(questionId, input, key) {
|
|
187
|
+
const result = this.respond(questionId, input, key);
|
|
188
|
+
return { thread: this.getThread(this.#legacyThreadId(questionId)), responseId: result.responseId, deduplicated: result.deduplicated };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
getThread(threadId) {
|
|
192
|
+
const { node, ancestors, children } = this.getNode(threadId);
|
|
193
|
+
const questions = children.filter((child) => child.expectsAnswer).map((question) => ({
|
|
194
|
+
id: question.id, prompt: question.title, context: question.body, status: question.status,
|
|
195
|
+
presentation: question.presentation, createdAt: question.createdAt,
|
|
196
|
+
responses: this.getNode(question.id).children.filter((child) => child.response).map((response) => ({
|
|
197
|
+
id: response.id, questionId: question.id, kind: response.response.kind, body: response.body,
|
|
198
|
+
selections: response.response.selections, presentationRevision: response.response.presentationRevision,
|
|
199
|
+
author: response.author, createdAt: response.createdAt
|
|
200
|
+
}))
|
|
201
|
+
}));
|
|
202
|
+
const counts = this.#counts(threadId);
|
|
203
|
+
return { id: node.id, title: node.title, project: ancestors[0]?.title || node.title, summary: node.body,
|
|
204
|
+
author: node.author, createdAt: node.createdAt, updatedAt: node.updatedAt,
|
|
205
|
+
counts: { ...counts, questions: counts.requests }, questions };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
listThreads(view = 'history') {
|
|
209
|
+
const items = this.listNodes().filter((node) => node.parentId && !node.expectsAnswer && !node.response).map((node) => {
|
|
210
|
+
const result = this.getThread(node.id);
|
|
211
|
+
return { ...result, latestPrompt: result.questions.at(-1)?.prompt || node.body, questions: undefined };
|
|
212
|
+
}).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
213
|
+
if (view === 'needs-answer') return items.filter((item) => item.counts.outstanding > 0);
|
|
214
|
+
if (view === 'waiting-on-team') return items.filter((item) => item.counts.waitingOnTeam > 0);
|
|
215
|
+
if (view === 'deferred') return items.filter((item) => item.counts.deferred > 0);
|
|
216
|
+
return items;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
listEvents(after = 0, threadId = null) {
|
|
220
|
+
const rows = threadId
|
|
221
|
+
? this.db.prepare('SELECT * FROM events WHERE sequence > ? AND thread_id = ? ORDER BY sequence LIMIT 100').all(after, threadId)
|
|
222
|
+
: this.db.prepare('SELECT * FROM events WHERE sequence > ? ORDER BY sequence LIMIT 100').all(after);
|
|
223
|
+
return rows.map((row) => ({ sequence: row.sequence, id: row.id, type: row.type, threadId: row.thread_id, payload: parse(row.payload_json, {}), createdAt: row.created_at }));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
seedDemo() {
|
|
227
|
+
const { count } = this.db.prepare('SELECT COUNT(*) AS count FROM nodes').get();
|
|
228
|
+
if (count > 0) return;
|
|
229
|
+
this.createThread({
|
|
230
|
+
id: 'thr_mist-creature',
|
|
231
|
+
title: 'Mist creature — visual direction',
|
|
232
|
+
project: 'MistFall',
|
|
233
|
+
summary: 'Choosing a readable silhouette before the next movement pass.',
|
|
234
|
+
author: { name: 'Mara (demo)', role: 'Visual storyteller' },
|
|
235
|
+
questions: [{
|
|
236
|
+
id: 'q_silhouette',
|
|
237
|
+
prompt: 'Which silhouette should anchor the creature’s next pass?',
|
|
238
|
+
context: 'I explored three ways the mist can feel alive at gameplay distance. The choice here is about posture and motion language, not final color.',
|
|
239
|
+
presentation: {
|
|
240
|
+
kind: 'comparison-v1', revision: 'silhouettes-r1', eyebrow: 'Direction review · Revision 1',
|
|
241
|
+
options: [
|
|
242
|
+
{ id: 'drift', label: 'A · The Drift', detail: 'Quiet, vertical, almost ceremonial. Cloth-like wake.', image: '/assets/mist-drift.svg', alt: 'Tall narrow mist creature silhouette with a long flowing wake' },
|
|
243
|
+
{ id: 'prowler', label: 'B · The Prowler', detail: 'Low center of gravity. Reads as alert and predatory.', image: '/assets/mist-prowler.svg', alt: 'Wide crouched mist creature silhouette with forward-reaching limbs' },
|
|
244
|
+
{ id: 'bloom', label: 'C · The Bloom', detail: 'Unstable radial shape. Beautiful, stranger, less readable.', image: '/assets/mist-bloom.svg', alt: 'Radial mist creature silhouette opening like a many-petaled flower' }
|
|
245
|
+
]
|
|
246
|
+
}
|
|
247
|
+
}]
|
|
248
|
+
});
|
|
249
|
+
const design = this.createThread({
|
|
250
|
+
id: 'thr-fog-arrival',
|
|
251
|
+
title: 'When should the valley fog arrive?',
|
|
252
|
+
project: 'MistFall',
|
|
253
|
+
summary: 'A small pacing decision with one resolved question and one follow-up.',
|
|
254
|
+
author: { name: 'Ivo (demo)', role: 'Game design engineer' },
|
|
255
|
+
questions: [{
|
|
256
|
+
id: 'q_fog-timing', prompt: 'Should the first fog bank arrive before or after the player finds the lantern?',
|
|
257
|
+
context: 'Before makes the lantern feel necessary; after gives the opening more breathing room.',
|
|
258
|
+
presentation: { kind: 'text-v1', revision: 'timing-r1', choices: ['Before the lantern', 'After the lantern'] }
|
|
259
|
+
}]
|
|
260
|
+
});
|
|
261
|
+
this.addResponse('q_fog-timing', {
|
|
262
|
+
kind: 'answer', body: 'After the lantern. Let me learn the valley in clear air first, then make the fog change how I read the same place.',
|
|
263
|
+
selections: [{ id: 'after', label: 'After the lantern' }], author: { name: 'Scott (demo)' }
|
|
264
|
+
}, 'demo-fog-answer');
|
|
265
|
+
this.addQuestion(design.id, {
|
|
266
|
+
id: 'q_fog-duration', prompt: 'Follow-up: should that first fog bank pass, or remain for the rest of the chapter?',
|
|
267
|
+
context: 'A passing bank makes it a reveal. Persistent fog makes it the chapter’s new normal.',
|
|
268
|
+
presentation: { kind: 'text-v1', revision: 'duration-r1', choices: ['Pass after the reveal', 'Remain through the chapter'] }
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
#normalizeInput(input) {
|
|
273
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new InputError('publish input must be an object');
|
|
274
|
+
if (input.path && input.parentId) throw new InputError('use path or parentId, not both');
|
|
275
|
+
const familiarScope = input.project != null || input.thread != null;
|
|
276
|
+
if (familiarScope && (input.path || input.parentId)) throw new InputError('use project/thread, path, or parentId, not multiple locators');
|
|
277
|
+
const path = familiarScope ? [
|
|
278
|
+
...(input.project != null ? [text(input.project, 'project')] : []),
|
|
279
|
+
...(input.thread != null ? [text(input.thread, 'thread')] : [])
|
|
280
|
+
] : input.path;
|
|
281
|
+
const question = typeof input.question === 'string' ? input.question : null;
|
|
282
|
+
const expectsAnswer = answerRequest(input) || question !== null || input.kind === 'question'; // old request shorthand
|
|
283
|
+
const title = text(question || input.title, 'title or question');
|
|
284
|
+
const body = text(input.body || input.context, 'body', false);
|
|
285
|
+
let presentation = input.presentation;
|
|
286
|
+
if (input.html) presentation = { kind: 'html-v1', html: input.html, fallback: input.fallback, revision: input.revision };
|
|
287
|
+
if (!presentation && input.choices) presentation = { kind: 'text-v1', choices: input.choices, multiple: !!input.multiple };
|
|
288
|
+
if (path && (!Array.isArray(path) || path.some((part) => typeof part !== 'string' || !part.trim()))) throw new InputError('path is an array of nonempty node titles');
|
|
289
|
+
return { expectsAnswer, title, body, path: path?.map((part) => part.trim()) || null, parentId: input.parentId || null,
|
|
290
|
+
author: input.author || { name: 'AI teammate' }, presentation: presentation ? this.#presentation(presentation, false) : (expectsAnswer ? this.#presentation({}, false) : null) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
#presentation(input, captureAssets = true) {
|
|
294
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) throw new InputError('presentation must be an object');
|
|
295
|
+
const value = structuredClone(input);
|
|
296
|
+
value.kind ||= 'text-v1';
|
|
297
|
+
// Revision is generated at insertion, not here, so request retries have a stable fingerprint.
|
|
298
|
+
if (value.kind === 'html-v1') {
|
|
299
|
+
text(value.html, 'presentation html');
|
|
300
|
+
if (!value.fallback) throw new InputError('an authored presentation needs a readable fallback');
|
|
301
|
+
}
|
|
302
|
+
if (captureAssets && Array.isArray(value.options)) for (const option of value.options) {
|
|
303
|
+
if (typeof option.image === 'string' && option.image.startsWith('/assets/')) {
|
|
304
|
+
const filename = resolve(assetsDir, option.image.slice('/assets/'.length));
|
|
305
|
+
if (!filename.startsWith(resolve(assetsDir) + sep)) throw new InputError('invalid asset reference');
|
|
306
|
+
try {
|
|
307
|
+
const bytes = readFileSync(filename);
|
|
308
|
+
const mime = filename.endsWith('.svg') ? 'image/svg+xml' : filename.endsWith('.png') ? 'image/png' : 'image/jpeg';
|
|
309
|
+
option.image = `data:${mime};base64,${bytes.toString('base64')}`;
|
|
310
|
+
} catch { throw new InputError('presentation asset not found'); }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#resolvePath(path, author) {
|
|
317
|
+
let parentId = null;
|
|
318
|
+
const createdIds = [];
|
|
319
|
+
for (const title of path) {
|
|
320
|
+
const matches = this.db.prepare('SELECT id FROM nodes WHERE parent_id IS ? AND title = ?').all(parentId, title);
|
|
321
|
+
if (matches.length > 1) throw new ConflictError(`path is ambiguous at “${title}”; use parentId`);
|
|
322
|
+
if (matches.length === 1) parentId = matches[0].id;
|
|
323
|
+
else {
|
|
324
|
+
parentId = this.#insert({ title, parentId, author });
|
|
325
|
+
createdIds.push(parentId);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { parentId, createdIds };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
#insert(input) {
|
|
332
|
+
const id = input.id || `node_${randomUUID()}`;
|
|
333
|
+
const createdAt = input.createdAt || now();
|
|
334
|
+
const presentation = input.presentation ? { ...input.presentation, revision: input.presentation.revision || `rev_${randomUUID()}` } : null;
|
|
335
|
+
this.db.prepare(`INSERT INTO nodes
|
|
336
|
+
(id, parent_id, expects_answer, title, body, author_json, status, presentation_json, response_json, idempotency_key, request_hash, created_at, updated_at)
|
|
337
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
338
|
+
.run(id, input.parentId || null, input.expectsAnswer ? 1 : 0, input.title, input.body || '',
|
|
339
|
+
JSON.stringify(input.author || { name: 'AI teammate' }), input.status || (input.expectsAnswer ? 'outstanding' : null),
|
|
340
|
+
presentation ? JSON.stringify(presentation) : null, input.response ? JSON.stringify(input.response) : null,
|
|
341
|
+
input.key || null, input.requestHash || null, createdAt, input.updatedAt || createdAt);
|
|
342
|
+
// Updating the whole ancestor chain makes nested activity discoverable without changing identity.
|
|
343
|
+
if (input.parentId) this.db.prepare(`WITH RECURSIVE parents(id) AS (
|
|
344
|
+
SELECT ? UNION ALL SELECT n.parent_id FROM nodes n JOIN parents p ON n.id = p.id WHERE n.parent_id IS NOT NULL
|
|
345
|
+
) UPDATE nodes SET updated_at = ? WHERE id IN (SELECT id FROM parents)`).run(input.parentId, createdAt);
|
|
346
|
+
return id;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#existing(key, requestHash, ...compatibleHashes) {
|
|
350
|
+
if (!key) return null;
|
|
351
|
+
const existing = this.db.prepare('SELECT * FROM nodes WHERE idempotency_key = ?').get(key);
|
|
352
|
+
if (existing && existing.request_hash !== requestHash && !compatibleHashes.filter(Boolean).includes(existing.request_hash)) throw new ConflictError('Idempotency-Key was already used for different content');
|
|
353
|
+
return existing;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#require(id) {
|
|
357
|
+
const node = this.db.prepare('SELECT * FROM nodes WHERE id = ?').get(id);
|
|
358
|
+
if (!node) throw new NotFoundError('node not found');
|
|
359
|
+
return node;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#node(row) {
|
|
363
|
+
return { id: row.id, parentId: row.parent_id, title: row.title, body: row.body, expectsAnswer: !!row.expects_answer,
|
|
364
|
+
author: parse(row.author_json, {}), status: row.status, presentation: parse(row.presentation_json), response: parse(row.response_json),
|
|
365
|
+
createdAt: row.created_at, updatedAt: row.updated_at };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#counts(id) {
|
|
369
|
+
const rows = this.db.prepare(`WITH RECURSIVE descendants AS (
|
|
370
|
+
SELECT * FROM nodes WHERE id = ? UNION ALL SELECT n.* FROM nodes n JOIN descendants d ON n.parent_id = d.id
|
|
371
|
+
) SELECT expects_answer, status FROM descendants`).all(id);
|
|
372
|
+
return { nodes: rows.length, requests: rows.filter((row) => row.expects_answer).length,
|
|
373
|
+
outstanding: rows.filter((row) => row.status === 'outstanding').length,
|
|
374
|
+
waitingOnTeam: rows.filter((row) => row.status === 'waiting_on_team').length,
|
|
375
|
+
deferred: rows.filter((row) => row.status === 'deferred').length };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
#legacyThreadId(nodeId) {
|
|
379
|
+
const node = this.#require(nodeId);
|
|
380
|
+
return node.parent_id || node.id;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
#atomic(work) {
|
|
384
|
+
this.db.exec('BEGIN IMMEDIATE');
|
|
385
|
+
let result;
|
|
386
|
+
try { result = work(); this.db.exec('COMMIT'); }
|
|
387
|
+
catch (error) { this.db.exec('ROLLBACK'); throw error; }
|
|
388
|
+
// The saved record is authoritative even if a live transport listener fails.
|
|
389
|
+
try { this.changes.emit('change'); } catch (error) { console.error('Live update listener failed after commit:', error); }
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
#event(type, threadId, payload) {
|
|
394
|
+
this.db.prepare('INSERT INTO events (id, type, thread_id, payload_json, created_at) VALUES (?, ?, ?, ?, ?)')
|
|
395
|
+
.run(`evt_${randomUUID()}`, type, threadId, JSON.stringify(payload), now());
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#migrateNodeKinds() {
|
|
399
|
+
const columns = this.db.prepare('PRAGMA table_info(nodes)').all().map((column) => column.name);
|
|
400
|
+
if (!columns.includes('kind')) return;
|
|
401
|
+
this.#atomic(() => {
|
|
402
|
+
this.db.exec(`ALTER TABLE nodes ADD COLUMN expects_answer INTEGER NOT NULL DEFAULT 0;
|
|
403
|
+
UPDATE nodes SET expects_answer = 1 WHERE kind = 'question';
|
|
404
|
+
ALTER TABLE nodes DROP COLUMN kind;`);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
#migrateLegacy() {
|
|
409
|
+
const legacy = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='threads'").get();
|
|
410
|
+
const { count } = this.db.prepare('SELECT COUNT(*) AS count FROM nodes').get();
|
|
411
|
+
if (!legacy || count > 0) return;
|
|
412
|
+
this.#atomic(() => {
|
|
413
|
+
const oldThreads = this.db.prepare('SELECT * FROM threads ORDER BY created_at').all();
|
|
414
|
+
for (const thread of oldThreads) {
|
|
415
|
+
const { parentId } = this.#resolvePath([thread.project], { name: 'Threadroom' });
|
|
416
|
+
this.#insert({ id: thread.id, parentId, title: thread.title, body: thread.summary,
|
|
417
|
+
author: { name: thread.author_name, role: thread.author_role }, createdAt: thread.created_at, updatedAt: thread.updated_at });
|
|
418
|
+
}
|
|
419
|
+
for (const question of this.db.prepare('SELECT * FROM questions ORDER BY created_at').all()) {
|
|
420
|
+
this.#insert({ id: question.id, parentId: question.thread_id, expectsAnswer: true, title: question.prompt, body: question.context,
|
|
421
|
+
author: this.getNode(question.thread_id).node.author, status: question.status,
|
|
422
|
+
presentation: this.#presentation({ ...parse(question.presentation_json, {}), kind: question.presentation_kind, revision: question.presentation_revision }),
|
|
423
|
+
createdAt: question.created_at });
|
|
424
|
+
}
|
|
425
|
+
for (const response of this.db.prepare('SELECT * FROM responses ORDER BY created_at').all()) {
|
|
426
|
+
this.#insert({ id: response.id, parentId: response.question_id, title: response.body.slice(0, 100) || response.kind,
|
|
427
|
+
body: response.body, author: { name: response.author_name },
|
|
428
|
+
response: { kind: response.kind, selections: parse(response.selections_json, []), presentationRevision: response.presentation_revision, targetId: response.question_id },
|
|
429
|
+
key: response.idempotency_key,
|
|
430
|
+
requestHash: response.idempotency_key ? `legacy:${fingerprint({ nodeId: response.question_id, kind: response.kind, body: response.body, selections: parse(response.selections_json, []) })}` : null,
|
|
431
|
+
createdAt: response.created_at });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function answerRequest(input) {
|
|
438
|
+
if (input.expectsAnswer != null && typeof input.expectsAnswer !== 'boolean') throw new InputError('expectsAnswer must be a boolean');
|
|
439
|
+
return input.expectsAnswer === true;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function text(value, name, required = true) {
|
|
443
|
+
if (value == null && !required) return '';
|
|
444
|
+
if (typeof value !== 'string' || (required && !value.trim())) throw new InputError(`${name} must be ${required ? 'a nonempty' : 'a'} string`);
|
|
445
|
+
return value.trim();
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function routableId(input, name) {
|
|
449
|
+
if (input == null || !Object.hasOwn(input, 'id')) return;
|
|
450
|
+
const value = input.id;
|
|
451
|
+
if (typeof value !== 'string' || !value.length) throw new InputError(`${name} must be a nonempty string`);
|
|
452
|
+
if (!value.isWellFormed()) throw new InputError(`${name} must contain well-formed Unicode`);
|
|
453
|
+
if (value === '.' || value === '..') throw new InputError(`${name} cannot be a URL dot segment`);
|
|
454
|
+
if (/[\u0000-\u001f\u007f-\u009f]/u.test(value)) throw new InputError(`${name} cannot contain control characters`);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export class InputError extends Error { statusCode = 400; }
|
|
458
|
+
export class NotFoundError extends Error { statusCode = 404; }
|
|
459
|
+
export class ConflictError extends Error { statusCode = 409; }
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createWebsiteHandler } from './site.js';
|
|
3
|
+
|
|
4
|
+
const port = Number(process.env.UI_PORT || 4311);
|
|
5
|
+
const apiBaseUrl = process.env.THREADROOM_API_URL || 'http://127.0.0.1:4310';
|
|
6
|
+
const website = createWebsiteHandler({ apiBaseUrl });
|
|
7
|
+
const server = createServer((request, response) => {
|
|
8
|
+
website(request, response).catch((error) => {
|
|
9
|
+
console.error(error);
|
|
10
|
+
if (!response.headersSent) response.writeHead(500);
|
|
11
|
+
response.end('Website error');
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
server.listen(port, '127.0.0.1', () => console.log(`Independent Threadroom UI: http://127.0.0.1:${server.address().port} · API: ${apiBaseUrl}`));
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
6
|
+
import { threadroomDataDirectory, threadroomDatabasePath } from './paths.js';
|
|
7
|
+
|
|
8
|
+
const packageDir = fileURLToPath(new URL('../', import.meta.url));
|
|
9
|
+
const cliPath = join(packageDir, 'bin/threadroom-service.js');
|
|
10
|
+
const version = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')).version;
|
|
11
|
+
const help = `Threadroom service (Node 24+; local, unauthenticated loopback only)
|
|
12
|
+
|
|
13
|
+
threadroom-service serve [--database /absolute/path] [--port 4310]
|
|
14
|
+
threadroom-service api [--database /absolute/path] [--port 4310]
|
|
15
|
+
threadroom-service ui [--api-url http://127.0.0.1:4310] [--port 4311]
|
|
16
|
+
threadroom-service launchd-config --output-dir /absolute/path
|
|
17
|
+
[--database /absolute/path] [--api-port 4310] [--ui-port 4311]
|
|
18
|
+
[--api-url http://127.0.0.1:4310]
|
|
19
|
+
threadroom-service --help | --version
|
|
20
|
+
|
|
21
|
+
serve runs the API and website together; api and ui keep them independent.
|
|
22
|
+
All run in the foreground until stopped. Port 0 chooses a free port.
|
|
23
|
+
API storage: --database, then THREADROOM_DB, then the per-user data directory.
|
|
24
|
+
UI never opens a database. No command installs or activates a background job.
|
|
25
|
+
launchd-config only writes two plists; review and activation are manual.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
function optionsFor(args, accepted) {
|
|
29
|
+
const options = {};
|
|
30
|
+
for (let i = 0; i < args.length; i++) {
|
|
31
|
+
const flag = args[i];
|
|
32
|
+
if (!flag.startsWith('--') || !accepted.includes(flag.slice(2))) throw new Error(`Unknown option: ${flag}. See --help.`);
|
|
33
|
+
const key = flag.slice(2);
|
|
34
|
+
if (options[key] !== undefined) throw new Error(`Duplicate option: ${flag}`);
|
|
35
|
+
const value = args[++i];
|
|
36
|
+
if (!value || value.startsWith('--')) throw new Error(`${flag} needs a value.`);
|
|
37
|
+
options[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return options;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function port(value, fallback, ephemeral = true) {
|
|
43
|
+
const text = String(value ?? fallback);
|
|
44
|
+
const number = Number(text);
|
|
45
|
+
if (!/^\d+$/.test(text) || !Number.isSafeInteger(number) || number < (ephemeral ? 0 : 1) || number > 65535) {
|
|
46
|
+
throw new Error(`Invalid port: ${text}; expected ${ephemeral ? '0' : '1'}–65535.`);
|
|
47
|
+
}
|
|
48
|
+
return number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function absolute(value, flag) {
|
|
52
|
+
if (!isAbsolute(value)) throw new Error(`${flag} needs an absolute path.`);
|
|
53
|
+
return resolve(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function databasePath(options) {
|
|
57
|
+
if (options.database) return absolute(options.database, '--database');
|
|
58
|
+
return threadroomDatabasePath();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function apiUrl(value) {
|
|
62
|
+
let url;
|
|
63
|
+
try { url = new URL(value); } catch { throw new Error(`Invalid API URL: ${value}`); }
|
|
64
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
|
|
65
|
+
throw new Error('API URL must be an HTTP(S) base URL without credentials, query, or fragment.');
|
|
66
|
+
}
|
|
67
|
+
return url.href.replace(/\/$/, '');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function resourceEntry(name) {
|
|
71
|
+
// Recognized source CLI uses current source, even after a previous pack.
|
|
72
|
+
// An extracted distribution must always use its own bundled resources.
|
|
73
|
+
const checkout = resolve(packageDir, '../..');
|
|
74
|
+
const manifest = join(checkout, 'package.json');
|
|
75
|
+
if (resolve(packageDir) === join(checkout, 'packages', 'service') && existsSync(manifest)) {
|
|
76
|
+
const root = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
77
|
+
if (root.name === 'threadroom' && root.workspaces?.includes('packages/*')) {
|
|
78
|
+
const source = join(checkout, 'src', name);
|
|
79
|
+
if (existsSync(source)) return pathToFileURL(source).href;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const bundled = join(packageDir, 'dist', 'src', name);
|
|
83
|
+
if (existsSync(bundled)) return pathToFileURL(bundled).href;
|
|
84
|
+
throw new Error('Runtime resources are missing. Pack from the source checkout with npm pack --workspace threadroom-service.');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function xml(value) {
|
|
88
|
+
const text = String(value);
|
|
89
|
+
if (/[\u0000-\u0008\u000b\u000c\u000e-\u001f]/u.test(text)) throw new Error('Paths and URLs cannot contain XML control characters.');
|
|
90
|
+
return text.replace(/[&<>"']/g, (character) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[character]));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function plist(label, args, environment, directory, logName) {
|
|
94
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
95
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
96
|
+
<plist version="1.0"><dict>
|
|
97
|
+
<key>Label</key><string>${xml(label)}</string>
|
|
98
|
+
<key>ProgramArguments</key><array>${[process.execPath, cliPath, ...args].map((arg) => `<string>${xml(arg)}</string>`).join('')}</array>
|
|
99
|
+
<key>EnvironmentVariables</key><dict>${Object.entries(environment).map(([key, value]) => `<key>${xml(key)}</key><string>${xml(value)}</string>`).join('')}</dict>
|
|
100
|
+
<key>WorkingDirectory</key><string>${xml(directory)}</string>
|
|
101
|
+
<key>RunAtLoad</key><true/>
|
|
102
|
+
<key>KeepAlive</key><true/>
|
|
103
|
+
<key>ThrottleInterval</key><integer>10</integer>
|
|
104
|
+
<key>Umask</key><integer>63</integer>
|
|
105
|
+
<key>StandardOutPath</key><string>${xml(join(directory, `${logName}.log`))}</string>
|
|
106
|
+
<key>StandardErrorPath</key><string>${xml(join(directory, `${logName}.error.log`))}</string>
|
|
107
|
+
</dict></plist>
|
|
108
|
+
`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function generateConfig(options) {
|
|
112
|
+
if (!options['output-dir']) throw new Error('launchd-config needs --output-dir /absolute/path.');
|
|
113
|
+
const output = absolute(options['output-dir'], '--output-dir');
|
|
114
|
+
const database = databasePath(options);
|
|
115
|
+
const apiPort = port(options['api-port'], 4310, false);
|
|
116
|
+
const uiPort = port(options['ui-port'], 4311, false);
|
|
117
|
+
if (apiPort === uiPort) throw new Error('API and UI need different ports.');
|
|
118
|
+
const url = apiUrl(options['api-url'] || `http://127.0.0.1:${apiPort}`);
|
|
119
|
+
const directory = threadroomDataDirectory();
|
|
120
|
+
// Capture user identity paths, not shell/Pi lifetime or arbitrary ambient settings.
|
|
121
|
+
const environment = { HOME: homedir() };
|
|
122
|
+
if (process.platform !== 'darwin') {
|
|
123
|
+
if (process.env.XDG_DATA_HOME && isAbsolute(process.env.XDG_DATA_HOME)) environment.XDG_DATA_HOME = process.env.XDG_DATA_HOME;
|
|
124
|
+
if (process.env.LOCALAPPDATA) environment.LOCALAPPDATA = process.env.LOCALAPPDATA;
|
|
125
|
+
}
|
|
126
|
+
const jobs = [
|
|
127
|
+
['local.threadroom.api', plist('local.threadroom.api', ['api', '--database', database, '--port', String(apiPort)], {
|
|
128
|
+
...environment, THREADROOM_SEED_DEMO: '0',
|
|
129
|
+
THREADROOM_UI_ORIGINS: `http://127.0.0.1:${uiPort},http://localhost:${uiPort}`
|
|
130
|
+
}, directory, 'api')],
|
|
131
|
+
['local.threadroom.ui', plist('local.threadroom.ui', ['ui', '--api-url', url, '--port', String(uiPort)], environment, directory, 'ui')]
|
|
132
|
+
];
|
|
133
|
+
// Validate every destination before any write. Atomic replacement avoids
|
|
134
|
+
// following symlinks or hard links that happen to occupy the final name.
|
|
135
|
+
mkdirSync(output, { recursive: true, mode: 0o700 });
|
|
136
|
+
const destinations = jobs.map(([label, contents]) => ({ contents, path: join(output, `${label}.plist`) }));
|
|
137
|
+
for (const { path } of destinations) {
|
|
138
|
+
const existing = lstatSync(path, { throwIfNoEntry: false });
|
|
139
|
+
if (existing && !existing.isFile()) throw new Error(`Refusing non-regular launchd configuration destination: ${path}`);
|
|
140
|
+
}
|
|
141
|
+
for (const { contents, path } of destinations) {
|
|
142
|
+
const temporary = join(output, `.${randomUUID()}.plist.tmp`);
|
|
143
|
+
try {
|
|
144
|
+
writeFileSync(temporary, contents, { flag: 'wx', mode: 0o600 });
|
|
145
|
+
renameSync(temporary, path);
|
|
146
|
+
} finally {
|
|
147
|
+
rmSync(temporary, { force: true });
|
|
148
|
+
}
|
|
149
|
+
console.log(path);
|
|
150
|
+
}
|
|
151
|
+
console.log(`Configuration only; no jobs installed or started. Before approved activation, prepare private directories:\n ${directory}\n ${dirname(database)}\nNode: ${process.execPath}\nCLI: ${cliPath}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function run(args) {
|
|
155
|
+
if (Number(process.versions.node.split('.')[0]) < 24) throw new Error('Node 24 or newer is required.');
|
|
156
|
+
const [command, ...rest] = args;
|
|
157
|
+
if (!command || command === '--help' || command === 'help' || (rest.length === 1 && rest[0] === '--help')) {
|
|
158
|
+
console.log(help); return;
|
|
159
|
+
}
|
|
160
|
+
if (command === '--version') { console.log(version); return; }
|
|
161
|
+
if (command === 'launchd-config') {
|
|
162
|
+
generateConfig(optionsFor(rest, ['output-dir', 'database', 'api-port', 'ui-port', 'api-url']));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (command === 'serve' || command === 'api') {
|
|
166
|
+
const options = optionsFor(rest, ['database', 'port']);
|
|
167
|
+
const database = databasePath(options);
|
|
168
|
+
const listenPort = port(options.port ?? process.env.PORT, 4310);
|
|
169
|
+
const entry = resourceEntry('main.js');
|
|
170
|
+
process.umask(0o077);
|
|
171
|
+
mkdirSync(dirname(database), { recursive: true, mode: 0o700 });
|
|
172
|
+
Object.assign(process.env, {
|
|
173
|
+
THREADROOM_DB: database, PORT: String(listenPort), HOST: '127.0.0.1',
|
|
174
|
+
THREADROOM_SERVE_UI: command === 'api' ? '0' : '1', THREADROOM_SEED_DEMO: '0'
|
|
175
|
+
});
|
|
176
|
+
await import(entry);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (command === 'ui') {
|
|
180
|
+
const options = optionsFor(rest, ['api-url', 'port']);
|
|
181
|
+
const url = apiUrl(options['api-url'] || process.env.THREADROOM_API_URL || 'http://127.0.0.1:4310');
|
|
182
|
+
const listenPort = port(options.port ?? process.env.UI_PORT, 4311);
|
|
183
|
+
Object.assign(process.env, { THREADROOM_API_URL: url, UI_PORT: String(listenPort) });
|
|
184
|
+
await import(resourceEntry('ui-main.js'));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
throw new Error(`Unknown command: ${command}. See --help.`);
|
|
188
|
+
}
|