@promptbook/cli 0.112.0-96 → 0.112.0-97

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 (62) hide show
  1. package/apps/agents-server/README.md +3 -3
  2. package/apps/agents-server/playwright.config.ts +2 -1
  3. package/apps/agents-server/src/app/admin/code-runners/CodeRunnersClient.tsx +358 -19
  4. package/apps/agents-server/src/app/admin/database/page.tsx +2 -1
  5. package/apps/agents-server/src/app/admin/servers/CreateServerDialog.tsx +46 -505
  6. package/apps/agents-server/src/app/admin/servers/ServersClient.tsx +23 -11
  7. package/apps/agents-server/src/app/admin/servers/ServersRegistryApi.ts +5 -0
  8. package/apps/agents-server/src/app/admin/servers/ServersRegistryDnsTypes.ts +87 -0
  9. package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +258 -128
  10. package/apps/agents-server/src/app/admin/servers/useCreateServerWizard.ts +46 -334
  11. package/apps/agents-server/src/app/admin/servers/useServersRegistryState.ts +26 -2
  12. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +435 -0
  13. package/apps/agents-server/src/app/admin/update/page.tsx +14 -0
  14. package/apps/agents-server/src/app/api/admin/code-runners/authentication/route.ts +197 -0
  15. package/apps/agents-server/src/app/api/admin/code-runners/route.ts +4 -35
  16. package/apps/agents-server/src/app/api/admin/servers/[serverId]/route.ts +10 -5
  17. package/apps/agents-server/src/app/api/admin/servers/route.ts +97 -6
  18. package/apps/agents-server/src/app/api/admin/update/route.ts +52 -0
  19. package/apps/agents-server/src/app/api/auth/login/route.ts +8 -0
  20. package/apps/agents-server/src/app/api/auth/logout/route.ts +10 -2
  21. package/apps/agents-server/src/app/page.tsx +10 -0
  22. package/apps/agents-server/src/components/Header/buildHeaderSystemMenuItems.ts +6 -0
  23. package/apps/agents-server/src/database/$provideClientSql.ts +4 -17
  24. package/apps/agents-server/src/database/$provideDatabaseAdminExecutor.ts +3 -24
  25. package/apps/agents-server/src/database/$providePostgresPool.ts +27 -0
  26. package/apps/agents-server/src/database/$provideSupabaseForServer.ts +11 -1
  27. package/apps/agents-server/src/database/agentsServerDatabaseMode.ts +20 -1
  28. package/apps/agents-server/src/database/postgres/$provideLocalPostgresSupabase.ts +1261 -0
  29. package/apps/agents-server/src/languages/ServerTranslationKeys.ts +1 -0
  30. package/apps/agents-server/src/languages/translations/czech.yaml +1 -0
  31. package/apps/agents-server/src/languages/translations/english.yaml +1 -0
  32. package/apps/agents-server/src/middleware.ts +32 -0
  33. package/apps/agents-server/src/tools/$provideServer.ts +2 -2
  34. package/apps/agents-server/src/utils/codeRunnerAuthentication.ts +394 -0
  35. package/apps/agents-server/src/utils/codeRunnerConfiguration.ts +67 -0
  36. package/apps/agents-server/src/utils/serverManagement/standaloneVpsServerMetadata.ts +145 -0
  37. package/apps/agents-server/src/utils/serverRegistry.ts +7 -6
  38. package/apps/agents-server/src/utils/session.ts +37 -9
  39. package/apps/agents-server/src/utils/shibboleth/createShibbolethAuthenticationLogPayload.ts +173 -0
  40. package/apps/agents-server/src/utils/shibboleth/writeShibbolethAuthenticationLog.ts +27 -0
  41. package/apps/agents-server/src/utils/standaloneVpsDnsDiagnostics.ts +258 -0
  42. package/apps/agents-server/src/utils/standaloneVpsRawIpBootstrap.ts +87 -0
  43. package/apps/agents-server/src/utils/vpsConfiguration.ts +87 -15
  44. package/apps/agents-server/src/utils/vpsSelfUpdate.ts +664 -0
  45. package/esm/apps/agents-server/src/database/agentsServerDatabaseMode.d.ts +9 -1
  46. package/esm/apps/agents-server/src/utils/serverRegistry.d.ts +1 -1
  47. package/esm/index.es.js +8 -6
  48. package/esm/index.es.js.map +1 -1
  49. package/esm/src/version.d.ts +1 -1
  50. package/package.json +1 -1
  51. package/src/book-components/Chat/utils/renderMarkdown.ts +1 -3
  52. package/src/cli/cli-commands/agents-server/ensureAgentsServerEnvFile.ts +1 -1
  53. package/src/other/templates/getTemplatesPipelineCollection.ts +698 -755
  54. package/src/scrapers/document/DocumentScraper.ts +1 -1
  55. package/src/scrapers/document-legacy/LegacyDocumentScraper.ts +1 -1
  56. package/src/version.ts +2 -2
  57. package/src/versions.txt +1 -0
  58. package/umd/apps/agents-server/src/database/agentsServerDatabaseMode.d.ts +9 -1
  59. package/umd/apps/agents-server/src/utils/serverRegistry.d.ts +1 -1
  60. package/umd/index.umd.js +8 -6
  61. package/umd/index.umd.js.map +1 -1
  62. package/umd/src/version.d.ts +1 -1
@@ -0,0 +1,435 @@
1
+ 'use client';
2
+
3
+ import { CheckCircle2, Download, Loader2, RefreshCcw, Rocket, Server, TriangleAlert } from 'lucide-react';
4
+ import { useCallback, useEffect, useMemo, useState } from 'react';
5
+ import { Card } from '../../../components/Homepage/Card';
6
+
7
+ /**
8
+ * Browser-safe environment option returned by the update API.
9
+ */
10
+ type UpdateEnvironmentOption = {
11
+ readonly id: string;
12
+ readonly branch: string;
13
+ readonly label: string;
14
+ readonly description: string;
15
+ };
16
+
17
+ /**
18
+ * Browser-safe latest update-job snapshot.
19
+ */
20
+ type UpdateJobSnapshot = {
21
+ readonly status: 'idle' | 'running' | 'succeeded' | 'failed';
22
+ readonly pid: number | null;
23
+ readonly targetBranch: string | null;
24
+ readonly targetEnvironment: UpdateEnvironmentOption;
25
+ readonly currentStep: string | null;
26
+ readonly currentCommitSha: string | null;
27
+ readonly targetCommitSha: string | null;
28
+ readonly errorMessage: string | null;
29
+ readonly startedAt: string | null;
30
+ readonly finishedAt: string | null;
31
+ readonly isStale: boolean;
32
+ readonly logTail: string | null;
33
+ readonly logFilePath: string | null;
34
+ };
35
+
36
+ /**
37
+ * Browser-safe self-update overview returned by the super-admin API.
38
+ */
39
+ type UpdateOverview = {
40
+ readonly isAvailable: boolean;
41
+ readonly unavailableReason: string | null;
42
+ readonly environments: ReadonlyArray<UpdateEnvironmentOption>;
43
+ readonly currentEnvironment: UpdateEnvironmentOption;
44
+ readonly repositoryDirectory: string | null;
45
+ readonly currentCommitSha: string | null;
46
+ readonly currentCommitShortSha: string | null;
47
+ readonly currentCommitMessage: string | null;
48
+ readonly latestRemoteCommitSha: string | null;
49
+ readonly latestRemoteCommitShortSha: string | null;
50
+ readonly isUpdateAvailable: boolean;
51
+ readonly job: UpdateJobSnapshot;
52
+ readonly error?: string;
53
+ };
54
+
55
+ /**
56
+ * Client UI for standalone VPS branch-aware self-updates.
57
+ */
58
+ export function UpdateClient() {
59
+ const [overview, setOverview] = useState<UpdateOverview | null>(null);
60
+ const [selectedEnvironmentId, setSelectedEnvironmentId] = useState<string>('');
61
+ const [isLoading, setIsLoading] = useState(true);
62
+ const [isStartingUpdate, setIsStartingUpdate] = useState(false);
63
+ const [errorMessage, setErrorMessage] = useState<string | null>(null);
64
+ const [successMessage, setSuccessMessage] = useState<string | null>(null);
65
+
66
+ /**
67
+ * Loads the latest self-update overview from the server.
68
+ */
69
+ const loadOverview = useCallback(async (options?: { readonly isSilent?: boolean }): Promise<void> => {
70
+ try {
71
+ if (!options?.isSilent) {
72
+ setIsLoading(true);
73
+ }
74
+ setErrorMessage(null);
75
+
76
+ const response = await fetch('/api/admin/update', { cache: 'no-store' });
77
+ const payload = (await response.json()) as UpdateOverview;
78
+
79
+ if (!response.ok) {
80
+ throw new Error(payload.error || 'Failed to load the update overview.');
81
+ }
82
+
83
+ setOverview(payload);
84
+ setSelectedEnvironmentId((currentSelectedEnvironmentId) =>
85
+ currentSelectedEnvironmentId || payload.currentEnvironment.id,
86
+ );
87
+ } catch (error) {
88
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to load the update overview.');
89
+ } finally {
90
+ if (!options?.isSilent) {
91
+ setIsLoading(false);
92
+ }
93
+ }
94
+ }, []);
95
+
96
+ useEffect(() => {
97
+ void loadOverview();
98
+ }, [loadOverview]);
99
+
100
+ useEffect(() => {
101
+ if (overview?.job.status !== 'running') {
102
+ return;
103
+ }
104
+
105
+ const interval = window.setInterval(() => {
106
+ void loadOverview({ isSilent: true });
107
+ }, 4000);
108
+
109
+ return () => {
110
+ window.clearInterval(interval);
111
+ };
112
+ }, [loadOverview, overview?.job.status]);
113
+
114
+ const selectedEnvironment = useMemo(
115
+ () =>
116
+ overview?.environments.find((environment) => environment.id === selectedEnvironmentId) ||
117
+ overview?.currentEnvironment ||
118
+ null,
119
+ [overview, selectedEnvironmentId],
120
+ );
121
+ const isEnvironmentSwitchRequired =
122
+ Boolean(selectedEnvironment) && selectedEnvironment?.id !== overview?.currentEnvironment.id;
123
+ const isUpdateRunning = overview?.job.status === 'running';
124
+
125
+ /**
126
+ * Starts one detached update run for the selected environment.
127
+ */
128
+ async function startUpdate(): Promise<void> {
129
+ if (!selectedEnvironment) {
130
+ return;
131
+ }
132
+
133
+ try {
134
+ setIsStartingUpdate(true);
135
+ setErrorMessage(null);
136
+ setSuccessMessage(null);
137
+
138
+ const response = await fetch('/api/admin/update', {
139
+ method: 'POST',
140
+ headers: {
141
+ 'Content-Type': 'application/json',
142
+ },
143
+ body: JSON.stringify({
144
+ environment: selectedEnvironment.id,
145
+ }),
146
+ });
147
+ const payload = (await response.json()) as UpdateOverview;
148
+
149
+ if (!response.ok) {
150
+ throw new Error(payload.error || 'Failed to start the update.');
151
+ }
152
+
153
+ setOverview(payload);
154
+ setSuccessMessage(
155
+ isEnvironmentSwitchRequired
156
+ ? `Switched to ${selectedEnvironment.label} and started the standalone VPS update.`
157
+ : 'Standalone VPS update started.',
158
+ );
159
+ } catch (error) {
160
+ setErrorMessage(error instanceof Error ? error.message : 'Failed to start the update.');
161
+ } finally {
162
+ setIsStartingUpdate(false);
163
+ }
164
+ }
165
+
166
+ return (
167
+ <div className="container mx-auto space-y-6 px-4 py-8">
168
+ <div className="mt-20 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
169
+ <div>
170
+ <h1 className="text-3xl font-light text-gray-900">Update</h1>
171
+ <p className="mt-1 max-w-3xl text-sm text-gray-500">
172
+ Switch the standalone VPS between Production, Live, Preview, and LTS, and update the managed
173
+ Promptbook checkout with one click.
174
+ </p>
175
+ </div>
176
+
177
+ <button
178
+ type="button"
179
+ onClick={() => void loadOverview()}
180
+ disabled={isLoading || isStartingUpdate}
181
+ className="inline-flex items-center gap-2 rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-60"
182
+ >
183
+ <RefreshCcw className={`h-4 w-4 ${isLoading ? 'animate-spin' : ''}`} />
184
+ Refresh
185
+ </button>
186
+ </div>
187
+
188
+ {errorMessage && (
189
+ <div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
190
+ {errorMessage}
191
+ </div>
192
+ )}
193
+ {successMessage && (
194
+ <div className="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-700">
195
+ {successMessage}
196
+ </div>
197
+ )}
198
+
199
+ {!overview?.isAvailable && overview?.unavailableReason && (
200
+ <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
201
+ {overview.unavailableReason}
202
+ </div>
203
+ )}
204
+
205
+ <div className="grid gap-4 lg:grid-cols-[1.4fr_1fr]">
206
+ <Card className="hover:border-gray-200 hover:shadow-md">
207
+ <div className="space-y-4">
208
+ <div className="flex items-start gap-3">
209
+ <Server className="mt-0.5 h-5 w-5 text-blue-600" />
210
+ <div>
211
+ <h2 className="text-lg font-semibold text-slate-900">Current deployment</h2>
212
+ <p className="mt-1 text-sm text-slate-500">
213
+ The server currently tracks the <span className="font-medium">{overview?.currentEnvironment.label || 'Production'}</span>{' '}
214
+ environment on branch <span className="font-mono">{overview?.currentEnvironment.branch || 'production'}</span>.
215
+ </p>
216
+ </div>
217
+ </div>
218
+
219
+ <dl className="grid gap-4 text-sm text-slate-600 sm:grid-cols-2">
220
+ <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
221
+ <dt className="text-xs font-semibold uppercase tracking-wide text-slate-500">Branch</dt>
222
+ <dd className="mt-1 font-mono text-slate-900">
223
+ {overview?.currentEnvironment.branch || 'production'}
224
+ </dd>
225
+ </div>
226
+ <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
227
+ <dt className="text-xs font-semibold uppercase tracking-wide text-slate-500">
228
+ Deployed commit
229
+ </dt>
230
+ <dd className="mt-1 font-mono text-slate-900">
231
+ {overview?.currentCommitShortSha || 'Unknown'}
232
+ </dd>
233
+ </div>
234
+ <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
235
+ <dt className="text-xs font-semibold uppercase tracking-wide text-slate-500">
236
+ Latest remote commit
237
+ </dt>
238
+ <dd className="mt-1 font-mono text-slate-900">
239
+ {overview?.latestRemoteCommitShortSha || 'Unknown'}
240
+ </dd>
241
+ </div>
242
+ <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
243
+ <dt className="text-xs font-semibold uppercase tracking-wide text-slate-500">
244
+ Update availability
245
+ </dt>
246
+ <dd className="mt-1 flex items-center gap-2 text-slate-900">
247
+ {overview?.isUpdateAvailable ? (
248
+ <>
249
+ <TriangleAlert className="h-4 w-4 text-amber-500" />
250
+ New commit available
251
+ </>
252
+ ) : (
253
+ <>
254
+ <CheckCircle2 className="h-4 w-4 text-emerald-600" />
255
+ Up to date
256
+ </>
257
+ )}
258
+ </dd>
259
+ </div>
260
+ </dl>
261
+
262
+ {overview?.currentCommitMessage && (
263
+ <div className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-600">
264
+ <div className="text-xs font-semibold uppercase tracking-wide text-slate-500">
265
+ Current commit message
266
+ </div>
267
+ <div className="mt-1 text-slate-900">{overview.currentCommitMessage}</div>
268
+ </div>
269
+ )}
270
+
271
+ {overview?.repositoryDirectory && (
272
+ <div className="text-xs text-slate-500">
273
+ Managed repository:
274
+ <span className="ml-2 font-mono text-slate-700">{overview.repositoryDirectory}</span>
275
+ </div>
276
+ )}
277
+ </div>
278
+ </Card>
279
+
280
+ <Card className="hover:border-gray-200 hover:shadow-md">
281
+ <div className="space-y-4">
282
+ <div>
283
+ <h2 className="text-lg font-semibold text-slate-900">Target environment</h2>
284
+ <p className="mt-1 text-sm text-slate-500">
285
+ Selecting another environment automatically updates the server to the latest commit on
286
+ that branch.
287
+ </p>
288
+ </div>
289
+
290
+ <div className="grid gap-3">
291
+ {overview?.environments.map((environment) => {
292
+ const isSelected = environment.id === selectedEnvironment?.id;
293
+ const isCurrent = environment.id === overview.currentEnvironment.id;
294
+
295
+ return (
296
+ <button
297
+ key={environment.id}
298
+ type="button"
299
+ onClick={() => setSelectedEnvironmentId(environment.id)}
300
+ disabled={isUpdateRunning || isStartingUpdate}
301
+ className={`rounded-2xl border px-4 py-4 text-left transition ${
302
+ isSelected
303
+ ? 'border-blue-300 bg-blue-50 text-blue-900 shadow-sm'
304
+ : 'border-slate-200 bg-white text-slate-700 hover:border-slate-300 hover:shadow-sm'
305
+ } disabled:cursor-not-allowed disabled:opacity-60`}
306
+ >
307
+ <div className="flex items-center justify-between gap-3">
308
+ <div className="text-sm font-semibold">{environment.label}</div>
309
+ {isCurrent && (
310
+ <span className="rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
311
+ Current
312
+ </span>
313
+ )}
314
+ </div>
315
+ <div className="mt-1 font-mono text-xs">{environment.branch}</div>
316
+ <div className="mt-2 text-sm opacity-80">{environment.description}</div>
317
+ </button>
318
+ );
319
+ })}
320
+ </div>
321
+
322
+ <button
323
+ type="button"
324
+ onClick={() => void startUpdate()}
325
+ disabled={
326
+ !overview?.isAvailable ||
327
+ !selectedEnvironment ||
328
+ isUpdateRunning ||
329
+ isStartingUpdate ||
330
+ (!isEnvironmentSwitchRequired && !overview?.isUpdateAvailable)
331
+ }
332
+ className="inline-flex w-full items-center justify-center gap-2 rounded-md bg-blue-600 px-4 py-3 text-sm font-semibold text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
333
+ >
334
+ {isStartingUpdate || isUpdateRunning ? (
335
+ <Loader2 className="h-4 w-4 animate-spin" />
336
+ ) : isEnvironmentSwitchRequired ? (
337
+ <Rocket className="h-4 w-4" />
338
+ ) : (
339
+ <Download className="h-4 w-4" />
340
+ )}
341
+ {isEnvironmentSwitchRequired
342
+ ? `Switch to ${selectedEnvironment?.label || 'selected environment'} and update`
343
+ : 'Update to latest commit'}
344
+ </button>
345
+ </div>
346
+ </Card>
347
+ </div>
348
+
349
+ <Card className="hover:border-gray-200 hover:shadow-md">
350
+ <div className="space-y-4">
351
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
352
+ <div>
353
+ <h2 className="text-lg font-semibold text-slate-900">Update job</h2>
354
+ <p className="mt-1 text-sm text-slate-500">
355
+ The update runs in the background so the browser request can finish cleanly before pm2
356
+ restarts the server.
357
+ </p>
358
+ </div>
359
+ <span
360
+ className={`inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold uppercase tracking-wide ${
361
+ overview?.job.status === 'running'
362
+ ? 'border-blue-200 bg-blue-50 text-blue-700'
363
+ : overview?.job.status === 'failed'
364
+ ? 'border-rose-200 bg-rose-50 text-rose-700'
365
+ : overview?.job.status === 'succeeded'
366
+ ? 'border-emerald-200 bg-emerald-50 text-emerald-700'
367
+ : 'border-slate-200 bg-slate-50 text-slate-500'
368
+ }`}
369
+ >
370
+ {overview?.job.status || 'idle'}
371
+ </span>
372
+ </div>
373
+
374
+ <dl className="grid gap-4 text-sm text-slate-600 md:grid-cols-2 xl:grid-cols-4">
375
+ <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
376
+ <dt className="text-xs font-semibold uppercase tracking-wide text-slate-500">Target</dt>
377
+ <dd className="mt-1 text-slate-900">{overview?.job.targetEnvironment.label || 'Production'}</dd>
378
+ </div>
379
+ <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
380
+ <dt className="text-xs font-semibold uppercase tracking-wide text-slate-500">Step</dt>
381
+ <dd className="mt-1 text-slate-900">{overview?.job.currentStep || 'Idle'}</dd>
382
+ </div>
383
+ <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
384
+ <dt className="text-xs font-semibold uppercase tracking-wide text-slate-500">Started</dt>
385
+ <dd className="mt-1 text-slate-900">{formatTimestamp(overview?.job.startedAt)}</dd>
386
+ </div>
387
+ <div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
388
+ <dt className="text-xs font-semibold uppercase tracking-wide text-slate-500">Finished</dt>
389
+ <dd className="mt-1 text-slate-900">{formatTimestamp(overview?.job.finishedAt)}</dd>
390
+ </div>
391
+ </dl>
392
+
393
+ {overview?.job.errorMessage && (
394
+ <div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
395
+ {overview.job.errorMessage}
396
+ </div>
397
+ )}
398
+ {overview?.job.isStale && (
399
+ <div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
400
+ The previous background update process stopped unexpectedly. You can start the update again.
401
+ </div>
402
+ )}
403
+ {overview?.job.logFilePath && (
404
+ <div className="text-xs text-slate-500">
405
+ Installer log:
406
+ <span className="ml-2 font-mono text-slate-700">{overview.job.logFilePath}</span>
407
+ </div>
408
+ )}
409
+ <pre className="max-h-[28rem] overflow-auto rounded-xl border border-slate-200 bg-slate-950 p-4 text-xs text-slate-100">
410
+ {overview?.job.logTail || 'No persisted update log output yet.'}
411
+ </pre>
412
+ </div>
413
+ </Card>
414
+ </div>
415
+ );
416
+ }
417
+
418
+ /**
419
+ * Formats optional timestamps for the status cards.
420
+ *
421
+ * @param value - ISO timestamp or `null`.
422
+ * @returns Human-friendly timestamp or fallback text.
423
+ */
424
+ function formatTimestamp(value: string | null | undefined): string {
425
+ if (!value) {
426
+ return 'Not available';
427
+ }
428
+
429
+ const date = new Date(value);
430
+ if (Number.isNaN(date.getTime())) {
431
+ return value;
432
+ }
433
+
434
+ return `${date.toLocaleString()} (${value})`;
435
+ }
@@ -0,0 +1,14 @@
1
+ import { ForbiddenPage } from '../../../components/ForbiddenPage/ForbiddenPage';
2
+ import { isUserGlobalAdmin } from '../../../utils/isUserGlobalAdmin';
3
+ import { UpdateClient } from './UpdateClient';
4
+
5
+ /**
6
+ * Super-admin page for branch-aware standalone VPS self-updates.
7
+ */
8
+ export default async function UpdatePage() {
9
+ if (!(await isUserGlobalAdmin())) {
10
+ return <ForbiddenPage />;
11
+ }
12
+
13
+ return <UpdateClient />;
14
+ }
@@ -0,0 +1,197 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { isUserGlobalAdmin } from '@/src/utils/isUserGlobalAdmin';
3
+ import {
4
+ getCodeRunnerAuthenticationSession,
5
+ getLatestCodeRunnerAuthenticationSession,
6
+ startCodeRunnerAuthenticationSession,
7
+ stopCodeRunnerAuthenticationSession,
8
+ subscribeToCodeRunnerAuthenticationSession,
9
+ writeCodeRunnerAuthenticationSessionInput,
10
+ } from '@/src/utils/codeRunnerAuthentication';
11
+ import { readConfiguredCodeRunner } from '@/src/utils/codeRunnerConfiguration';
12
+
13
+ export const runtime = 'nodejs';
14
+ export const dynamic = 'force-dynamic';
15
+
16
+ /**
17
+ * Loads the latest authentication session for the saved runner or streams a specific session.
18
+ */
19
+ export async function GET(request: Request) {
20
+ if (!(await isUserGlobalAdmin())) {
21
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
22
+ }
23
+
24
+ try {
25
+ const { searchParams } = new URL(request.url);
26
+ const sessionId = searchParams.get('sessionId')?.trim() || '';
27
+ const isStreamRequested = searchParams.get('stream') === '1';
28
+
29
+ if (isStreamRequested) {
30
+ if (!sessionId) {
31
+ return NextResponse.json({ error: 'Authentication session id is required.' }, { status: 400 });
32
+ }
33
+
34
+ const session = getCodeRunnerAuthenticationSession(sessionId);
35
+ if (!session) {
36
+ return NextResponse.json({ error: 'Authentication session was not found.' }, { status: 404 });
37
+ }
38
+
39
+ return createCodeRunnerAuthenticationEventStream(request, sessionId, session);
40
+ }
41
+
42
+ const { agent } = await readConfiguredCodeRunner();
43
+ return NextResponse.json({
44
+ session: getLatestCodeRunnerAuthenticationSession(agent),
45
+ });
46
+ } catch (error) {
47
+ return NextResponse.json(
48
+ { error: error instanceof Error ? error.message : 'Failed to load the authentication session.' },
49
+ { status: 500 },
50
+ );
51
+ }
52
+ }
53
+
54
+ /**
55
+ * Starts a new browser-driven authentication terminal for the saved runner.
56
+ */
57
+ export async function POST() {
58
+ if (!(await isUserGlobalAdmin())) {
59
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
60
+ }
61
+
62
+ try {
63
+ const { agent } = await readConfiguredCodeRunner();
64
+ return NextResponse.json({
65
+ session: await startCodeRunnerAuthenticationSession(agent),
66
+ });
67
+ } catch (error) {
68
+ return NextResponse.json(
69
+ { error: error instanceof Error ? error.message : 'Failed to start the authentication session.' },
70
+ { status: 500 },
71
+ );
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Sends terminal input to a running authentication session.
77
+ */
78
+ export async function PATCH(request: Request) {
79
+ if (!(await isUserGlobalAdmin())) {
80
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
81
+ }
82
+
83
+ try {
84
+ const body = (await request.json().catch(() => null)) as
85
+ | {
86
+ readonly sessionId?: string;
87
+ readonly input?: string;
88
+ }
89
+ | null;
90
+
91
+ if (!body?.sessionId || typeof body.input !== 'string') {
92
+ return NextResponse.json({ error: 'Authentication session input is required.' }, { status: 400 });
93
+ }
94
+
95
+ return NextResponse.json({
96
+ session: writeCodeRunnerAuthenticationSessionInput(body.sessionId, body.input),
97
+ });
98
+ } catch (error) {
99
+ return NextResponse.json(
100
+ { error: error instanceof Error ? error.message : 'Failed to send authentication input.' },
101
+ { status: 500 },
102
+ );
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Stops one authentication terminal from the admin UI.
108
+ */
109
+ export async function DELETE(request: Request) {
110
+ if (!(await isUserGlobalAdmin())) {
111
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
112
+ }
113
+
114
+ try {
115
+ const body = (await request.json().catch(() => null)) as
116
+ | {
117
+ readonly sessionId?: string;
118
+ }
119
+ | null;
120
+
121
+ if (!body?.sessionId) {
122
+ return NextResponse.json({ error: 'Authentication session id is required.' }, { status: 400 });
123
+ }
124
+
125
+ return NextResponse.json({
126
+ session: stopCodeRunnerAuthenticationSession(body.sessionId),
127
+ });
128
+ } catch (error) {
129
+ return NextResponse.json(
130
+ { error: error instanceof Error ? error.message : 'Failed to stop the authentication session.' },
131
+ { status: 500 },
132
+ );
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Creates one SSE response that replays buffered output and then streams live terminal events.
138
+ *
139
+ * @param request - Browser stream request.
140
+ * @param sessionId - Authentication session id.
141
+ * @param session - Existing session snapshot.
142
+ * @returns Event stream response.
143
+ */
144
+ function createCodeRunnerAuthenticationEventStream(
145
+ request: Request,
146
+ sessionId: string,
147
+ session: NonNullable<ReturnType<typeof getCodeRunnerAuthenticationSession>>,
148
+ ): Response {
149
+ const encoder = new TextEncoder();
150
+
151
+ return new Response(
152
+ new ReadableStream({
153
+ start(controller) {
154
+ const emitEvent = (event: string, payload: unknown) => {
155
+ controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`));
156
+ };
157
+
158
+ emitEvent('snapshot', session);
159
+
160
+ if (!session.isRunning) {
161
+ controller.close();
162
+ return;
163
+ }
164
+
165
+ const unsubscribe = subscribeToCodeRunnerAuthenticationSession(sessionId, {
166
+ onOutput: ({ chunk }) => emitEvent('output', { chunk }),
167
+ onExit: ({ snapshot: nextSession }) => {
168
+ emitEvent('exit', nextSession);
169
+ unsubscribe?.();
170
+ controller.close();
171
+ },
172
+ });
173
+
174
+ if (!unsubscribe) {
175
+ controller.error(new Error('Authentication session was not found.'));
176
+ return;
177
+ }
178
+
179
+ request.signal.addEventListener(
180
+ 'abort',
181
+ () => {
182
+ unsubscribe();
183
+ controller.close();
184
+ },
185
+ { once: true },
186
+ );
187
+ },
188
+ }),
189
+ {
190
+ headers: {
191
+ 'Content-Type': 'text/event-stream',
192
+ 'Cache-Control': 'no-cache, no-transform',
193
+ Connection: 'keep-alive',
194
+ },
195
+ },
196
+ );
197
+ }