@poncho-ai/cli 0.14.1 → 0.16.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/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +26 -0
- package/dist/{chunk-AIEVSNGF.js → chunk-XT5HPFIW.js} +1025 -353
- package/dist/cli.js +1 -1
- package/dist/index.js +1 -1
- package/dist/{run-interactive-ink-7ULE5JJI.js → run-interactive-ink-2JQJDP7W.js} +1 -1
- package/package.json +4 -4
- package/src/index.ts +168 -5
- package/src/init-onboarding.ts +2 -4
- package/src/web-ui-client.ts +2605 -0
- package/src/web-ui-store.ts +340 -0
- package/src/web-ui-styles.ts +1340 -0
- package/src/web-ui.ts +64 -3809
- package/test/init-onboarding.contract.test.ts +2 -3
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, resolve } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
6
|
+
import type { Message } from "@poncho-ai/sdk";
|
|
7
|
+
|
|
8
|
+
export interface WebUiConversation {
|
|
9
|
+
conversationId: string;
|
|
10
|
+
title: string;
|
|
11
|
+
messages: Message[];
|
|
12
|
+
runtimeRunId?: string;
|
|
13
|
+
ownerId: string;
|
|
14
|
+
tenantId: string | null;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
updatedAt: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type ConversationStoreFile = {
|
|
20
|
+
conversations: WebUiConversation[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const DEFAULT_OWNER = "local-owner";
|
|
24
|
+
|
|
25
|
+
const getStateDirectory = (): string => {
|
|
26
|
+
const cwd = process.cwd();
|
|
27
|
+
const home = homedir();
|
|
28
|
+
const isServerless =
|
|
29
|
+
process.env.VERCEL === "1" ||
|
|
30
|
+
process.env.VERCEL_ENV !== undefined ||
|
|
31
|
+
process.env.VERCEL_URL !== undefined ||
|
|
32
|
+
process.env.AWS_LAMBDA_FUNCTION_NAME !== undefined ||
|
|
33
|
+
process.env.AWS_EXECUTION_ENV?.includes("AWS_Lambda") === true ||
|
|
34
|
+
process.env.LAMBDA_TASK_ROOT !== undefined ||
|
|
35
|
+
process.env.NOW_REGION !== undefined ||
|
|
36
|
+
cwd.startsWith("/var/task") ||
|
|
37
|
+
home.startsWith("/var/task") ||
|
|
38
|
+
process.env.SERVERLESS === "1";
|
|
39
|
+
if (isServerless) {
|
|
40
|
+
return "/tmp/.poncho/state";
|
|
41
|
+
}
|
|
42
|
+
return resolve(homedir(), ".poncho", "state");
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export class FileConversationStore {
|
|
46
|
+
private readonly filePath: string;
|
|
47
|
+
private readonly conversations = new Map<string, WebUiConversation>();
|
|
48
|
+
private loaded = false;
|
|
49
|
+
private writing = Promise.resolve();
|
|
50
|
+
|
|
51
|
+
constructor(workingDir: string) {
|
|
52
|
+
const projectName = basename(workingDir).replace(/[^a-zA-Z0-9_-]+/g, "-") || "project";
|
|
53
|
+
const projectHash = createHash("sha256")
|
|
54
|
+
.update(workingDir)
|
|
55
|
+
.digest("hex")
|
|
56
|
+
.slice(0, 12);
|
|
57
|
+
this.filePath = resolve(
|
|
58
|
+
getStateDirectory(),
|
|
59
|
+
`${projectName}-${projectHash}-web-ui-state.json`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async ensureLoaded(): Promise<void> {
|
|
64
|
+
if (this.loaded) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.loaded = true;
|
|
68
|
+
try {
|
|
69
|
+
const content = await readFile(this.filePath, "utf8");
|
|
70
|
+
const parsed = JSON.parse(content) as ConversationStoreFile;
|
|
71
|
+
for (const conversation of parsed.conversations ?? []) {
|
|
72
|
+
this.conversations.set(conversation.conversationId, conversation);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// File does not exist yet or contains invalid JSON.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async persist(): Promise<void> {
|
|
80
|
+
const payload: ConversationStoreFile = {
|
|
81
|
+
conversations: Array.from(this.conversations.values()),
|
|
82
|
+
};
|
|
83
|
+
this.writing = this.writing.then(async () => {
|
|
84
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
85
|
+
await writeFile(this.filePath, JSON.stringify(payload, null, 2), "utf8");
|
|
86
|
+
});
|
|
87
|
+
await this.writing;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async list(ownerId = DEFAULT_OWNER): Promise<WebUiConversation[]> {
|
|
91
|
+
await this.ensureLoaded();
|
|
92
|
+
return Array.from(this.conversations.values())
|
|
93
|
+
.filter((conversation) => conversation.ownerId === ownerId)
|
|
94
|
+
.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async get(conversationId: string): Promise<WebUiConversation | undefined> {
|
|
98
|
+
await this.ensureLoaded();
|
|
99
|
+
return this.conversations.get(conversationId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async create(ownerId = DEFAULT_OWNER, title?: string): Promise<WebUiConversation> {
|
|
103
|
+
await this.ensureLoaded();
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
const conversation: WebUiConversation = {
|
|
106
|
+
conversationId: randomUUID(),
|
|
107
|
+
title: title && title.trim().length > 0 ? title.trim() : "New conversation",
|
|
108
|
+
messages: [],
|
|
109
|
+
ownerId,
|
|
110
|
+
tenantId: null,
|
|
111
|
+
createdAt: now,
|
|
112
|
+
updatedAt: now,
|
|
113
|
+
};
|
|
114
|
+
this.conversations.set(conversation.conversationId, conversation);
|
|
115
|
+
await this.persist();
|
|
116
|
+
return conversation;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async update(conversation: WebUiConversation): Promise<void> {
|
|
120
|
+
await this.ensureLoaded();
|
|
121
|
+
this.conversations.set(conversation.conversationId, {
|
|
122
|
+
...conversation,
|
|
123
|
+
updatedAt: Date.now(),
|
|
124
|
+
});
|
|
125
|
+
await this.persist();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async rename(conversationId: string, title: string): Promise<WebUiConversation | undefined> {
|
|
129
|
+
await this.ensureLoaded();
|
|
130
|
+
const existing = this.conversations.get(conversationId);
|
|
131
|
+
if (!existing) {
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
const updated = {
|
|
135
|
+
...existing,
|
|
136
|
+
title: title.trim().length > 0 ? title.trim() : existing.title,
|
|
137
|
+
updatedAt: Date.now(),
|
|
138
|
+
};
|
|
139
|
+
this.conversations.set(conversationId, updated);
|
|
140
|
+
await this.persist();
|
|
141
|
+
return updated;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async delete(conversationId: string): Promise<boolean> {
|
|
145
|
+
await this.ensureLoaded();
|
|
146
|
+
const removed = this.conversations.delete(conversationId);
|
|
147
|
+
if (removed) {
|
|
148
|
+
await this.persist();
|
|
149
|
+
}
|
|
150
|
+
return removed;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type SessionRecord = {
|
|
155
|
+
sessionId: string;
|
|
156
|
+
ownerId: string;
|
|
157
|
+
csrfToken: string;
|
|
158
|
+
createdAt: number;
|
|
159
|
+
expiresAt: number;
|
|
160
|
+
lastSeenAt: number;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export class SessionStore {
|
|
164
|
+
private readonly sessions = new Map<string, SessionRecord>();
|
|
165
|
+
private readonly ttlMs: number;
|
|
166
|
+
|
|
167
|
+
constructor(ttlMs = 1000 * 60 * 60 * 8) {
|
|
168
|
+
this.ttlMs = ttlMs;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
create(ownerId = DEFAULT_OWNER): SessionRecord {
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
const session: SessionRecord = {
|
|
174
|
+
sessionId: randomUUID(),
|
|
175
|
+
ownerId,
|
|
176
|
+
csrfToken: randomUUID(),
|
|
177
|
+
createdAt: now,
|
|
178
|
+
expiresAt: now + this.ttlMs,
|
|
179
|
+
lastSeenAt: now,
|
|
180
|
+
};
|
|
181
|
+
this.sessions.set(session.sessionId, session);
|
|
182
|
+
return session;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
get(sessionId: string): SessionRecord | undefined {
|
|
186
|
+
const session = this.sessions.get(sessionId);
|
|
187
|
+
if (!session) {
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
if (Date.now() > session.expiresAt) {
|
|
191
|
+
this.sessions.delete(sessionId);
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
session.lastSeenAt = Date.now();
|
|
195
|
+
return session;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
delete(sessionId: string): void {
|
|
199
|
+
this.sessions.delete(sessionId);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
type LoginAttemptState = {
|
|
204
|
+
count: number;
|
|
205
|
+
firstFailureAt: number;
|
|
206
|
+
lockedUntil?: number;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
export class LoginRateLimiter {
|
|
210
|
+
private readonly attempts = new Map<string, LoginAttemptState>();
|
|
211
|
+
|
|
212
|
+
constructor(
|
|
213
|
+
private readonly maxAttempts = 5,
|
|
214
|
+
private readonly windowMs = 1000 * 60 * 5,
|
|
215
|
+
private readonly lockoutMs = 1000 * 60 * 10,
|
|
216
|
+
) {}
|
|
217
|
+
|
|
218
|
+
canAttempt(key: string): { allowed: boolean; retryAfterSeconds?: number } {
|
|
219
|
+
const current = this.attempts.get(key);
|
|
220
|
+
if (!current) {
|
|
221
|
+
return { allowed: true };
|
|
222
|
+
}
|
|
223
|
+
if (current.lockedUntil && Date.now() < current.lockedUntil) {
|
|
224
|
+
return {
|
|
225
|
+
allowed: false,
|
|
226
|
+
retryAfterSeconds: Math.ceil((current.lockedUntil - Date.now()) / 1000),
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
return { allowed: true };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
registerFailure(key: string): { locked: boolean; retryAfterSeconds?: number } {
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const current = this.attempts.get(key);
|
|
235
|
+
if (!current || now - current.firstFailureAt > this.windowMs) {
|
|
236
|
+
this.attempts.set(key, { count: 1, firstFailureAt: now });
|
|
237
|
+
return { locked: false };
|
|
238
|
+
}
|
|
239
|
+
const count = current.count + 1;
|
|
240
|
+
const next: LoginAttemptState = {
|
|
241
|
+
...current,
|
|
242
|
+
count,
|
|
243
|
+
};
|
|
244
|
+
if (count >= this.maxAttempts) {
|
|
245
|
+
next.lockedUntil = now + this.lockoutMs;
|
|
246
|
+
this.attempts.set(key, next);
|
|
247
|
+
return { locked: true, retryAfterSeconds: Math.ceil(this.lockoutMs / 1000) };
|
|
248
|
+
}
|
|
249
|
+
this.attempts.set(key, next);
|
|
250
|
+
return { locked: false };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
registerSuccess(key: string): void {
|
|
254
|
+
this.attempts.delete(key);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export const parseCookies = (request: IncomingMessage): Record<string, string> => {
|
|
259
|
+
const cookieHeader = request.headers.cookie ?? "";
|
|
260
|
+
const pairs = cookieHeader
|
|
261
|
+
.split(";")
|
|
262
|
+
.map((part) => part.trim())
|
|
263
|
+
.filter(Boolean);
|
|
264
|
+
const cookies: Record<string, string> = {};
|
|
265
|
+
for (const pair of pairs) {
|
|
266
|
+
const index = pair.indexOf("=");
|
|
267
|
+
if (index <= 0) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const key = pair.slice(0, index);
|
|
271
|
+
const value = pair.slice(index + 1);
|
|
272
|
+
try {
|
|
273
|
+
cookies[key] = decodeURIComponent(value);
|
|
274
|
+
} catch {
|
|
275
|
+
cookies[key] = value;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return cookies;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
export const setCookie = (
|
|
282
|
+
response: ServerResponse,
|
|
283
|
+
name: string,
|
|
284
|
+
value: string,
|
|
285
|
+
options: {
|
|
286
|
+
httpOnly?: boolean;
|
|
287
|
+
secure?: boolean;
|
|
288
|
+
sameSite?: "Lax" | "Strict" | "None";
|
|
289
|
+
path?: string;
|
|
290
|
+
maxAge?: number;
|
|
291
|
+
},
|
|
292
|
+
): void => {
|
|
293
|
+
const segments = [`${name}=${encodeURIComponent(value)}`];
|
|
294
|
+
segments.push(`Path=${options.path ?? "/"}`);
|
|
295
|
+
if (typeof options.maxAge === "number") {
|
|
296
|
+
segments.push(`Max-Age=${Math.max(0, Math.floor(options.maxAge))}`);
|
|
297
|
+
}
|
|
298
|
+
if (options.httpOnly) {
|
|
299
|
+
segments.push("HttpOnly");
|
|
300
|
+
}
|
|
301
|
+
if (options.secure) {
|
|
302
|
+
segments.push("Secure");
|
|
303
|
+
}
|
|
304
|
+
if (options.sameSite) {
|
|
305
|
+
segments.push(`SameSite=${options.sameSite}`);
|
|
306
|
+
}
|
|
307
|
+
const previous = response.getHeader("Set-Cookie");
|
|
308
|
+
const serialized = segments.join("; ");
|
|
309
|
+
if (!previous) {
|
|
310
|
+
response.setHeader("Set-Cookie", serialized);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (Array.isArray(previous)) {
|
|
314
|
+
response.setHeader("Set-Cookie", [...previous, serialized]);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
response.setHeader("Set-Cookie", [String(previous), serialized]);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export const verifyPassphrase = (provided: string, expected: string): boolean => {
|
|
321
|
+
const providedBuffer = Buffer.from(provided);
|
|
322
|
+
const expectedBuffer = Buffer.from(expected);
|
|
323
|
+
if (providedBuffer.length !== expectedBuffer.length) {
|
|
324
|
+
const zero = Buffer.alloc(expectedBuffer.length);
|
|
325
|
+
return timingSafeEqual(expectedBuffer, zero) && false;
|
|
326
|
+
}
|
|
327
|
+
return timingSafeEqual(providedBuffer, expectedBuffer);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
export const getRequestIp = (request: IncomingMessage): string => {
|
|
331
|
+
return request.socket.remoteAddress ?? "unknown";
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
export const inferConversationTitle = (text: string): string => {
|
|
335
|
+
const normalized = text.trim().replace(/\s+/g, " ");
|
|
336
|
+
if (!normalized) {
|
|
337
|
+
return "New conversation";
|
|
338
|
+
}
|
|
339
|
+
return normalized.length <= 48 ? normalized : `${normalized.slice(0, 48)}...`;
|
|
340
|
+
};
|