@promptbook/cli 0.112.0-132 → 0.112.0-134

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 (101) hide show
  1. package/apps/agents-server/src/app/admin/api-tokens/ApiTokensClient.tsx +4 -1
  2. package/apps/agents-server/src/app/admin/chat-feedback/ChatFeedbackClient.tsx +3 -0
  3. package/apps/agents-server/src/app/admin/chat-feedback/ChatFeedbackTable.tsx +13 -8
  4. package/apps/agents-server/src/app/admin/chat-history/ChatHistoryClient.tsx +3 -0
  5. package/apps/agents-server/src/app/admin/chat-history/ChatHistoryTable.tsx +13 -8
  6. package/apps/agents-server/src/app/admin/custom-css/CustomCssClient.tsx +4 -0
  7. package/apps/agents-server/src/app/admin/custom-css/CustomCssEditorPanel.tsx +7 -1
  8. package/apps/agents-server/src/app/admin/custom-css/CustomCssFilesPanel.tsx +7 -1
  9. package/apps/agents-server/src/app/admin/custom-js/CustomJsClient.tsx +4 -0
  10. package/apps/agents-server/src/app/admin/custom-js/CustomJsEditorPanel.tsx +7 -1
  11. package/apps/agents-server/src/app/admin/custom-js/CustomJsFilesPanel.tsx +7 -1
  12. package/apps/agents-server/src/app/admin/files/FilesGalleryClient.tsx +4 -0
  13. package/apps/agents-server/src/app/admin/files/FilesGalleryGrid.tsx +9 -11
  14. package/apps/agents-server/src/app/admin/files/FilesGalleryTable.tsx +9 -8
  15. package/apps/agents-server/src/app/admin/images/ImagesGalleryClient.tsx +4 -0
  16. package/apps/agents-server/src/app/admin/images/ImagesGalleryGrid.tsx +16 -11
  17. package/apps/agents-server/src/app/admin/images/ImagesGalleryTable.tsx +18 -9
  18. package/apps/agents-server/src/app/admin/login-methods/shibboleth/page.tsx +9 -9
  19. package/apps/agents-server/src/app/admin/messages/MessagesClient.tsx +7 -6
  20. package/apps/agents-server/src/app/admin/servers/ServersRegistryTable.tsx +11 -10
  21. package/apps/agents-server/src/app/admin/task-manager/TaskManagerClient.tsx +4 -2
  22. package/apps/agents-server/src/app/admin/task-manager/TaskManagerTaskRow.tsx +14 -15
  23. package/apps/agents-server/src/app/admin/task-manager/TaskManagerTasksCard.tsx +4 -1
  24. package/apps/agents-server/src/app/admin/task-manager/useTaskManagerState.ts +6 -9
  25. package/apps/agents-server/src/app/admin/update/CustomCommitPicker.tsx +258 -0
  26. package/apps/agents-server/src/app/admin/update/UpdateClient.tsx +305 -108
  27. package/apps/agents-server/src/app/admin/usage/UsageClient.tsx +4 -2
  28. package/apps/agents-server/src/app/admin/usage/UsageClientAnalyticsPanels.tsx +6 -4
  29. package/apps/agents-server/src/app/admin/usage/UsageClientFormatting.ts +6 -12
  30. package/apps/agents-server/src/app/admin/usage/UsageClientTimelineChart.tsx +6 -3
  31. package/apps/agents-server/src/app/admin/usage/useUsageClientState.ts +12 -4
  32. package/apps/agents-server/src/app/admin/users/[userId]/UserDetailClient.tsx +8 -3
  33. package/apps/agents-server/src/app/agents/[agentName]/AgentProfileChat.tsx +6 -20
  34. package/apps/agents-server/src/app/agents/[agentName]/api/book/route.ts +2 -0
  35. package/apps/agents-server/src/app/agents/[agentName]/book/useBookEditorHistory.ts +9 -2
  36. package/apps/agents-server/src/app/agents/[agentName]/chat/useAgentChatHistoryClientState.ts +2 -15
  37. package/apps/agents-server/src/app/agents/[agentName]/history/page.tsx +6 -1
  38. package/apps/agents-server/src/app/agents/[agentName]/integration/CalendarIntegrationSection.tsx +23 -5
  39. package/apps/agents-server/src/app/agents/[agentName]/timeouts/AgentTimeoutsClient.tsx +4 -2
  40. package/apps/agents-server/src/app/agents/[agentName]/timeouts/AgentTimeoutsFiltersCard.tsx +9 -4
  41. package/apps/agents-server/src/app/agents/[agentName]/timeouts/AgentTimeoutsTableCard.tsx +4 -1
  42. package/apps/agents-server/src/app/agents/[agentName]/timeouts/AgentTimeoutsTableRow.tsx +10 -3
  43. package/apps/agents-server/src/app/api/admin/update/commits/route.ts +35 -0
  44. package/apps/agents-server/src/app/api/admin/update/route.ts +10 -2
  45. package/apps/agents-server/src/app/api/agents/[agentName]/route.ts +5 -0
  46. package/apps/agents-server/src/app/api/internal/user-chat-jobs/run/route.ts +14 -2
  47. package/apps/agents-server/src/app/api/v1/agents/[agentId]/route.ts +4 -0
  48. package/apps/agents-server/src/app/api/v1/agents/route.ts +2 -0
  49. package/apps/agents-server/src/app/dashboard/page.tsx +11 -16
  50. package/apps/agents-server/src/app/recycle-bin/actions.ts +3 -0
  51. package/apps/agents-server/src/app/swagger/SwaggerApiKeysPanel.tsx +4 -1
  52. package/apps/agents-server/src/app/system/user-memory/UserMemoryClient.tsx +4 -2
  53. package/apps/agents-server/src/app/system/user-wallet/UserWalletClient.tsx +3 -0
  54. package/apps/agents-server/src/app/system/user-wallet/UserWalletRecordsTable.tsx +5 -1
  55. package/apps/agents-server/src/app/system/utilities/mocked-chats/MockedChatsEditorClient.tsx +3 -0
  56. package/apps/agents-server/src/app/system/utilities/mocked-chats/MockedChatsEditorSidebar.tsx +7 -8
  57. package/apps/agents-server/src/components/PrintHeader/PrintHeader.tsx +4 -2
  58. package/apps/agents-server/src/components/UsersList/UsersList.tsx +4 -2
  59. package/apps/agents-server/src/database/migrations/2026-06-2600-agent-directory-performance-indexes.sql +14 -0
  60. package/apps/agents-server/src/database/sqlite/$provideLocalSqliteSupabase.ts +92 -1
  61. package/apps/agents-server/src/tools/createAgentProgressTools.ts +8 -6
  62. package/apps/agents-server/src/utils/localization/formatServerLanguageHumanReadableDate.ts +70 -0
  63. package/apps/agents-server/src/utils/localization/getRequestServerLanguage.ts +32 -0
  64. package/apps/agents-server/src/utils/userChat/createRunUserChatJobExecutionContext.ts +17 -0
  65. package/apps/agents-server/src/utils/userChat/createRunUserChatJobPersistenceController.ts +0 -21
  66. package/apps/agents-server/src/utils/userChat/listUserChats.ts +5 -1
  67. package/apps/agents-server/src/utils/userChat/persistUserChatJobProgressCard.ts +31 -0
  68. package/apps/agents-server/src/utils/userChat/retryUserChatJob.ts +0 -4
  69. package/apps/agents-server/src/utils/userChat/runUserChatJob.ts +29 -0
  70. package/apps/agents-server/src/utils/userChat/userChatMessageLifecycle.ts +2 -2
  71. package/apps/agents-server/src/utils/userChat/userChatProgressCard.ts +99 -401
  72. package/apps/agents-server/src/utils/vpsSelfUpdate.ts +625 -51
  73. package/esm/index.es.js +252 -41
  74. package/esm/index.es.js.map +1 -1
  75. package/esm/scripts/run-codex-prompts/common/buildCoderRunProgressSnapshot.d.ts +5 -1
  76. package/esm/scripts/run-codex-prompts/common/cliProgressDisplay.d.ts +7 -0
  77. package/esm/scripts/run-codex-prompts/common/coderRunEstimateCache.d.ts +31 -0
  78. package/esm/scripts/run-codex-prompts/common/progressFormatting.d.ts +2 -0
  79. package/esm/scripts/run-codex-prompts/runners/claude-code/ClaudeCodeRunner.d.ts +3 -1
  80. package/esm/scripts/run-codex-prompts/ui/CoderRunUiState.d.ts +7 -0
  81. package/esm/src/book-3.0/cliAgentEnv.d.ts +1 -1
  82. package/esm/src/cli/cli-commands/coder/ThinkingLevel.d.ts +1 -1
  83. package/esm/src/version.d.ts +1 -1
  84. package/package.json +1 -1
  85. package/src/book-3.0/cliAgentEnv.ts +1 -1
  86. package/src/cli/cli-commands/agents-server/startAgentsServer.ts +1 -1
  87. package/src/cli/cli-commands/coder/ThinkingLevel.ts +3 -1
  88. package/src/other/templates/getTemplatesPipelineCollection.ts +911 -741
  89. package/src/version.ts +2 -2
  90. package/src/versions.txt +2 -0
  91. package/umd/index.umd.js +254 -44
  92. package/umd/index.umd.js.map +1 -1
  93. package/umd/scripts/run-codex-prompts/common/buildCoderRunProgressSnapshot.d.ts +5 -1
  94. package/umd/scripts/run-codex-prompts/common/cliProgressDisplay.d.ts +7 -0
  95. package/umd/scripts/run-codex-prompts/common/coderRunEstimateCache.d.ts +31 -0
  96. package/umd/scripts/run-codex-prompts/common/progressFormatting.d.ts +2 -0
  97. package/umd/scripts/run-codex-prompts/runners/claude-code/ClaudeCodeRunner.d.ts +3 -1
  98. package/umd/scripts/run-codex-prompts/ui/CoderRunUiState.d.ts +7 -0
  99. package/umd/src/book-3.0/cliAgentEnv.d.ts +1 -1
  100. package/umd/src/cli/cli-commands/coder/ThinkingLevel.d.ts +1 -1
  101. package/umd/src/version.d.ts +1 -1
@@ -25,33 +25,56 @@ const VPS_SELF_UPDATE_STALE_ERROR_MESSAGE =
25
25
  const VPS_SELF_UPDATE_RESTART_SUCCESS_STEP =
26
26
  'Standalone VPS self-update finished successfully after restarting the server.';
27
27
 
28
+ /**
29
+ * Default upstream repository URL used when no custom origin is configured.
30
+ */
31
+ export const VPS_SELF_UPDATE_DEFAULT_ORIGIN_REPOSITORY_URL = 'https://github.com/webgptorg/promptbook.git';
32
+
33
+ /**
34
+ * Identifier of the synthetic environment that allows targeting an arbitrary git ref.
35
+ */
36
+ export const VPS_SELF_UPDATE_CUSTOM_ENVIRONMENT_ID = 'custom' as const;
37
+
28
38
  /**
29
39
  * Supported standalone VPS update environments.
40
+ *
41
+ * Order matters: it is the order presented to the super-admin in the UI.
30
42
  */
31
43
  export const VPS_SELF_UPDATE_ENVIRONMENTS = [
32
- {
33
- id: 'production',
34
- branch: 'production',
35
- label: 'Production',
36
- description: 'Recommended stable deployment branch for standalone servers.',
37
- },
38
44
  {
39
45
  id: 'main',
40
46
  branch: 'main',
41
47
  label: 'Live',
42
48
  description: 'Tracks the latest commit from the main development branch.',
49
+ isCustom: false,
43
50
  },
44
51
  {
45
52
  id: 'preview',
46
53
  branch: 'preview',
47
54
  label: 'Preview',
48
55
  description: 'Follows the preview branch before changes reach production.',
56
+ isCustom: false,
57
+ },
58
+ {
59
+ id: 'production',
60
+ branch: 'production',
61
+ label: 'Production',
62
+ description: 'Recommended stable deployment branch for standalone servers.',
63
+ isCustom: false,
49
64
  },
50
65
  {
51
66
  id: 'lts',
52
67
  branch: 'lts',
53
68
  label: 'LTS',
54
69
  description: 'Keeps the server on the long-term-support branch.',
70
+ isCustom: false,
71
+ },
72
+ {
73
+ id: VPS_SELF_UPDATE_CUSTOM_ENVIRONMENT_ID,
74
+ branch: '',
75
+ label: 'Custom',
76
+ description: 'Pick an arbitrary commit, tag, or branch — advanced and potentially unstable.',
77
+ isCustom: true,
55
78
  },
56
79
  ] as const;
57
80
 
@@ -164,6 +187,10 @@ export type VpsSelfUpdateOverview = {
164
187
  * Current local repository commit subject.
165
188
  */
166
189
  readonly currentCommitMessage: string | null;
190
+ /**
191
+ * Author timestamp of the currently deployed commit in ISO format.
192
+ */
193
+ readonly currentCommitDate: string | null;
167
194
  /**
168
195
  * Latest remote commit on the selected branch.
169
196
  */
@@ -172,10 +199,30 @@ export type VpsSelfUpdateOverview = {
172
199
  * Short latest remote commit.
173
200
  */
174
201
  readonly latestRemoteCommitShortSha: string | null;
202
+ /**
203
+ * Author timestamp of the latest remote commit in ISO format.
204
+ */
205
+ readonly latestRemoteCommitDate: string | null;
206
+ /**
207
+ * Number of commits the deployed checkout is behind the latest remote commit, or `null` when unknown.
208
+ */
209
+ readonly commitsBehindCount: number | null;
175
210
  /**
176
211
  * Whether the remote branch contains a newer commit than the deployed checkout.
177
212
  */
178
213
  readonly isUpdateAvailable: boolean;
214
+ /**
215
+ * Configured upstream repository URL (defaults to `webgptorg/promptbook`).
216
+ */
217
+ readonly originRepositoryUrl: string;
218
+ /**
219
+ * Whether the configured origin matches the default upstream repository.
220
+ */
221
+ readonly isOriginRepositoryDefault: boolean;
222
+ /**
223
+ * Default upstream repository URL.
224
+ */
225
+ readonly defaultOriginRepositoryUrl: string;
179
226
  /**
180
227
  * Latest persisted update-job state.
181
228
  */
@@ -196,6 +243,24 @@ export type VpsSelfUpdateJobOverviewContext = {
196
243
  readonly currentCommitSha: string | null;
197
244
  };
198
245
 
246
+ /**
247
+ * Request payload accepted by {@link startVpsSelfUpdate}.
248
+ */
249
+ export type VpsSelfUpdateStartRequest = {
250
+ /**
251
+ * Predefined environment id (e.g. `production`) or `custom` to target an arbitrary ref.
252
+ */
253
+ readonly environmentId: string;
254
+ /**
255
+ * Optional arbitrary commit hash, tag, or branch used when `environmentId === 'custom'`.
256
+ */
257
+ readonly customRef?: string | null;
258
+ /**
259
+ * Optional override of the upstream repository URL (must be a `https://` git URL).
260
+ */
261
+ readonly originRepositoryUrl?: string | null;
262
+ };
263
+
199
264
  /**
200
265
  * Starts one detached VPS self-update run for the selected environment.
201
266
  *
@@ -203,10 +268,10 @@ export type VpsSelfUpdateJobOverviewContext = {
203
268
  * helper writes the initial persisted state and detaches the background process so
204
269
  * the triggering HTTP request can finish before pm2 restarts the server.
205
270
  *
206
- * @param targetEnvironmentId - Deployment environment selected by the super admin.
271
+ * @param request - Update request payload.
207
272
  * @returns Fresh overview including the running background job.
208
273
  */
209
- export async function startVpsSelfUpdate(targetEnvironmentId: string): Promise<VpsSelfUpdateOverview> {
274
+ export async function startVpsSelfUpdate(request: VpsSelfUpdateStartRequest): Promise<VpsSelfUpdateOverview> {
210
275
  if (process.platform !== 'linux') {
211
276
  throw new NotAllowed(
212
277
  spaceTrim(`
@@ -215,7 +280,26 @@ export async function startVpsSelfUpdate(targetEnvironmentId: string): Promise<V
215
280
  );
216
281
  }
217
282
 
218
- const targetEnvironment = resolveVpsSelfUpdateEnvironment(targetEnvironmentId);
283
+ const targetEnvironment = resolveVpsSelfUpdateEnvironment(request.environmentId);
284
+ const isCustomEnvironment = targetEnvironment.isCustom;
285
+ const targetRef = isCustomEnvironment ? normalizeArbitraryRef(request.customRef) : targetEnvironment.branch;
286
+
287
+ if (isCustomEnvironment && !targetRef) {
288
+ throw new NotAllowed(
289
+ spaceTrim(`
290
+ A custom self-update requires a non-empty target ref (commit hash, tag, or branch).
291
+ `),
292
+ );
293
+ }
294
+
295
+ const requestedOriginUrl = normalizeOriginRepositoryUrl(request.originRepositoryUrl);
296
+ if (requestedOriginUrl !== null) {
297
+ await persistVpsSelfUpdateOriginRepositoryUrl(requestedOriginUrl);
298
+ }
299
+
300
+ const originRepositoryUrl =
301
+ requestedOriginUrl || (await readConfiguredVpsSelfUpdateOriginRepositoryUrl());
302
+
219
303
  const currentJob = await readPersistedVpsSelfUpdateJob();
220
304
  if (currentJob.status === 'running' && !currentJob.isStale) {
221
305
  throw new NotAllowed(
@@ -237,21 +321,26 @@ export async function startVpsSelfUpdate(targetEnvironmentId: string): Promise<V
237
321
  const logHandle = await open(logFilePath, 'a');
238
322
 
239
323
  try {
240
- const child = spawn('bash', [scriptPath, 'self-update', '--branch', targetEnvironment.branch], {
324
+ const installerArgs = isCustomEnvironment
325
+ ? [scriptPath, 'self-update', '--ref', targetRef]
326
+ : [scriptPath, 'self-update', '--branch', targetRef];
327
+
328
+ const child = spawn('bash', installerArgs, {
241
329
  detached: true,
242
330
  stdio: ['ignore', logHandle.fd, logHandle.fd],
243
331
  env: {
244
332
  ...createVpsInstallerCommandEnvironment(),
245
333
  PTBK_SELF_UPDATE_STATUS_FILE: statusFilePath,
246
334
  PTBK_SELF_UPDATE_LOG_FILE: logFilePath,
247
- PTBK_TARGET_REPOSITORY_REF: targetEnvironment.branch,
335
+ PTBK_TARGET_REPOSITORY_REF: targetRef,
336
+ PROMPTBOOK_REPOSITORY_URL: originRepositoryUrl,
248
337
  },
249
338
  });
250
339
 
251
340
  await writeVpsSelfUpdateStatusFile({
252
341
  STATUS: 'running',
253
342
  PID: String(child.pid ?? ''),
254
- TARGET_REF: targetEnvironment.branch,
343
+ TARGET_REF: targetRef,
255
344
  CURRENT_STEP_B64: encodeStatusField('Queued standalone VPS self-update.'),
256
345
  ERROR_MESSAGE_B64: '',
257
346
  STARTED_AT: startedAt,
@@ -269,6 +358,58 @@ export async function startVpsSelfUpdate(targetEnvironmentId: string): Promise<V
269
358
  return readVpsSelfUpdateOverview();
270
359
  }
271
360
 
361
+ /**
362
+ * Normalizes one free-form arbitrary git ref entered by the super admin.
363
+ *
364
+ * @param value - Raw user-provided ref.
365
+ * @returns Trimmed ref or empty string when invalid.
366
+ */
367
+ function normalizeArbitraryRef(value: string | null | undefined): string {
368
+ const trimmedValue = value?.trim() || '';
369
+ if (!trimmedValue) {
370
+ return '';
371
+ }
372
+
373
+ if (!/^[A-Za-z0-9._/-]+$/u.test(trimmedValue)) {
374
+ throw new NotAllowed(
375
+ spaceTrim(`
376
+ The provided git ref \`${trimmedValue}\` contains unsupported characters.
377
+
378
+ **Only letters, digits, dots, underscores, slashes, and dashes are allowed.**
379
+ `),
380
+ );
381
+ }
382
+
383
+ return trimmedValue;
384
+ }
385
+
386
+ /**
387
+ * Validates a user-provided upstream repository URL.
388
+ *
389
+ * @param value - Raw URL string.
390
+ * @returns Normalized URL or `null` when the user did not request an override.
391
+ */
392
+ function normalizeOriginRepositoryUrl(value: string | null | undefined): string | null {
393
+ if (value === null || value === undefined) {
394
+ return null;
395
+ }
396
+
397
+ const trimmedValue = value.trim();
398
+ if (!trimmedValue) {
399
+ return null;
400
+ }
401
+
402
+ if (!/^https:\/\/[\w.-]+\/[\w./-]+(?:\.git)?$/u.test(trimmedValue)) {
403
+ throw new NotAllowed(
404
+ spaceTrim(`
405
+ The upstream repository URL \`${trimmedValue}\` is not a valid public **https** git URL.
406
+ `),
407
+ );
408
+ }
409
+
410
+ return trimmedValue;
411
+ }
412
+
272
413
  /**
273
414
  * Reads the current standalone VPS self-update overview.
274
415
  *
@@ -279,63 +420,51 @@ export async function readVpsSelfUpdateOverview(): Promise<VpsSelfUpdateOverview
279
420
  const repositoryDirectory = await resolveManagedPromptbookRepositoryDirectory();
280
421
  const scriptPath = await resolveVpsInstallerScriptPath();
281
422
  const job = await readPersistedVpsSelfUpdateJob();
423
+ const originRepositoryUrl = await readConfiguredVpsSelfUpdateOriginRepositoryUrl();
282
424
 
283
425
  if (process.platform !== 'linux') {
284
- return {
285
- isAvailable: false,
286
- unavailableReason: 'Self-update is available only on the standalone Linux VPS deployment.',
287
- environments: VPS_SELF_UPDATE_ENVIRONMENTS,
426
+ return createUnavailableOverview({
288
427
  currentEnvironment,
289
428
  repositoryDirectory,
290
- currentCommitSha: null,
291
- currentCommitShortSha: null,
292
- currentCommitMessage: null,
293
- latestRemoteCommitSha: null,
294
- latestRemoteCommitShortSha: null,
295
- isUpdateAvailable: false,
296
429
  job,
297
- };
430
+ originRepositoryUrl,
431
+ unavailableReason: 'Self-update is available only on the standalone Linux VPS deployment.',
432
+ });
298
433
  }
299
434
 
300
435
  if (!scriptPath) {
301
- return {
302
- isAvailable: false,
303
- unavailableReason: 'The shared VPS installer script could not be found on this server.',
304
- environments: VPS_SELF_UPDATE_ENVIRONMENTS,
436
+ return createUnavailableOverview({
305
437
  currentEnvironment,
306
438
  repositoryDirectory,
307
- currentCommitSha: null,
308
- currentCommitShortSha: null,
309
- currentCommitMessage: null,
310
- latestRemoteCommitSha: null,
311
- latestRemoteCommitShortSha: null,
312
- isUpdateAvailable: false,
313
439
  job,
314
- };
440
+ originRepositoryUrl,
441
+ unavailableReason: 'The shared VPS installer script could not be found on this server.',
442
+ });
315
443
  }
316
444
 
317
445
  if (!repositoryDirectory) {
318
- return {
319
- isAvailable: false,
320
- unavailableReason: 'The managed Promptbook repository directory is not configured on this server.',
321
- environments: VPS_SELF_UPDATE_ENVIRONMENTS,
446
+ return createUnavailableOverview({
322
447
  currentEnvironment,
323
448
  repositoryDirectory: null,
324
- currentCommitSha: null,
325
- currentCommitShortSha: null,
326
- currentCommitMessage: null,
327
- latestRemoteCommitSha: null,
328
- latestRemoteCommitShortSha: null,
329
- isUpdateAvailable: false,
330
449
  job,
331
- };
450
+ originRepositoryUrl,
451
+ unavailableReason: 'The managed Promptbook repository directory is not configured on this server.',
452
+ });
332
453
  }
333
454
 
334
- const [currentCommitSha, currentCommitMessage, latestRemoteCommitSha] = await Promise.all([
455
+ const [currentCommitSha, currentCommitMessage, currentCommitDate, latestRemoteCommitSha] = await Promise.all([
335
456
  runGitInRepository(repositoryDirectory, ['rev-parse', 'HEAD']),
336
457
  runGitInRepository(repositoryDirectory, ['log', '-1', '--format=%s']),
337
- readRemoteCommitSha(repositoryDirectory, currentEnvironment.branch),
458
+ runGitInRepository(repositoryDirectory, ['log', '-1', '--format=%aI']),
459
+ readRemoteCommitSha(repositoryDirectory, currentEnvironment.branch, originRepositoryUrl),
338
460
  ]);
461
+ const latestRemoteCommitDate = latestRemoteCommitSha
462
+ ? await readCommitDateFromRepository(repositoryDirectory, latestRemoteCommitSha)
463
+ : null;
464
+ const commitsBehindCount =
465
+ currentCommitSha && latestRemoteCommitSha
466
+ ? await countCommitsBetween(repositoryDirectory, currentCommitSha, latestRemoteCommitSha)
467
+ : null;
339
468
  const resolvedJob = resolveVpsSelfUpdateJobForOverview(job, {
340
469
  currentEnvironment,
341
470
  currentCommitSha,
@@ -350,15 +479,325 @@ export async function readVpsSelfUpdateOverview(): Promise<VpsSelfUpdateOverview
350
479
  currentCommitSha,
351
480
  currentCommitShortSha: abbreviateCommitSha(currentCommitSha),
352
481
  currentCommitMessage,
482
+ currentCommitDate,
353
483
  latestRemoteCommitSha,
354
484
  latestRemoteCommitShortSha: abbreviateCommitSha(latestRemoteCommitSha),
485
+ latestRemoteCommitDate,
486
+ commitsBehindCount,
355
487
  isUpdateAvailable: Boolean(
356
488
  currentCommitSha && latestRemoteCommitSha && currentCommitSha !== latestRemoteCommitSha,
357
489
  ),
490
+ originRepositoryUrl,
491
+ isOriginRepositoryDefault: originRepositoryUrl === VPS_SELF_UPDATE_DEFAULT_ORIGIN_REPOSITORY_URL,
492
+ defaultOriginRepositoryUrl: VPS_SELF_UPDATE_DEFAULT_ORIGIN_REPOSITORY_URL,
358
493
  job: resolvedJob,
359
494
  };
360
495
  }
361
496
 
497
+ /**
498
+ * Browser-safe summary of one commit that the super admin can pick from the custom-target picker.
499
+ */
500
+ export type VpsSelfUpdateCandidateCommit = {
501
+ /**
502
+ * Full commit hash.
503
+ */
504
+ readonly commitSha: string;
505
+ /**
506
+ * Short commit hash (first 7 chars).
507
+ */
508
+ readonly shortCommitSha: string;
509
+ /**
510
+ * Single-line commit subject.
511
+ */
512
+ readonly subject: string;
513
+ /**
514
+ * Author name.
515
+ */
516
+ readonly authorName: string;
517
+ /**
518
+ * Author email.
519
+ */
520
+ readonly authorEmail: string;
521
+ /**
522
+ * Author timestamp in ISO format.
523
+ */
524
+ readonly authoredAt: string;
525
+ /**
526
+ * Branches that point at this commit (origin-prefixed names stripped).
527
+ */
528
+ readonly branches: ReadonlyArray<string>;
529
+ /**
530
+ * Tags that point at this commit.
531
+ */
532
+ readonly tags: ReadonlyArray<string>;
533
+ /**
534
+ * Whether at least one tag points at the commit (used to flag stable releases).
535
+ */
536
+ readonly isReleaseTag: boolean;
537
+ };
538
+
539
+ /**
540
+ * Filter applied to the candidate-commit listing.
541
+ */
542
+ export type VpsSelfUpdateCandidateCommitsFilter = {
543
+ /**
544
+ * Free-text search across subject, author name, hash, branch and tag names.
545
+ */
546
+ readonly searchText?: string | null;
547
+ /**
548
+ * Restrict to commits authored on or after this ISO date.
549
+ */
550
+ readonly authoredAfter?: string | null;
551
+ /**
552
+ * Restrict to commits authored on or before this ISO date.
553
+ */
554
+ readonly authoredBefore?: string | null;
555
+ /**
556
+ * Hard limit on returned commits (default 200).
557
+ */
558
+ readonly limit?: number | null;
559
+ };
560
+
561
+ /**
562
+ * Hard ceiling for the candidate-commit listing to avoid streaming the entire repository to the browser.
563
+ */
564
+ const VPS_SELF_UPDATE_MAX_CANDIDATE_COMMITS = 500;
565
+
566
+ /**
567
+ * Field separator used between commit fields in the `git log` machine output.
568
+ */
569
+ const GIT_LOG_FIELD_SEPARATOR = '\x1f';
570
+
571
+ /**
572
+ * Lists commits from the managed repository for the custom-target picker.
573
+ *
574
+ * Fetches the latest refs from the configured upstream first so the picker can include
575
+ * recent commits that have not been deployed yet, then resolves branches/tags per commit.
576
+ *
577
+ * @param filter - Filter applied to the result.
578
+ * @returns Browser-safe commit list.
579
+ */
580
+ export async function listVpsSelfUpdateCandidateCommits(
581
+ filter: VpsSelfUpdateCandidateCommitsFilter = {},
582
+ ): Promise<ReadonlyArray<VpsSelfUpdateCandidateCommit>> {
583
+ if (process.platform !== 'linux') {
584
+ return [];
585
+ }
586
+
587
+ const repositoryDirectory = await resolveManagedPromptbookRepositoryDirectory();
588
+ if (!repositoryDirectory) {
589
+ return [];
590
+ }
591
+
592
+ const originRepositoryUrl = await readConfiguredVpsSelfUpdateOriginRepositoryUrl();
593
+ await runGitInRepository(repositoryDirectory, [
594
+ 'fetch',
595
+ '--no-tags',
596
+ '--prune',
597
+ '--depth=200',
598
+ originRepositoryUrl,
599
+ '+refs/heads/*:refs/remotes/origin/*',
600
+ ]);
601
+ await runGitInRepository(repositoryDirectory, [
602
+ 'fetch',
603
+ '--tags',
604
+ '--force',
605
+ originRepositoryUrl,
606
+ '+refs/tags/*:refs/tags/*',
607
+ ]);
608
+
609
+ const limit = clampCandidateCommitLimit(filter.limit);
610
+ const logArgs = [
611
+ 'log',
612
+ `--max-count=${VPS_SELF_UPDATE_MAX_CANDIDATE_COMMITS}`,
613
+ '--all',
614
+ `--format=%H${GIT_LOG_FIELD_SEPARATOR}%aI${GIT_LOG_FIELD_SEPARATOR}%an${GIT_LOG_FIELD_SEPARATOR}%ae${GIT_LOG_FIELD_SEPARATOR}%s`,
615
+ ];
616
+
617
+ if (filter.authoredAfter) {
618
+ logArgs.push(`--since=${filter.authoredAfter}`);
619
+ }
620
+ if (filter.authoredBefore) {
621
+ logArgs.push(`--until=${filter.authoredBefore}`);
622
+ }
623
+
624
+ const logOutput = await runGitInRepository(repositoryDirectory, logArgs);
625
+ if (!logOutput) {
626
+ return [];
627
+ }
628
+
629
+ const branchesByCommit = await readRefsByCommit(repositoryDirectory, 'refs/remotes/origin');
630
+ const tagsByCommit = await readRefsByCommit(repositoryDirectory, 'refs/tags');
631
+ const searchText = filter.searchText?.trim().toLowerCase() || '';
632
+
633
+ const commits: Array<VpsSelfUpdateCandidateCommit> = [];
634
+ for (const line of logOutput.split('\n')) {
635
+ if (!line) {
636
+ continue;
637
+ }
638
+
639
+ const fields = line.split(GIT_LOG_FIELD_SEPARATOR);
640
+ const commitSha = fields[0] ?? '';
641
+ if (!commitSha) {
642
+ continue;
643
+ }
644
+
645
+ const authoredAt = fields[1] ?? '';
646
+ const authorName = fields[2] ?? '';
647
+ const authorEmail = fields[3] ?? '';
648
+ const subject = fields.slice(4).join(GIT_LOG_FIELD_SEPARATOR);
649
+ const branches = branchesByCommit.get(commitSha) ?? [];
650
+ const tags = tagsByCommit.get(commitSha) ?? [];
651
+
652
+ if (searchText && !matchesCandidateCommitSearchText(searchText, commitSha, subject, authorName, branches, tags)) {
653
+ continue;
654
+ }
655
+
656
+ commits.push({
657
+ commitSha,
658
+ shortCommitSha: commitSha.slice(0, 7),
659
+ subject,
660
+ authorName,
661
+ authorEmail,
662
+ authoredAt,
663
+ branches,
664
+ tags,
665
+ isReleaseTag: tags.length > 0,
666
+ });
667
+
668
+ if (commits.length >= limit) {
669
+ break;
670
+ }
671
+ }
672
+
673
+ return commits;
674
+ }
675
+
676
+ /**
677
+ * Clamps an external limit value to the safe candidate-commit range.
678
+ *
679
+ * @param value - Raw user-provided limit.
680
+ * @returns Clamped value.
681
+ */
682
+ function clampCandidateCommitLimit(value: number | null | undefined): number {
683
+ const defaultLimit = 200;
684
+ if (value === null || value === undefined || !Number.isFinite(value)) {
685
+ return defaultLimit;
686
+ }
687
+
688
+ return Math.max(1, Math.min(VPS_SELF_UPDATE_MAX_CANDIDATE_COMMITS, Math.floor(value)));
689
+ }
690
+
691
+ /**
692
+ * Returns `true` when one commit matches the free-text filter applied to the picker.
693
+ *
694
+ * @param searchText - Lower-cased search text.
695
+ * @param commitSha - Full commit hash.
696
+ * @param subject - Commit subject.
697
+ * @param authorName - Author display name.
698
+ * @param branches - Branches pointing at the commit.
699
+ * @param tags - Tags pointing at the commit.
700
+ * @returns `true` when the commit should be included.
701
+ */
702
+ function matchesCandidateCommitSearchText(
703
+ searchText: string,
704
+ commitSha: string,
705
+ subject: string,
706
+ authorName: string,
707
+ branches: ReadonlyArray<string>,
708
+ tags: ReadonlyArray<string>,
709
+ ): boolean {
710
+ if (commitSha.toLowerCase().startsWith(searchText)) {
711
+ return true;
712
+ }
713
+
714
+ if (subject.toLowerCase().includes(searchText)) {
715
+ return true;
716
+ }
717
+
718
+ if (authorName.toLowerCase().includes(searchText)) {
719
+ return true;
720
+ }
721
+
722
+ if (branches.some((branch) => branch.toLowerCase().includes(searchText))) {
723
+ return true;
724
+ }
725
+
726
+ return tags.some((tag) => tag.toLowerCase().includes(searchText));
727
+ }
728
+
729
+ /**
730
+ * Reads refs grouped by commit hash so the picker can annotate each commit with its branches/tags.
731
+ *
732
+ * @param repositoryDirectory - Repository checkout path.
733
+ * @param refPrefix - Ref namespace passed to `git for-each-ref` (e.g. `refs/tags`).
734
+ * @returns Map keyed by commit hash.
735
+ */
736
+ async function readRefsByCommit(
737
+ repositoryDirectory: string,
738
+ refPrefix: string,
739
+ ): Promise<Map<string, Array<string>>> {
740
+ const output = await runGitInRepository(repositoryDirectory, [
741
+ 'for-each-ref',
742
+ '--format=%(objectname)\x1f%(refname:short)',
743
+ refPrefix,
744
+ ]);
745
+ const refsByCommit = new Map<string, Array<string>>();
746
+
747
+ if (!output) {
748
+ return refsByCommit;
749
+ }
750
+
751
+ for (const line of output.split('\n')) {
752
+ const [commitSha, refName] = line.split('\x1f');
753
+ if (!commitSha || !refName) {
754
+ continue;
755
+ }
756
+
757
+ const cleanRefName = refName.replace(/^origin\//u, '');
758
+ const list = refsByCommit.get(commitSha) ?? [];
759
+ list.push(cleanRefName);
760
+ refsByCommit.set(commitSha, list);
761
+ }
762
+
763
+ return refsByCommit;
764
+ }
765
+
766
+ /**
767
+ * Builds the placeholder overview used when self-update is unavailable on the host.
768
+ *
769
+ * @param context - Fields shared across every unavailable-overview branch.
770
+ * @returns Browser-safe placeholder overview.
771
+ */
772
+ function createUnavailableOverview(context: {
773
+ readonly currentEnvironment: VpsSelfUpdateEnvironmentOption;
774
+ readonly repositoryDirectory: string | null;
775
+ readonly job: VpsSelfUpdateJobSnapshot;
776
+ readonly originRepositoryUrl: string;
777
+ readonly unavailableReason: string;
778
+ }): VpsSelfUpdateOverview {
779
+ return {
780
+ isAvailable: false,
781
+ unavailableReason: context.unavailableReason,
782
+ environments: VPS_SELF_UPDATE_ENVIRONMENTS,
783
+ currentEnvironment: context.currentEnvironment,
784
+ repositoryDirectory: context.repositoryDirectory,
785
+ currentCommitSha: null,
786
+ currentCommitShortSha: null,
787
+ currentCommitMessage: null,
788
+ currentCommitDate: null,
789
+ latestRemoteCommitSha: null,
790
+ latestRemoteCommitShortSha: null,
791
+ latestRemoteCommitDate: null,
792
+ commitsBehindCount: null,
793
+ isUpdateAvailable: false,
794
+ originRepositoryUrl: context.originRepositoryUrl,
795
+ isOriginRepositoryDefault: context.originRepositoryUrl === VPS_SELF_UPDATE_DEFAULT_ORIGIN_REPOSITORY_URL,
796
+ defaultOriginRepositoryUrl: VPS_SELF_UPDATE_DEFAULT_ORIGIN_REPOSITORY_URL,
797
+ job: context.job,
798
+ };
799
+ }
800
+
362
801
  /**
363
802
  * Converts the persisted shell status into the status that should be shown in the admin overview.
364
803
  *
@@ -400,6 +839,8 @@ export function resolveVpsSelfUpdateJobForOverview(
400
839
  /**
401
840
  * Resolves one environment id or branch name to the canonical environment object.
402
841
  *
842
+ * Unknown values fall back to the production environment to preserve the historical default.
843
+ *
403
844
  * @param value - Raw environment id, branch name, or label.
404
845
  * @returns Canonical environment metadata.
405
846
  */
@@ -407,9 +848,39 @@ export function resolveVpsSelfUpdateEnvironment(value: string | null | undefined
407
848
  const normalizedValue = value?.trim().toLowerCase() || 'production';
408
849
  return (
409
850
  VPS_SELF_UPDATE_ENVIRONMENTS.find(
410
- (environment) => environment.id === normalizedValue || environment.branch === normalizedValue,
411
- ) ?? VPS_SELF_UPDATE_ENVIRONMENTS[0]
851
+ (environment) =>
852
+ !environment.isCustom &&
853
+ (environment.id === normalizedValue || environment.branch === normalizedValue),
854
+ ) ?? getDefaultVpsSelfUpdateEnvironment()
855
+ );
856
+ }
857
+
858
+ /**
859
+ * Returns the canonical production environment used as the default fallback.
860
+ *
861
+ * @returns Production environment option.
862
+ */
863
+ export function getDefaultVpsSelfUpdateEnvironment(): VpsSelfUpdateEnvironmentOption {
864
+ const productionEnvironment = VPS_SELF_UPDATE_ENVIRONMENTS.find((environment) => environment.id === 'production');
865
+ if (!productionEnvironment) {
866
+ throw new Error('Production environment is missing from VPS_SELF_UPDATE_ENVIRONMENTS.');
867
+ }
868
+ return productionEnvironment;
869
+ }
870
+
871
+ /**
872
+ * Returns the canonical custom environment option.
873
+ *
874
+ * @returns Custom environment metadata.
875
+ */
876
+ export function getCustomVpsSelfUpdateEnvironment(): VpsSelfUpdateEnvironmentOption {
877
+ const customEnvironment = VPS_SELF_UPDATE_ENVIRONMENTS.find(
878
+ (environment) => environment.id === VPS_SELF_UPDATE_CUSTOM_ENVIRONMENT_ID,
412
879
  );
880
+ if (!customEnvironment) {
881
+ throw new Error('Custom environment is missing from VPS_SELF_UPDATE_ENVIRONMENTS.');
882
+ }
883
+ return customEnvironment;
413
884
  }
414
885
 
415
886
  /**
@@ -450,6 +921,50 @@ async function readCurrentVpsSelfUpdateEnvironment(): Promise<VpsSelfUpdateEnvir
450
921
  return resolveVpsSelfUpdateEnvironment(configuredBranch);
451
922
  }
452
923
 
924
+ /**
925
+ * Reads the configured upstream repository URL from `.env` (falling back to the default upstream).
926
+ *
927
+ * @returns Configured upstream URL.
928
+ */
929
+ async function readConfiguredVpsSelfUpdateOriginRepositoryUrl(): Promise<string> {
930
+ const configuredUrl = await readConfiguredVpsEnvironmentValue('PROMPTBOOK_REPOSITORY_URL');
931
+ return configuredUrl?.trim() || VPS_SELF_UPDATE_DEFAULT_ORIGIN_REPOSITORY_URL;
932
+ }
933
+
934
+ /**
935
+ * Persists the configured upstream repository URL into the standalone VPS `.env` file.
936
+ *
937
+ * Setting the value to the default upstream URL removes any previous override so that the
938
+ * installer falls back to the bundled default the next time it runs.
939
+ *
940
+ * @param originRepositoryUrl - Normalized upstream URL.
941
+ */
942
+ async function persistVpsSelfUpdateOriginRepositoryUrl(originRepositoryUrl: string): Promise<void> {
943
+ const envFilePath = resolveVpsEnvironmentFilePath();
944
+ let existingContent = '';
945
+ try {
946
+ existingContent = await readFile(envFilePath, 'utf-8');
947
+ } catch (error) {
948
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
949
+ throw error;
950
+ }
951
+ }
952
+
953
+ const lines = existingContent.split(/\r?\n/u);
954
+ const keyPattern = /^\s*(?:export\s+)?PROMPTBOOK_REPOSITORY_URL=/u;
955
+ const isDefaultUpstream = originRepositoryUrl === VPS_SELF_UPDATE_DEFAULT_ORIGIN_REPOSITORY_URL;
956
+ const filteredLines = lines.filter((line) => !keyPattern.test(line));
957
+
958
+ if (!isDefaultUpstream) {
959
+ filteredLines.push(`PROMPTBOOK_REPOSITORY_URL=${originRepositoryUrl}`);
960
+ }
961
+
962
+ const nextContent = `${filteredLines.join('\n').replace(/\n+$/u, '')}\n`;
963
+ await mkdir(dirname(envFilePath), { recursive: true });
964
+ await writeFile(envFilePath, nextContent, { encoding: 'utf-8', mode: 0o600 });
965
+ process.env.PROMPTBOOK_REPOSITORY_URL = isDefaultUpstream ? '' : originRepositoryUrl;
966
+ }
967
+
453
968
  /**
454
969
  * Resolves the state-directory path used for persistent update logs and status files.
455
970
  *
@@ -624,13 +1139,72 @@ async function runGitInRepository(repositoryDirectory: string, args: ReadonlyArr
624
1139
  *
625
1140
  * @param repositoryDirectory - Repository checkout path.
626
1141
  * @param branch - Target branch.
1142
+ * @param originRepositoryUrl - Configured upstream repository URL.
627
1143
  * @returns Remote commit sha or `null`.
628
1144
  */
629
- async function readRemoteCommitSha(repositoryDirectory: string, branch: string): Promise<string | null> {
630
- const output = await runGitInRepository(repositoryDirectory, ['ls-remote', 'origin', `refs/heads/${branch}`]);
1145
+ async function readRemoteCommitSha(
1146
+ repositoryDirectory: string,
1147
+ branch: string,
1148
+ originRepositoryUrl: string,
1149
+ ): Promise<string | null> {
1150
+ if (!branch) {
1151
+ return null;
1152
+ }
1153
+
1154
+ const output = await runGitInRepository(repositoryDirectory, [
1155
+ 'ls-remote',
1156
+ originRepositoryUrl,
1157
+ `refs/heads/${branch}`,
1158
+ ]);
631
1159
  return output?.split(/\s+/u)[0] || null;
632
1160
  }
633
1161
 
1162
+ /**
1163
+ * Reads the author timestamp of a known commit from the local repository.
1164
+ *
1165
+ * @param repositoryDirectory - Repository checkout path.
1166
+ * @param commitSha - Commit hash to look up.
1167
+ * @returns ISO timestamp or `null` when the commit is unknown locally.
1168
+ */
1169
+ async function readCommitDateFromRepository(
1170
+ repositoryDirectory: string,
1171
+ commitSha: string,
1172
+ ): Promise<string | null> {
1173
+ return runGitInRepository(repositoryDirectory, ['log', '-1', '--format=%aI', commitSha]);
1174
+ }
1175
+
1176
+ /**
1177
+ * Counts how many commits separate two commits in the local repository.
1178
+ *
1179
+ * Returns `null` when either commit cannot be resolved (typical for a shallow clone that has not been deepened yet).
1180
+ *
1181
+ * @param repositoryDirectory - Repository checkout path.
1182
+ * @param fromCommitSha - Older commit hash.
1183
+ * @param toCommitSha - Newer commit hash.
1184
+ * @returns Commit count or `null`.
1185
+ */
1186
+ async function countCommitsBetween(
1187
+ repositoryDirectory: string,
1188
+ fromCommitSha: string,
1189
+ toCommitSha: string,
1190
+ ): Promise<number | null> {
1191
+ if (fromCommitSha === toCommitSha) {
1192
+ return 0;
1193
+ }
1194
+
1195
+ const output = await runGitInRepository(repositoryDirectory, [
1196
+ 'rev-list',
1197
+ '--count',
1198
+ `${fromCommitSha}..${toCommitSha}`,
1199
+ ]);
1200
+ if (output === null) {
1201
+ return null;
1202
+ }
1203
+
1204
+ const parsedCount = Number.parseInt(output, 10);
1205
+ return Number.isFinite(parsedCount) ? parsedCount : null;
1206
+ }
1207
+
634
1208
  /**
635
1209
  * Checks whether a detached update process is still alive.
636
1210
  *