@promptbook/cli 0.112.0-93 → 0.112.0-96

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 (52) hide show
  1. package/apps/agents-server/next.config.ts +8 -1
  2. package/apps/agents-server/playwright.config.ts +2 -0
  3. package/apps/agents-server/src/app/admin/_components/AdminConfigurationShell.tsx +13 -7
  4. package/apps/agents-server/src/app/admin/code-runners/CodeRunnersClient.tsx +225 -0
  5. package/apps/agents-server/src/app/admin/code-runners/page.tsx +14 -0
  6. package/apps/agents-server/src/app/admin/database/DatabaseAdminClient.tsx +38 -0
  7. package/apps/agents-server/src/app/admin/database/DatabaseAdminStudioSurface.tsx +42 -0
  8. package/apps/agents-server/src/app/admin/database/page.tsx +33 -0
  9. package/apps/agents-server/src/app/admin/environment/EnvironmentVariablesClient.tsx +259 -0
  10. package/apps/agents-server/src/app/admin/environment/page.tsx +21 -0
  11. package/apps/agents-server/src/app/admin/logs/LogsClient.tsx +78 -0
  12. package/apps/agents-server/src/app/admin/logs/page.tsx +14 -0
  13. package/apps/agents-server/src/app/admin/servers/ServersClient.tsx +64 -33
  14. package/apps/agents-server/src/app/admin/servers/ServersRegistryApi.ts +5 -0
  15. package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +15 -2
  16. package/apps/agents-server/src/app/admin/servers/page.tsx +3 -3
  17. package/apps/agents-server/src/app/admin/servers/useServersRegistryState.ts +12 -2
  18. package/apps/agents-server/src/app/api/admin/code-runners/route.ts +104 -0
  19. package/apps/agents-server/src/app/api/admin/database/studio/route.ts +113 -0
  20. package/apps/agents-server/src/app/api/admin/environment/route.ts +65 -0
  21. package/apps/agents-server/src/app/api/admin/logs/route.ts +24 -0
  22. package/apps/agents-server/src/app/api/admin/servers/[serverId]/route.ts +79 -3
  23. package/apps/agents-server/src/app/api/admin/servers/route.ts +36 -1
  24. package/apps/agents-server/src/app/layout.tsx +1 -0
  25. package/apps/agents-server/src/app/page.tsx +101 -1
  26. package/apps/agents-server/src/components/Header/buildHeaderSystemMenuItems.ts +27 -4
  27. package/apps/agents-server/src/database/$provideClientSql.ts +2 -6
  28. package/apps/agents-server/src/database/$provideDatabaseAdminExecutor.ts +273 -0
  29. package/apps/agents-server/src/database/resolvePostgresConnectionString.ts +26 -0
  30. package/apps/agents-server/src/database/sqlite/$provideAgentsServerSqliteDatabase.ts +83 -0
  31. package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +20 -71
  32. package/apps/agents-server/src/languages/ServerTranslationKeys.ts +5 -0
  33. package/apps/agents-server/src/languages/translations/czech.yaml +5 -0
  34. package/apps/agents-server/src/languages/translations/english.yaml +5 -0
  35. package/apps/agents-server/src/tools/$provideServer.ts +27 -0
  36. package/apps/agents-server/src/utils/serverRegistry.ts +20 -1
  37. package/apps/agents-server/src/utils/session.ts +123 -2
  38. package/apps/agents-server/src/utils/vpsConfiguration.ts +550 -0
  39. package/esm/index.es.js +1 -1
  40. package/esm/index.es.js.map +1 -1
  41. package/esm/src/book-components/Chat/utils/renderMarkdown.test.d.ts +1 -0
  42. package/esm/src/version.d.ts +1 -1
  43. package/package.json +3 -1
  44. package/src/book-components/Chat/MarkdownContent/MarkdownContent.tsx +9 -398
  45. package/src/book-components/Chat/utils/renderMarkdown.ts +323 -8
  46. package/src/other/templates/getTemplatesPipelineCollection.ts +712 -829
  47. package/src/version.ts +2 -2
  48. package/src/versions.txt +2 -0
  49. package/umd/index.umd.js +1 -1
  50. package/umd/index.umd.js.map +1 -1
  51. package/umd/src/book-components/Chat/utils/renderMarkdown.test.d.ts +1 -0
  52. package/umd/src/version.d.ts +1 -1
@@ -138,6 +138,11 @@ type DeleteCurrentServerConfirmationResult = {
138
138
  * @private function of <ServersClient/>
139
139
  */
140
140
  type UseServersRegistryStateResult = {
141
+ /**
142
+ * Whether the current viewer can mutate server rows.
143
+ */
144
+ readonly canEdit: boolean;
145
+
141
146
  /**
142
147
  * Server resolved from the current request domain.
143
148
  */
@@ -429,12 +434,13 @@ function useServersRegistryDraftState(servers: ReadonlyArray<ManagedServerRow>)
429
434
  */
430
435
  function useServersRegistryReloadAction(options: {
431
436
  readonly replaceServerDrafts: (servers: ReadonlyArray<ManagedServerRow>) => void;
437
+ readonly setCanEdit: (canEdit: boolean) => void;
432
438
  readonly setCurrentServerId: (currentServerId: number | null) => void;
433
439
  readonly setError: (error: string | null) => void;
434
440
  readonly setLoading: (isLoading: boolean) => void;
435
441
  readonly setServers: (servers: ManagedServerRow[]) => void;
436
442
  }) {
437
- const { replaceServerDrafts, setCurrentServerId, setError, setLoading, setServers } = options;
443
+ const { replaceServerDrafts, setCanEdit, setCurrentServerId, setError, setLoading, setServers } = options;
438
444
 
439
445
  return useCallback(async () => {
440
446
  setLoading(true);
@@ -444,13 +450,14 @@ function useServersRegistryReloadAction(options: {
444
450
  const payload = await ServersRegistryApi.fetchServers();
445
451
  setServers([...payload.servers]);
446
452
  setCurrentServerId(payload.currentServerId);
453
+ setCanEdit(payload.canEdit);
447
454
  replaceServerDrafts(payload.servers);
448
455
  } catch (loadError) {
449
456
  setError(resolveServersRegistryActionErrorMessage(loadError, 'Failed to load servers.'));
450
457
  } finally {
451
458
  setLoading(false);
452
459
  }
453
- }, [replaceServerDrafts, setCurrentServerId, setError, setLoading, setServers]);
460
+ }, [replaceServerDrafts, setCanEdit, setCurrentServerId, setError, setLoading, setServers]);
454
461
  }
455
462
 
456
463
  /**
@@ -618,6 +625,7 @@ function useDeleteCurrentServerAction(options: {
618
625
  */
619
626
  export function useServersRegistryState(): UseServersRegistryStateResult {
620
627
  const [servers, setServers] = useState<ManagedServerRow[]>([]);
628
+ const [canEdit, setCanEdit] = useState(false);
621
629
  const [loading, setLoading] = useState(true);
622
630
  const [error, setError] = useState<string | null>(null);
623
631
  const [currentServerId, setCurrentServerId] = useState<number | null>(null);
@@ -635,6 +643,7 @@ export function useServersRegistryState(): UseServersRegistryStateResult {
635
643
 
636
644
  const reloadServers = useServersRegistryReloadAction({
637
645
  replaceServerDrafts,
646
+ setCanEdit,
638
647
  setCurrentServerId,
639
648
  setError,
640
649
  setLoading,
@@ -671,6 +680,7 @@ export function useServersRegistryState(): UseServersRegistryStateResult {
671
680
  });
672
681
 
673
682
  return {
683
+ canEdit,
674
684
  currentServer,
675
685
  currentServerId,
676
686
  deletingServerId,
@@ -0,0 +1,104 @@
1
+ import { execFile } from 'child_process';
2
+ import { promisify } from 'util';
3
+ import { NextResponse } from 'next/server';
4
+ import { isUserGlobalAdmin } from '@/src/utils/isUserGlobalAdmin';
5
+ import {
6
+ applyVpsCodeRunnerConfiguration,
7
+ listVpsEnvironmentVariables,
8
+ updateVpsEnvironmentVariables,
9
+ } from '@/src/utils/vpsConfiguration';
10
+
11
+ const execFileAsync = promisify(execFile);
12
+
13
+ /**
14
+ * Loads configured code-runner settings from the editable VPS environment.
15
+ */
16
+ export async function GET() {
17
+ if (!(await isUserGlobalAdmin())) {
18
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
19
+ }
20
+
21
+ try {
22
+ const snapshot = await listVpsEnvironmentVariables();
23
+ const environmentByKey = Object.fromEntries(
24
+ snapshot.variables.map((variable) => [variable.key, variable.value]),
25
+ ) as Record<string, string>;
26
+
27
+ return NextResponse.json({
28
+ agent: environmentByKey.PTBK_AGENT || process.env.PTBK_AGENT || 'github-copilot',
29
+ model: environmentByKey.PTBK_MODEL || process.env.PTBK_MODEL || 'gpt-5.4',
30
+ thinkingLevel: environmentByKey.PTBK_THINKING_LEVEL || process.env.PTBK_THINKING_LEVEL || 'xhigh',
31
+ status: await resolveRunnerStatus(environmentByKey.PTBK_AGENT || process.env.PTBK_AGENT || 'github-copilot'),
32
+ });
33
+ } catch (error) {
34
+ return NextResponse.json(
35
+ { error: error instanceof Error ? error.message : 'Failed to load code-runner configuration.' },
36
+ { status: 500 },
37
+ );
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Updates code-runner environment variables.
43
+ */
44
+ export async function PATCH(request: Request) {
45
+ if (!(await isUserGlobalAdmin())) {
46
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
47
+ }
48
+
49
+ try {
50
+ const body = (await request.json().catch(() => null)) as
51
+ | {
52
+ readonly agent?: string;
53
+ readonly model?: string;
54
+ readonly thinkingLevel?: string;
55
+ readonly applyRuntimeConfiguration?: boolean;
56
+ }
57
+ | null;
58
+
59
+ if (!body) {
60
+ return NextResponse.json({ error: 'Code-runner payload is required.' }, { status: 400 });
61
+ }
62
+
63
+ await updateVpsEnvironmentVariables({
64
+ PTBK_AGENT: body.agent || '',
65
+ PTBK_MODEL: body.model || '',
66
+ PTBK_THINKING_LEVEL: body.thinkingLevel || '',
67
+ });
68
+
69
+ const response = await GET();
70
+ const payload = (await response.json()) as Record<string, unknown>;
71
+
72
+ return NextResponse.json({
73
+ ...payload,
74
+ applyResult: body.applyRuntimeConfiguration ? await applyVpsCodeRunnerConfiguration() : null,
75
+ });
76
+ } catch (error) {
77
+ return NextResponse.json(
78
+ { error: error instanceof Error ? error.message : 'Failed to update code-runner configuration.' },
79
+ { status: 500 },
80
+ );
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Resolves a short runner-authentication status for the configured runner.
86
+ *
87
+ * @param agent - Runner id.
88
+ * @returns Human-readable status.
89
+ */
90
+ async function resolveRunnerStatus(agent: string): Promise<string> {
91
+ if (agent !== 'github-copilot') {
92
+ return 'Status check is currently available for GitHub Copilot CLI only.';
93
+ }
94
+
95
+ try {
96
+ const { stdout, stderr } = await execFileAsync('copilot', ['auth', 'status'], {
97
+ timeout: 10_000,
98
+ maxBuffer: 128 * 1024,
99
+ });
100
+ return [stdout, stderr].filter(Boolean).join('\n').trim() || 'GitHub Copilot CLI returned no status output.';
101
+ } catch (error) {
102
+ return error instanceof Error ? error.message : 'GitHub Copilot CLI status check failed.';
103
+ }
104
+ }
@@ -0,0 +1,113 @@
1
+ import { serializeError, type StudioBFFRequest } from '@prisma/studio-core/data/bff';
2
+ import { NextResponse } from 'next/server';
3
+ import {
4
+ $provideDatabaseAdminExecutor,
5
+ type DatabaseAdminExecutor,
6
+ type DatabaseAdminQuery,
7
+ } from '../../../../../database/$provideDatabaseAdminExecutor';
8
+ import { isUserGlobalAdmin } from '../../../../../utils/isUserGlobalAdmin';
9
+
10
+ export const runtime = 'nodejs';
11
+ export const dynamic = 'force-dynamic';
12
+
13
+ /**
14
+ * Handles Embedded Prisma Studio SQL requests for the configured Agents Server database.
15
+ *
16
+ * @param request - Incoming Studio BFF request.
17
+ * @returns Studio-compatible JSON response.
18
+ */
19
+ export async function POST(request: Request): Promise<NextResponse> {
20
+ if (!(await isUserGlobalAdmin())) {
21
+ return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
22
+ }
23
+
24
+ let payload: StudioBFFRequest;
25
+
26
+ try {
27
+ payload = (await request.json()) as StudioBFFRequest;
28
+ } catch (error) {
29
+ return NextResponse.json([serializeError(error)], { status: 400 });
30
+ }
31
+
32
+ return handleDatabaseAdminStudioRequest($provideDatabaseAdminExecutor(), payload);
33
+ }
34
+
35
+ /**
36
+ * Resolves one Studio BFF payload against the raw database executor.
37
+ *
38
+ * @param executor - Raw SQL executor for the active database backend.
39
+ * @param payload - Studio BFF request payload.
40
+ * @returns Studio-compatible JSON response.
41
+ */
42
+ async function handleDatabaseAdminStudioRequest(
43
+ executor: DatabaseAdminExecutor,
44
+ payload: StudioBFFRequest,
45
+ ): Promise<NextResponse> {
46
+ try {
47
+ switch (payload.procedure) {
48
+ case 'query':
49
+ return NextResponse.json([null, await executor.execute(payload.query as DatabaseAdminQuery)]);
50
+
51
+ case 'sequence':
52
+ return handleDatabaseAdminStudioSequence(executor, payload.sequence as readonly [
53
+ DatabaseAdminQuery,
54
+ DatabaseAdminQuery,
55
+ ]);
56
+
57
+ case 'transaction':
58
+ return NextResponse.json([
59
+ null,
60
+ await executor.executeTransaction(payload.queries as ReadonlyArray<DatabaseAdminQuery>),
61
+ ]);
62
+
63
+ case 'sql-lint':
64
+ return NextResponse.json([
65
+ null,
66
+ await executor.lintSql({
67
+ schemaVersion: payload.schemaVersion,
68
+ sql: payload.sql,
69
+ }),
70
+ ]);
71
+
72
+ default:
73
+ return NextResponse.json([serializeError(new Error('Invalid database admin procedure.'))], {
74
+ status: 400,
75
+ });
76
+ }
77
+ } catch (error) {
78
+ return NextResponse.json([serializeError(error)]);
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Executes a two-step Studio sequence, preserving the response shape expected by the BFF client.
84
+ *
85
+ * @param executor - Raw SQL executor for the active database backend.
86
+ * @param sequence - Exactly two Studio-generated queries.
87
+ * @returns Studio-compatible sequence response.
88
+ */
89
+ async function handleDatabaseAdminStudioSequence(
90
+ executor: DatabaseAdminExecutor,
91
+ sequence: readonly [DatabaseAdminQuery, DatabaseAdminQuery],
92
+ ): Promise<NextResponse> {
93
+ const [firstQuery, secondQuery] = sequence;
94
+
95
+ try {
96
+ const firstResult = await executor.execute(firstQuery);
97
+
98
+ try {
99
+ const secondResult = await executor.execute(secondQuery);
100
+ return NextResponse.json([
101
+ [null, firstResult],
102
+ [null, secondResult],
103
+ ]);
104
+ } catch (error) {
105
+ return NextResponse.json([
106
+ [null, firstResult],
107
+ [serializeError(error)],
108
+ ]);
109
+ }
110
+ } catch (error) {
111
+ return NextResponse.json([[serializeError(error)]]);
112
+ }
113
+ }
@@ -0,0 +1,65 @@
1
+ import { NextResponse } from 'next/server';
2
+ import {
3
+ applyVpsRuntimeConfiguration,
4
+ listVpsEnvironmentVariables,
5
+ updateVpsEnvironmentVariables,
6
+ } from '@/src/utils/vpsConfiguration';
7
+ import { isUserAdmin } from '@/src/utils/isUserAdmin';
8
+ import { isUserGlobalAdmin } from '@/src/utils/isUserGlobalAdmin';
9
+
10
+ /**
11
+ * Loads `.env` variables visible in the admin UI.
12
+ */
13
+ export async function GET() {
14
+ if (!(await isUserAdmin())) {
15
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
16
+ }
17
+
18
+ try {
19
+ return NextResponse.json({
20
+ ...(await listVpsEnvironmentVariables()),
21
+ canEdit: await isUserGlobalAdmin(),
22
+ });
23
+ } catch (error) {
24
+ return NextResponse.json(
25
+ { error: error instanceof Error ? error.message : 'Failed to load environment variables.' },
26
+ { status: 500 },
27
+ );
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Persists editable `.env` variables and optionally reapplies VPS runtime configuration.
33
+ */
34
+ export async function PATCH(request: Request) {
35
+ if (!(await isUserGlobalAdmin())) {
36
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
37
+ }
38
+
39
+ try {
40
+ const body = (await request.json().catch(() => null)) as
41
+ | {
42
+ readonly variables?: Record<string, string>;
43
+ readonly applyRuntimeConfiguration?: boolean;
44
+ }
45
+ | null;
46
+
47
+ if (!body || typeof body.variables !== 'object' || body.variables === null) {
48
+ return NextResponse.json({ error: 'Variables payload is required.' }, { status: 400 });
49
+ }
50
+
51
+ const snapshot = await updateVpsEnvironmentVariables(body.variables);
52
+ const applyResult = body.applyRuntimeConfiguration ? await applyVpsRuntimeConfiguration() : null;
53
+
54
+ return NextResponse.json({
55
+ ...snapshot,
56
+ canEdit: true,
57
+ applyResult,
58
+ });
59
+ } catch (error) {
60
+ return NextResponse.json(
61
+ { error: error instanceof Error ? error.message : 'Failed to update environment variables.' },
62
+ { status: 500 },
63
+ );
64
+ }
65
+ }
@@ -0,0 +1,24 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { isUserGlobalAdmin } from '@/src/utils/isUserGlobalAdmin';
3
+ import { readVpsPm2Logs } from '@/src/utils/vpsConfiguration';
4
+
5
+ /**
6
+ * Loads recent pm2 logs for the standalone Agents Server process.
7
+ */
8
+ export async function GET(request: Request) {
9
+ if (!(await isUserGlobalAdmin())) {
10
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
11
+ }
12
+
13
+ try {
14
+ const url = new URL(request.url);
15
+ const rawLines = Number.parseInt(url.searchParams.get('lines') || '200', 10);
16
+ const lineCount = Number.isFinite(rawLines) ? Math.min(Math.max(rawLines, 20), 1000) : 200;
17
+ return NextResponse.json(await readVpsPm2Logs(lineCount));
18
+ } catch (error) {
19
+ return NextResponse.json(
20
+ { error: error instanceof Error ? error.message : 'Failed to load pm2 logs.' },
21
+ { status: 500 },
22
+ );
23
+ }
24
+ }
@@ -1,7 +1,12 @@
1
1
  import { NextResponse } from 'next/server';
2
+ import { isAgentsServerSqliteMode } from '../../../../../database/agentsServerDatabaseMode';
2
3
  import { resolveCurrentServerRegistryContext } from '../../../../../utils/currentServerRegistryContext';
3
4
  import { isUserGlobalAdmin } from '../../../../../utils/isUserGlobalAdmin';
4
- import { createServerPublicUrl } from '../../../../../utils/serverRegistry';
5
+ import {
6
+ createServerPublicUrl,
7
+ listEnvironmentRegisteredServers,
8
+ normalizeServerDomain,
9
+ } from '../../../../../utils/serverRegistry';
5
10
  import {
6
11
  assertGlobalAdminAccess,
7
12
  deleteManagedServer,
@@ -10,6 +15,11 @@ import {
10
15
  updateManagedServer,
11
16
  type UpdateServerInput,
12
17
  } from '../../../../../utils/serverManagement';
18
+ import {
19
+ applyVpsRuntimeConfiguration,
20
+ listConfiguredVpsDomains,
21
+ updateConfiguredVpsDomains,
22
+ } from '../../../../../utils/vpsConfiguration';
13
23
 
14
24
  /**
15
25
  * Updates editable `_Server` fields for one registered server.
@@ -24,8 +34,15 @@ export async function PATCH(request: Request, context: { params: Promise<{ serve
24
34
 
25
35
  const { serverId } = await context.params;
26
36
  const body = (await request.json()) as Omit<UpdateServerInput, 'id'>;
37
+ const parsedServerId = parseManagedServerId(serverId);
38
+
39
+ if (isAgentsServerSqliteMode()) {
40
+ const updatedServer = await updateStandaloneVpsServerDomain(parsedServerId, body.domain);
41
+ return NextResponse.json({ server: updatedServer });
42
+ }
43
+
27
44
  const updatedServer = await updateManagedServer({
28
- id: parseManagedServerId(serverId),
45
+ id: parsedServerId,
29
46
  ...body,
30
47
  });
31
48
 
@@ -52,9 +69,19 @@ export async function DELETE(_request: Request, context: { params: Promise<{ ser
52
69
  assertGlobalAdminAccess(await isUserGlobalAdmin());
53
70
 
54
71
  const { serverId } = await context.params;
72
+ const parsedServerId = parseManagedServerId(serverId);
73
+
74
+ if (isAgentsServerSqliteMode()) {
75
+ await deleteStandaloneVpsServerDomain(parsedServerId);
76
+ return NextResponse.json({
77
+ success: true,
78
+ redirectUrl: null,
79
+ });
80
+ }
81
+
55
82
  const currentContext = await resolveCurrentServerRegistryContext();
56
83
  const nextServerId = await deleteManagedServer({
57
- serverId: parseManagedServerId(serverId),
84
+ serverId: parsedServerId,
58
85
  currentServerId: currentContext.currentServer?.id ?? null,
59
86
  });
60
87
  const nextServer =
@@ -76,3 +103,52 @@ export async function DELETE(_request: Request, context: { params: Promise<{ ser
76
103
  );
77
104
  }
78
105
  }
106
+
107
+ /**
108
+ * Updates a virtual standalone VPS server by replacing its domain in `SERVERS`.
109
+ *
110
+ * @param serverId - Virtual server id.
111
+ * @param rawDomain - Replacement domain.
112
+ * @returns Updated virtual server row.
113
+ */
114
+ async function updateStandaloneVpsServerDomain(serverId: number, rawDomain: string) {
115
+ const normalizedDomain = normalizeServerDomain(rawDomain);
116
+ if (!normalizedDomain) {
117
+ throw new Error('A valid domain is required.');
118
+ }
119
+
120
+ const servers = listEnvironmentRegisteredServers();
121
+ const serverIndex = servers.findIndex((server) => server.id === serverId);
122
+ if (serverIndex === -1) {
123
+ throw new Error(`Standalone VPS server ${serverId} was not found.`);
124
+ }
125
+
126
+ const domains = await listConfiguredVpsDomains();
127
+ const nextDomains = domains.map((domain, index) => (index === serverIndex ? normalizedDomain : domain));
128
+ await updateConfiguredVpsDomains(nextDomains);
129
+ await applyVpsRuntimeConfiguration();
130
+
131
+ const updatedServer = listEnvironmentRegisteredServers().find((server) => server.domain === normalizedDomain);
132
+ if (!updatedServer) {
133
+ throw new Error(`Standalone VPS server ${normalizedDomain} was not persisted.`);
134
+ }
135
+
136
+ return updatedServer;
137
+ }
138
+
139
+ /**
140
+ * Deletes a virtual standalone VPS server by removing its domain from `SERVERS`.
141
+ *
142
+ * @param serverId - Virtual server id.
143
+ */
144
+ async function deleteStandaloneVpsServerDomain(serverId: number): Promise<void> {
145
+ const servers = listEnvironmentRegisteredServers();
146
+ const serverIndex = servers.findIndex((server) => server.id === serverId);
147
+ if (serverIndex === -1) {
148
+ throw new Error(`Standalone VPS server ${serverId} was not found.`);
149
+ }
150
+
151
+ const domains = await listConfiguredVpsDomains();
152
+ await updateConfiguredVpsDomains(domains.filter((_domain, index) => index !== serverIndex));
153
+ await applyVpsRuntimeConfiguration();
154
+ }
@@ -1,12 +1,24 @@
1
1
  import { NextResponse } from 'next/server';
2
+ import { isAgentsServerSqliteMode } from '../../../../database/agentsServerDatabaseMode';
2
3
  import { resolveCurrentServerRegistryContext } from '../../../../utils/currentServerRegistryContext';
4
+ import { isUserAdmin } from '../../../../utils/isUserAdmin';
3
5
  import { isUserGlobalAdmin } from '../../../../utils/isUserGlobalAdmin';
6
+ import {
7
+ createServerPublicUrl,
8
+ listEnvironmentRegisteredServers,
9
+ normalizeServerDomain,
10
+ } from '../../../../utils/serverRegistry';
4
11
  import {
5
12
  assertGlobalAdminAccess,
6
13
  createManagedServer,
7
14
  resolveManagedServerErrorStatus,
8
15
  type CreateServerInput,
9
16
  } from '../../../../utils/serverManagement';
17
+ import {
18
+ applyVpsRuntimeConfiguration,
19
+ listConfiguredVpsDomains,
20
+ updateConfiguredVpsDomains,
21
+ } from '../../../../utils/vpsConfiguration';
10
22
 
11
23
  /**
12
24
  * Lists all registered servers together with the server resolved from the current domain.
@@ -15,12 +27,15 @@ import {
15
27
  */
16
28
  export async function GET() {
17
29
  try {
18
- assertGlobalAdminAccess(await isUserGlobalAdmin());
30
+ if (!(await isUserAdmin())) {
31
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
32
+ }
19
33
 
20
34
  const context = await resolveCurrentServerRegistryContext();
21
35
  return NextResponse.json({
22
36
  servers: context.registeredServers,
23
37
  currentServerId: context.currentServer?.id ?? null,
38
+ canEdit: await isUserGlobalAdmin(),
24
39
  });
25
40
  } catch (error) {
26
41
  return NextResponse.json(
@@ -43,6 +58,26 @@ export async function POST(request: Request) {
43
58
  assertGlobalAdminAccess(await isUserGlobalAdmin());
44
59
 
45
60
  const body = (await request.json()) as CreateServerInput;
61
+ if (isAgentsServerSqliteMode()) {
62
+ const normalizedDomain = normalizeServerDomain(body.domain);
63
+ if (!normalizedDomain) {
64
+ return NextResponse.json({ error: 'A valid domain is required.' }, { status: 400 });
65
+ }
66
+
67
+ const existingDomains = await listConfiguredVpsDomains();
68
+ await updateConfiguredVpsDomains([...existingDomains, normalizedDomain]);
69
+ await applyVpsRuntimeConfiguration();
70
+ const createdServer = listEnvironmentRegisteredServers().find((server) => server.domain === normalizedDomain);
71
+
72
+ return NextResponse.json(
73
+ {
74
+ server: createdServer ?? null,
75
+ publicUrl: createServerPublicUrl(normalizedDomain).href,
76
+ },
77
+ { status: 201 },
78
+ );
79
+ }
80
+
46
81
  const result = await createManagedServer(body);
47
82
 
48
83
  if (!result.ok) {
@@ -46,6 +46,7 @@ import {
46
46
  CONTROL_PANEL_OPTION_AVAILABILITY_METADATA_KEYS,
47
47
  getControlPanelOptionAvailability,
48
48
  } from '../utils/getControlPanelOptionAvailability';
49
+ import '@prisma/studio-core/ui/index.css';
49
50
  import './globals.css';
50
51
 
51
52
  /**
@@ -1,15 +1,39 @@
1
1
  import { headers } from 'next/headers';
2
+ import Link from 'next/link';
3
+ import { redirect } from 'next/navigation';
2
4
  import { $sideEffect } from '../../../../src/utils/organization/$sideEffect';
5
+ import { ForbiddenPage } from '../components/ForbiddenPage/ForbiddenPage';
3
6
  import { HomepagePrimarySections } from '../components/Homepage/HomepagePrimarySections';
4
7
  import { $provideServer } from '../tools/$provideServer';
5
8
  import { isUserAdmin } from '../utils/isUserAdmin';
9
+ import { isUserGlobalAdmin } from '../utils/isUserGlobalAdmin';
10
+ import {
11
+ createServerPublicUrl,
12
+ listRegisteredServersUsingServiceRole,
13
+ resolveRegisteredServerByHost,
14
+ type ServerRecord,
15
+ } from '../utils/serverRegistry';
6
16
  import { getHomePageAgents } from './_data/getHomePageAgents';
7
17
 
8
18
  /**
9
19
  * Renders the simplified agents home page with local and federated agents.
10
20
  */
11
21
  export default async function HomePage() {
12
- $sideEffect(/* Note: [??] This will ensure dynamic rendering of page and avoid Next.js pre-render */ headers());
22
+ const headerStore = await headers();
23
+ $sideEffect(/* Note: [??] This will ensure dynamic rendering of page and avoid Next.js pre-render */ headerStore);
24
+
25
+ const ipAddressRouting = await resolveIpAddressRouting(headerStore.get('host'));
26
+ if (ipAddressRouting === 'LOGIN') {
27
+ return <ForbiddenPage />;
28
+ }
29
+
30
+ if (ipAddressRouting === 'CONFIGURE') {
31
+ redirect('/admin/servers?setup=1');
32
+ }
33
+
34
+ if (Array.isArray(ipAddressRouting)) {
35
+ return <IpAddressDomainChooser servers={ipAddressRouting} />;
36
+ }
13
37
 
14
38
  const [{ publicUrl }, isAdmin, { agents, folders, homepageMessage, currentUser }] = await Promise.all([
15
39
  $provideServer(),
@@ -32,3 +56,79 @@ export default async function HomePage() {
32
56
  </div>
33
57
  );
34
58
  }
59
+
60
+ /**
61
+ * Resolves special raw-IP behavior for standalone VPS access.
62
+ *
63
+ * @param host - Request host header.
64
+ * @returns Routing instruction or `null` when normal homepage rendering should continue.
65
+ */
66
+ async function resolveIpAddressRouting(host: string | null): Promise<'LOGIN' | 'CONFIGURE' | ReadonlyArray<ServerRecord> | null> {
67
+ if (!host || !isIpAddressHost(host)) {
68
+ return null;
69
+ }
70
+
71
+ const registeredServers = await listRegisteredServersUsingServiceRole().catch(() => []);
72
+ if (resolveRegisteredServerByHost(host, registeredServers)) {
73
+ return null;
74
+ }
75
+
76
+ if (registeredServers.length === 0) {
77
+ return (await isUserGlobalAdmin()) ? 'CONFIGURE' : 'LOGIN';
78
+ }
79
+
80
+ if (registeredServers.length === 1) {
81
+ redirect(createServerPublicUrl(registeredServers[0]!.domain).href);
82
+ }
83
+
84
+ return registeredServers;
85
+ }
86
+
87
+ /**
88
+ * Checks whether the request host is a raw IPv4 or IPv6 address.
89
+ *
90
+ * @param host - Raw host header.
91
+ * @returns `true` for IP-address access.
92
+ */
93
+ function isIpAddressHost(host: string): boolean {
94
+ const hostname = host
95
+ .trim()
96
+ .replace(/^\[(.+)\](?::\d+)?$/u, '$1')
97
+ .replace(/:\d+$/u, '');
98
+
99
+ return /^\d{1,3}(?:\.\d{1,3}){3}$/u.test(hostname) || hostname.includes(':');
100
+ }
101
+
102
+ /**
103
+ * Simple domain chooser shown when a raw IP has multiple configured domains.
104
+ *
105
+ * @param props - Configured server list.
106
+ */
107
+ function IpAddressDomainChooser(props: { readonly servers: ReadonlyArray<ServerRecord> }) {
108
+ return (
109
+ <div className="min-h-screen bg-slate-50 px-4 py-24">
110
+ <div className="mx-auto max-w-2xl space-y-6">
111
+ <div>
112
+ <h1 className="text-3xl font-light text-slate-900">Choose a domain</h1>
113
+ <p className="mt-2 text-sm text-slate-600">
114
+ This VPS has multiple Agents Server domains configured. Open the domain you want to use.
115
+ </p>
116
+ </div>
117
+ <div className="grid gap-3">
118
+ {props.servers.map((server) => {
119
+ const url = createServerPublicUrl(server.domain).href;
120
+ return (
121
+ <Link
122
+ key={server.id}
123
+ href={url}
124
+ className="rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm font-semibold text-slate-800 shadow-sm transition hover:border-blue-300 hover:text-blue-700"
125
+ >
126
+ {server.domain}
127
+ </Link>
128
+ );
129
+ })}
130
+ </div>
131
+ </div>
132
+ </div>
133
+ );
134
+ }