@tokenbuddy/tb-admin 1.0.35 → 1.0.37

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 (93) hide show
  1. package/dist/src/cli.js +92 -19
  2. package/dist/src/config.d.ts +7 -1
  3. package/dist/src/config.js +16 -4
  4. package/dist/src/display-format.js +6 -14
  5. package/dist/src/init-command.d.ts +50 -0
  6. package/dist/src/init-command.js +347 -0
  7. package/dist/src/providers/fly-io.d.ts +3 -0
  8. package/dist/src/providers/fly-io.js +137 -0
  9. package/dist/src/providers/provider-definition.d.ts +38 -0
  10. package/dist/src/providers/provider-definition.js +2 -0
  11. package/dist/src/seller.d.ts +2 -0
  12. package/dist/src/seller.js +30 -13
  13. package/dist/src/server-cmd.d.ts +1 -0
  14. package/dist/src/server-cmd.js +9 -2
  15. package/dist/src/ui-actions.d.ts +3 -0
  16. package/dist/src/ui-actions.js +199 -27
  17. package/dist/src/ui-command.js +3 -2
  18. package/dist/src/ui-state.d.ts +1 -3
  19. package/dist/src/ui-state.js +4 -8
  20. package/dist/src/ui-static.js +43 -15
  21. package/dist/src/workdir.d.ts +21 -0
  22. package/dist/src/workdir.js +50 -0
  23. package/package.json +8 -2
  24. package/templates/providers/fly.io/admin.toml.example +18 -0
  25. package/templates/providers/fly.io/deploy-secrets/bootstrap/README.md +18 -0
  26. package/templates/providers/fly.io/deploy-secrets/bootstrap/admin-web.example.env +3 -0
  27. package/templates/providers/fly.io/deploy-secrets/bootstrap/cloudflare-r2.example.env +6 -0
  28. package/templates/providers/fly.io/deploy-secrets/bootstrap/registry-signing-key.example.json +6 -0
  29. package/templates/providers/fly.io/deploy-secrets/bootstrap/registry.example.json +14 -0
  30. package/templates/providers/fly.io/deploy-secrets/bootstrap/tb-registry.example.yaml +14 -0
  31. package/templates/providers/fly.io/deploy-secrets/seller-configs/README.md +13 -0
  32. package/templates/providers/fly.io/deploy-secrets/seller-configs/seller.example.yaml +35 -0
  33. package/templates/providers/fly.io/env/deploy.env.example +12 -0
  34. package/templates/providers/fly.io/fly/fly.tb-registry.toml +31 -0
  35. package/templates/providers/fly.io/fly/fly.tb-seller.toml +25 -0
  36. package/templates/providers/fly.io/provider.toml.example +10 -0
  37. package/dist/src/bootstrap-registry.d.ts.map +0 -1
  38. package/dist/src/bootstrap-registry.js.map +0 -1
  39. package/dist/src/cli.d.ts.map +0 -1
  40. package/dist/src/cli.js.map +0 -1
  41. package/dist/src/client.d.ts.map +0 -1
  42. package/dist/src/client.js.map +0 -1
  43. package/dist/src/config.d.ts.map +0 -1
  44. package/dist/src/config.js.map +0 -1
  45. package/dist/src/display-format.d.ts.map +0 -1
  46. package/dist/src/display-format.js.map +0 -1
  47. package/dist/src/index.d.ts.map +0 -1
  48. package/dist/src/index.js.map +0 -1
  49. package/dist/src/provider.d.ts.map +0 -1
  50. package/dist/src/provider.js.map +0 -1
  51. package/dist/src/seller.d.ts.map +0 -1
  52. package/dist/src/seller.js.map +0 -1
  53. package/dist/src/server-cmd.d.ts.map +0 -1
  54. package/dist/src/server-cmd.js.map +0 -1
  55. package/dist/src/ui-actions.d.ts.map +0 -1
  56. package/dist/src/ui-actions.js.map +0 -1
  57. package/dist/src/ui-command.d.ts.map +0 -1
  58. package/dist/src/ui-command.js.map +0 -1
  59. package/dist/src/ui-server.d.ts.map +0 -1
  60. package/dist/src/ui-server.js.map +0 -1
  61. package/dist/src/ui-state.d.ts.map +0 -1
  62. package/dist/src/ui-state.js.map +0 -1
  63. package/dist/src/ui-static.d.ts.map +0 -1
  64. package/dist/src/ui-static.js.map +0 -1
  65. package/dist/src/upstream-balance-probe.d.ts.map +0 -1
  66. package/dist/src/upstream-balance-probe.js.map +0 -1
  67. package/dist/src/vendor-client.d.ts.map +0 -1
  68. package/dist/src/vendor-client.js.map +0 -1
  69. package/dist/src/vendor-commands.d.ts.map +0 -1
  70. package/dist/src/vendor-commands.js.map +0 -1
  71. package/src/bootstrap-registry.ts +0 -90
  72. package/src/cli.ts +0 -1614
  73. package/src/client.ts +0 -179
  74. package/src/config.ts +0 -194
  75. package/src/display-format.ts +0 -411
  76. package/src/index.ts +0 -11
  77. package/src/provider.ts +0 -150
  78. package/src/seller.ts +0 -538
  79. package/src/server-cmd.ts +0 -362
  80. package/src/ui-actions.ts +0 -1040
  81. package/src/ui-command.ts +0 -44
  82. package/src/ui-server.ts +0 -353
  83. package/src/ui-state.ts +0 -1318
  84. package/src/ui-static.ts +0 -673
  85. package/src/upstream-balance-probe.ts +0 -13
  86. package/src/vendor-client.ts +0 -23
  87. package/src/vendor-commands.ts +0 -65
  88. package/tests/admin.test.ts +0 -2162
  89. package/tests/seller.test.ts +0 -388
  90. package/tests/ui-state-fleet.test.ts +0 -526
  91. package/tests/ui-static-row.test.ts +0 -467
  92. package/tests/vendor-cli.test.ts +0 -241
  93. package/tsconfig.json +0 -8
@@ -197,7 +197,6 @@ export class AdminUiState {
197
197
  const row = baseSellerRow(stubEntry, undefined, "fly", app, specsByApp.get(app.name));
198
198
  row.publishStatus = "unpublished";
199
199
  row.detailStatus = "pending";
200
- row.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
201
200
  rows.push(row);
202
201
  }
203
202
  return rows;
@@ -211,7 +210,7 @@ export class AdminUiState {
211
210
  */
212
211
  async fetchFlyApps() {
213
212
  if (this.options.flyApps) {
214
- return (await this.options.flyApps()).filter((app) => isSellerFlyAppName(app.name));
213
+ return (await this.options.flyApps()).filter((app) => isVisibleFlyInventoryApp(app.name));
215
214
  }
216
215
  // 默认路径: 走 SellerCommandRunner.ls(true) 真 spawn flyctl.
217
216
  // 避免在 ui-state.ts 里 import 整个 seller module 引入循环依赖,
@@ -229,7 +228,7 @@ export class AdminUiState {
229
228
  const runner = new mod.SellerCommandRunner(this.configManager);
230
229
  const result = await runner.ls(true);
231
230
  if (result && typeof result === "object" && "apps" in result) {
232
- return result.apps.filter((app) => isSellerFlyAppName(app.name));
231
+ return result.apps.filter((app) => isVisibleFlyInventoryApp(app.name));
233
232
  }
234
233
  return [];
235
234
  }
@@ -499,9 +498,6 @@ export class AdminUiState {
499
498
  baseRow.alertReason = "registry 收录了但 fly app 失踪 — 严重事故, 立即下线";
500
499
  baseRow.removeHint = "立即下线 (registry-only)";
501
500
  }
502
- else if (dataSource === "fly") {
503
- baseRow.publishHint = "未发布 — 可申请发布 (走 vendor-bootstrap stage)";
504
- }
505
501
  if (dataSource === "registry") {
506
502
  return {
507
503
  row: {
@@ -726,8 +722,8 @@ function baseSellerRow(entry, profile, dataSource = "registry", flyApp, machineS
726
722
  } : undefined
727
723
  };
728
724
  }
729
- function isSellerFlyAppName(name) {
730
- return Boolean(name && (name.startsWith("tbs-") || name.startsWith("tb-seller-")) && name !== "tb-seller");
725
+ function isVisibleFlyInventoryApp(name) {
726
+ return Boolean(name && name !== "tb-registry");
731
727
  }
732
728
  function sellerEntryFromFlyApp(app) {
733
729
  return {
@@ -56,6 +56,13 @@ export function adminUiHtml() {
56
56
  .panel,.bootstrap-card{border:1px solid var(--hairline);background:var(--panel);border-radius:12px;box-shadow:var(--shadow);overflow:hidden}
57
57
  .panel-head{min-height:56px;padding:12px 16px;display:flex;align-items:center;justify-content:space-between;gap:16px;border-bottom:1px solid var(--hairline);background:var(--panel)}
58
58
  .title{margin:0;color:var(--ink);font-size:20px;line-height:28px;font-weight:750}
59
+ .fleet-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;justify-content:flex-end}
60
+ .fleet-toggle{min-height:38px;border:1px solid var(--hairline-strong);border-radius:8px;background:var(--panel-subtle);color:var(--muted);padding:0 10px;display:inline-flex;align-items:center;gap:8px;font-size:12px;font-weight:800}
61
+ .fleet-toggle input{position:absolute;opacity:0;pointer-events:none}
62
+ .fleet-toggle .switch-track{width:34px;height:20px;border-radius:999px;background:#d8d0ea;box-shadow:inset 0 0 0 1px var(--hairline-strong);position:relative;flex:0 0 auto}
63
+ .fleet-toggle .switch-track:after{content:"";position:absolute;left:3px;top:3px;width:14px;height:14px;border-radius:999px;background:#fff;box-shadow:0 1px 3px rgba(60,41,112,.2);transition:transform .18s ease}
64
+ .fleet-toggle input:checked + .switch-track{background:var(--primary)}
65
+ .fleet-toggle input:checked + .switch-track:after{transform:translateX(14px)}
59
66
  /* ---- Buttons ------------------------------------------------------ */
60
67
  .btn{border:1px solid var(--hairline-strong);border-radius:8px;min-height:38px;padding:0 12px;background:#fff;color:var(--ink);font-size:13px;font-weight:750}
61
68
  .btn:hover{border-color:var(--primary);color:var(--primary)}
@@ -97,8 +104,6 @@ export function adminUiHtml() {
97
104
  .remove-hint-btn{margin-top:6px;background:var(--danger);color:#fff;border:0;border-radius:6px;padding:4px 10px;font-size:11px;font-weight:800;display:inline-block}
98
105
  .remove-hint-btn:hover{background:#d63d5a}
99
106
  .remove-hint-btn::before{content:"! ";margin-right:2px}
100
- .publish-hint-btn{margin-top:6px;background:#fff;color:var(--primary);border:1px solid var(--hairline-strong);border-radius:6px;padding:4px 10px;font-size:11px;font-weight:800;display:inline-block}
101
- .publish-hint-btn:hover{background:#f5f3ff}
102
107
  /* Status dot — five spec tones (green/amber/red/blue/gray) */
103
108
  .app-dot{width:10px;height:10px;border-radius:999px;background:#c8ced8;box-shadow:0 0 0 4px #edf1f8}
104
109
  .app-dot.tone-green{background:var(--success);box-shadow:0 0 0 4px rgba(16,185,129,.18)}
@@ -222,7 +227,11 @@ export function adminUiHtml() {
222
227
  .progress-title .spinner{width:14px;height:14px;border-width:2px}
223
228
  .progress-meta{display:flex;align-items:center;justify-content:space-between;gap:10px}
224
229
  .progress-toggle{color:var(--primary);font-size:var(--label-fs);font-weight:var(--label-weight);text-transform:uppercase;letter-spacing:var(--label-spacing)}
225
- .progress-log{margin:4px 0 0;max-height:150px;overflow:auto;border:1px solid var(--hairline);border-radius:8px;background:var(--ink);color:#f3f0ff;padding:9px;font-size:11px;white-space:pre-wrap;font-family:var(--font-mono)}
230
+ .progress-log-wrap{position:relative;margin-top:4px}
231
+ .progress-log{margin:4px 0 0;max-height:150px;overflow:auto;border:1px solid var(--hairline);border-radius:8px;background:var(--ink);color:#f3f0ff;padding:36px 9px 9px;font-size:11px;white-space:pre-wrap;font-family:var(--font-mono)}
232
+ .progress-copy{position:absolute;right:7px;top:7px;min-height:28px;border:1px solid rgba(255,255,255,.18);border-radius:6px;background:rgba(255,255,255,.08);color:#f3f0ff;padding:0 8px;display:inline-flex;align-items:center;gap:6px;font-size:11px;font-weight:800}
233
+ .progress-copy:hover{background:rgba(255,255,255,.16);border-color:rgba(255,255,255,.28);color:#fff}
234
+ .progress-copy svg{width:13px;height:13px;stroke:currentColor;stroke-width:2.1;fill:none;stroke-linecap:round;stroke-linejoin:round}
226
235
  .spinner{width:19px;height:19px;border:2px solid rgba(124,61,240,.18);border-top-color:var(--primary);border-radius:999px;animation:spin .75s linear infinite}
227
236
  .inline-spinner{width:13px;height:13px;border:2px solid rgba(124,61,240,.18);border-top-color:var(--primary);border-radius:999px;animation:spin .75s linear infinite;display:inline-block;vertical-align:middle}
228
237
  @keyframes spin{to{transform:rotate(360deg)}}
@@ -255,7 +264,14 @@ export function adminUiHtml() {
255
264
  <div>
256
265
  <h1 class="title">Seller fleet</h1>
257
266
  </div>
258
- <button id="createSeller" class="btn primary">Create Seller</button>
267
+ <div class="fleet-controls">
268
+ <label class="fleet-toggle" title="Hide Fly apps that have no Machines instances">
269
+ <input id="hideNoInstanceApps" type="checkbox" checked>
270
+ <span class="switch-track" aria-hidden="true"></span>
271
+ <span>Hide no-instance apps</span>
272
+ </label>
273
+ <button id="createSeller" class="btn primary">Create Seller</button>
274
+ </div>
259
275
  </div>
260
276
  <div class="app-list">
261
277
  <div class="app-table-head" role="row">
@@ -369,7 +385,7 @@ async function api(path, options={}){
369
385
  function uiErrorMessage(err){ const message = err instanceof Error ? err.message : String(err || ""); if (/failed to fetch|networkerror|load failed|fetch failed/i.test(message)) return "Admin UI connection lost. Restart tb-admin ui and reload this page."; if (/operator_auth_required/i.test(message)) return "Admin profile authentication failed. Check the configured operator token."; if (/HTTP Error 401/i.test(message)) return "Authentication failed while loading admin data. Check the local admin profile."; return message || "Request failed."; }
370
386
  const sellerStatusRefreshIntervalMs = 30000;
371
387
  const sellerDetailConcurrency = 2;
372
- let currentDetail = null; let sellerRowsCache = []; let editing = false; let deleteReady = false; let sellerInventoryInFlight = false; let sellerDetailQueue = []; let sellerDetailQueueKeys = new Set(); let sellerDetailInFlight = new Set(); let sellerDetailTimers = new Map(); let sellerClockTimer = null; let sellerRefreshLoaded = false; let sellerRefreshStage = "Starting"; let sellerRefreshError = ""; let createJobTimer = null; let currentCreateJob = null; let expandedProgressSteps = new Set(); let createAppSuffix = "";
388
+ let currentDetail = null; let sellerRowsCache = []; let editing = false; let deleteReady = false; let hideNoInstanceApps = true; let sellerInventoryInFlight = false; let sellerDetailQueue = []; let sellerDetailQueueKeys = new Set(); let sellerDetailInFlight = new Set(); let sellerDetailTimers = new Map(); let sellerClockTimer = null; let sellerRefreshLoaded = false; let sellerRefreshStage = "Starting"; let sellerRefreshError = ""; let createJobTimer = null; let currentCreateJob = null; let expandedProgressSteps = new Set(); let copiedProgressStepId = ""; let createAppSuffix = "";
373
389
  const esc = value => String(value ?? "").replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;" }[c]));
374
390
  const missing = value => '<span class="muted-value">'+esc(value)+'</span>';
375
391
  const dash = () => '<span class="muted-value">'+window.__tbFmt.UNKNOWN_VALUE+'</span>';
@@ -405,8 +421,10 @@ async function loadSellers(options={}){
405
421
  updateSellerRefreshMeta();
406
422
  }
407
423
  }
408
- function statusRefreshRow(row){ return { id:row.id, name:row.name, app:row.app, url:row.url, registryStatus:row.registryStatus, nodeStatus:row.nodeStatus, region:row.region, upstreamDomain:row.upstreamDomain, upstreamStatus:row.upstreamStatus, upstreamBalanceUsdMicros:row.upstreamBalanceUsdMicros, upstreamBalanceCurrency:row.upstreamBalanceCurrency, upstreamBalanceSource:row.upstreamBalanceSource, upstreamBalanceFetchedAt:row.upstreamBalanceFetchedAt, upstreamBalanceError:row.upstreamBalanceError, upstreamRechargeUrl:row.upstreamRechargeUrl, discountRatio:row.discountRatio, capacityUsed:row.capacityUsed, capacityLimit:row.capacityLimit, resourceCpuPercent:row.resourceCpuPercent, resourceMemoryPercent:row.resourceMemoryPercent, resourceMemoryRssMb:row.resourceMemoryRssMb, resourceMemoryLimitMb:row.resourceMemoryLimitMb, ttftMs:row.ttftMs, avgInferenceMs:row.avgInferenceMs, lastInferenceMs:row.lastInferenceMs, avgTokensPerSecond:row.avgTokensPerSecond, lastTokensPerSecond:row.lastTokensPerSecond, latencySamples:row.latencySamples, lastSwitchAt:row.lastSwitchAt, modelsCount:row.modelsCount, specs:row.specs, error:row.error, dataSource:row.dataSource, registryAlert:row.registryAlert, alertReason:row.alertReason, publishHint:row.publishHint, removeHint:row.removeHint, flyApp:row.flyApp, publishStatus:row.publishStatus, detailStatus:row.detailStatus, detailUpdatedAt:row.detailUpdatedAt, detailNextRefreshAt:row.detailNextRefreshAt }; }
409
- function renderSellerRows(rows){ sellerRowsCache = rows; document.getElementById("sellerRows").innerHTML = rows.map(row => sellerRow(row)).join(""); document.querySelectorAll("[data-detail]").forEach(btn => btn.onclick = () => openDetail(btn.dataset.detail)); }
424
+ function statusRefreshRow(row){ return { id:row.id, name:row.name, app:row.app, url:row.url, registryStatus:row.registryStatus, nodeStatus:row.nodeStatus, region:row.region, upstreamDomain:row.upstreamDomain, upstreamStatus:row.upstreamStatus, upstreamBalanceUsdMicros:row.upstreamBalanceUsdMicros, upstreamBalanceCurrency:row.upstreamBalanceCurrency, upstreamBalanceSource:row.upstreamBalanceSource, upstreamBalanceFetchedAt:row.upstreamBalanceFetchedAt, upstreamBalanceError:row.upstreamBalanceError, upstreamRechargeUrl:row.upstreamRechargeUrl, discountRatio:row.discountRatio, capacityUsed:row.capacityUsed, capacityLimit:row.capacityLimit, resourceCpuPercent:row.resourceCpuPercent, resourceMemoryPercent:row.resourceMemoryPercent, resourceMemoryRssMb:row.resourceMemoryRssMb, resourceMemoryLimitMb:row.resourceMemoryLimitMb, ttftMs:row.ttftMs, avgInferenceMs:row.avgInferenceMs, lastInferenceMs:row.lastInferenceMs, avgTokensPerSecond:row.avgTokensPerSecond, lastTokensPerSecond:row.lastTokensPerSecond, latencySamples:row.latencySamples, lastSwitchAt:row.lastSwitchAt, modelsCount:row.modelsCount, specs:row.specs, error:row.error, dataSource:row.dataSource, registryAlert:row.registryAlert, alertReason:row.alertReason, removeHint:row.removeHint, flyApp:row.flyApp, publishStatus:row.publishStatus, detailStatus:row.detailStatus, detailUpdatedAt:row.detailUpdatedAt, detailNextRefreshAt:row.detailNextRefreshAt }; }
425
+ function hasNoFlyMachineInstance(row){ const machines = Number(row.specs?.machines); return Number.isFinite(machines) && machines === 0; }
426
+ function visibleSellerRows(rows){ return hideNoInstanceApps ? rows.filter(row => row.dataSource !== "fly" || !hasNoFlyMachineInstance(row)) : rows; }
427
+ function renderSellerRows(rows){ sellerRowsCache = rows; const visibleRows = visibleSellerRows(rows); document.getElementById("sellerRows").innerHTML = visibleRows.map(row => sellerRow(row)).join("") || '<div class="status-line">No apps match the current display filter.</div>'; document.querySelectorAll("[data-detail]").forEach(btn => btn.onclick = () => openDetail(btn.dataset.detail)); }
410
428
  function startSellerRefresh(){ loadSellers({ initial:true }); sellerClockTimer = setInterval(() => { updateSellerRefreshMeta(); updateSellerRowRefreshCountdowns(); }, 1000); window.addEventListener("beforeunload", () => { resetSellerDetailQueue(); clearInterval(sellerClockTimer); }); }
411
429
  function sellerRowKey(row){ return String(row.id || row.app || row.url || ""); }
412
430
  function resetSellerDetailQueue(){ sellerDetailQueue = []; sellerDetailQueueKeys.clear(); sellerDetailInFlight.clear(); sellerDetailTimers.forEach(timer => clearTimeout(timer)); sellerDetailTimers.clear(); }
@@ -554,7 +572,7 @@ function sellerRow(row){
554
572
  const publishExtras = publishRelation === "registry_only" ? '<strong class="registry-incident" title="'+esc(statusTip)+'">严重事故</strong>' +
555
573
  (row.alertReason ? '<span class="alert-reason">'+esc(row.alertReason)+'</span>' : '') +
556
574
  (row.removeHint ? '<span class="remove-hint-btn" data-action="remove" data-seller-id="'+esc(row.id)+'" title="'+esc(row.removeHint)+'">'+esc(row.removeHint)+'</span>' : '')
557
- : publishRelation === "unpublished" && row.publishHint ? '<span class="publish-hint-btn" data-action="publish" data-seller-id="'+esc(row.id)+'" title="'+esc(row.publishHint)+'">'+esc(row.publishHint)+'</span>' : '';
575
+ : '';
558
576
  const publishCell = '<span class="field-cell"><span class="metric-label">Pub</span>'+dsChip+publishExtras+'</span>';
559
577
  const regionCell = '<span class="field-cell"><span class="metric-label">Region</span><strong>'+esc(regionText)+'</strong></span>';
560
578
  const modelsCell = '<span class="field-cell"><span class="metric-label">Models</span><strong>'+esc(modelsText)+'</strong></span>';
@@ -612,15 +630,18 @@ function renderDetail(){
612
630
  const fields = [["registryStatus", registryStatusDisplay(c.registryStatus || d.row.registryStatus)],["region", c.region],["upstreamUrl", c.upstreamUrl],["upstreamApiKey", c.upstreamApiKeyMasked],["upstreamStatus", fmt.normalizeStatusLabel(c.upstreamStatus)],["ttftMs", fmt.formatDuration(d.row.ttftMs)],["avgTokensPerSecond", fmt.formatSpeed(d.row.avgTokensPerSecond)],["lastTokensPerSecond", fmt.formatSpeed(d.row.lastTokensPerSecond)],["lastInferenceMs", fmt.formatDuration(d.row.lastInferenceMs)],["latencySamples", d.row.latencySamples === undefined ? "—" : esc(fmt.formatCount(d.row.latencySamples))],["upstreamBalance", c.upstreamBalance],["upstreamBalanceSource", c.upstreamBalanceSource],["upstreamBalanceFetchedAt", c.upstreamBalanceFetchedAt ? fmt.formatTimeFull(c.upstreamBalanceFetchedAt) : "—"],["upstreamBalanceError", c.upstreamBalanceError],["upstreamBalanceProbeTemplate", c.upstreamBalanceProbeTemplate],["upstreamBalanceProbeUrl", c.upstreamBalanceProbeUrl],["upstreamBalanceProbeUserId", c.upstreamBalanceProbeUserId],["upstreamBalanceProbeRechargeUrl", c.upstreamBalanceProbeRechargeUrl],["markupRatio", c.markupRatio],["discountRatio", c.discountRatio],["maxConnections", c.maxConnections],["maxQueueDepth", c.maxQueueDepth]];
613
631
  document.getElementById("configFields").innerHTML = detailFieldsHtml(fields);
614
632
  const billingOptions = Array.from(new Set(d.models.map(m => m.billingModel).filter(Boolean)));
615
- document.getElementById("modelsTable").innerHTML = '<table><thead><tr><th>Enable</th><th>Upstream model</th><th>Billing model</th><th>Input price</th><th>Output price</th><th>TTFT</th><th>AVG speed</th><th>Samples</th></tr></thead><tbody>'+d.models.map(m => '<tr><td>'+modelEnableHtml(m)+'</td><td>'+esc(m.upstreamModel)+'</td><td>'+(editing ? selectHtml(m.upstreamModel, m.billingModel, billingOptions) : esc(m.billingModel))+'</td><td>'+esc(m.inputPrice || "—")+'</td><td>'+esc(m.outputPrice || "—")+'</td><td>'+esc(m.ttftMs === undefined ? "—" : fmt.formatDuration(m.ttftMs))+'</td><td>'+esc(m.avgTokensPerSecond === undefined ? "—" : fmt.formatSpeed(m.avgTokensPerSecond))+'</td><td>'+esc(m.latencySamples === undefined ? "—" : fmt.formatCount(m.latencySamples))+'</td></tr>').join("")+'</tbody></table>';
633
+ document.getElementById("modelsTable").innerHTML = '<table><thead><tr><th><input id="modelBulkToggle" class="model-toggle" type="checkbox" aria-label="Toggle all models" '+(editing ? "" : "disabled")+'> Enable</th><th>Upstream model</th><th>Billing model</th><th>Input price</th><th>Output price</th><th>TTFT</th><th>AVG speed</th><th>Samples</th></tr></thead><tbody>'+d.models.map(m => '<tr><td>'+modelEnableHtml(m)+'</td><td>'+esc(m.upstreamModel)+'</td><td>'+(editing ? selectHtml(m.upstreamModel, m.billingModel, billingOptions) : esc(m.billingModel))+'</td><td>'+esc(m.inputPrice || "—")+'</td><td>'+esc(m.outputPrice || "—")+'</td><td>'+esc(m.ttftMs === undefined ? "—" : fmt.formatDuration(m.ttftMs))+'</td><td>'+esc(m.avgTokensPerSecond === undefined ? "—" : fmt.formatSpeed(m.avgTokensPerSecond))+'</td><td>'+esc(m.latencySamples === undefined ? "—" : fmt.formatCount(m.latencySamples))+'</td></tr>').join("")+'</tbody></table>';
634
+ setupModelBulkToggle();
616
635
  }
617
636
  function showDetailStatus(message, loading){ const el = document.getElementById("detailStatus"); if (loading){ el.innerHTML = '<span class="spinner" aria-hidden="true"></span>'; el.setAttribute("role", "status"); el.setAttribute("aria-label", message || "Loading"); } else { el.textContent = message || ""; el.removeAttribute("role"); el.removeAttribute("aria-label"); } el.classList.toggle("hidden", !message && !loading); el.classList.toggle("loading", Boolean(loading)); }
618
637
  function metricCell(value){ return value === undefined || value === null || value === "" ? missing("—") : esc(value); }
619
638
  function detailFieldsHtml(fields){ return fields.filter(([key,value]) => !isMissingDetailField(key,value)).map(([key,value]) => { const editable = editing && ["upstreamUrl","upstreamApiKey","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeRechargeUrl","upstreamBalanceProbeUserId","markupRatio","discountRatio","maxConnections","maxQueueDepth"].includes(key); return fieldHtml(key, value, editable); }).join(""); }
620
639
  function isMissingDetailField(key,value){ const fmt = window.__tbFmt; return value === undefined || value === null || value === "" || value === fmt.UNKNOWN_VALUE || (key === "registryStatus" && value === "unknown"); }
621
- function fieldHtml(key,value, editable, options={}){ const fieldId = "field-" + String(key).replace(/[^a-z0-9_-]/gi, "-") + "-" + Math.random().toString(36).slice(2); const label = options.label || key; const labelText = label + (options.required ? ' <span class="required-star">*</span>' : ''); const display = value === undefined || value === null ? "" : value; const className = "field" + (options.full ? " full" : ""); const help = options.help ? '<p class="field-help">'+esc(options.help)+'</p>' : ""; const hiddenInput = '<input type="hidden" data-field="'+esc(key)+'" value="'+esc(display)+'">'; if (options.hidden) return hiddenInput; if (!editable || options.readonly) return '<div class="'+className+'"><label>'+labelText+'</label><div class="readonly-value" title="'+esc(display)+'" data-readonly-field="'+esc(key)+'">'+esc(display || window.__tbFmt.UNKNOWN_VALUE)+'</div>'+(options.submit ? hiddenInput : "")+help+'</div>'; if (key === "upstreamBalanceProbeTemplate") return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><select id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'">'+balanceProbeTemplates.map(t => '<option value="'+esc(t)+'" '+(t === display ? "selected" : "")+'>'+esc(t)+'</option>').join("")+'</select>'+help+'</div>'; const type = options.type || (String(key).toLowerCase().includes("key") ? "password" : "text"); const attrs = options.type === "number" ? ' type="number" step="any"' : ' type="'+esc(type)+'"'; const placeholder = options.placeholder ? ' placeholder="'+esc(options.placeholder)+'"' : ""; return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><input'+attrs+placeholder+' id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'" value="'+esc(display)+'">'+help+'</div>'; }
640
+ function fieldHtml(key,value, editable, options={}){ const fieldId = "field-" + String(key).replace(/[^a-z0-9_-]/gi, "-") + "-" + Math.random().toString(36).slice(2); const label = options.label || key; const labelText = label + (options.required ? ' <span class="required-star">*</span>' : ''); const display = value === undefined || value === null ? "" : value; const className = "field" + (options.full ? " full" : ""); const help = options.help ? '<p class="field-help">'+esc(options.help)+'</p>' : ""; const hiddenInput = '<input type="hidden" data-field="'+esc(key)+'" value="'+esc(display)+'">'; if (options.hidden) return hiddenInput; if (!editable || options.readonly) return '<div class="'+className+'"><label>'+labelText+'</label><div class="readonly-value" title="'+esc(display)+'" data-readonly-field="'+esc(key)+'">'+esc(display || window.__tbFmt.UNKNOWN_VALUE)+'</div>'+(options.submit ? hiddenInput : "")+help+'</div>'; if (key === "upstreamBalanceProbeTemplate") return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><select id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'">'+balanceProbeTemplates.map(t => '<option value="'+esc(t)+'" '+(t === display ? "selected" : "")+'>'+esc(t)+'</option>').join("")+'</select>'+help+'</div>'; if (key === "upstreamProtocolPreset") return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><select id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'">'+["auto","chat","image"].map(t => '<option value="'+esc(t)+'" '+(t === display ? "selected" : "")+'>'+esc(t)+'</option>').join("")+'</select>'+help+'</div>'; const type = options.type || (String(key).toLowerCase().includes("key") ? "password" : "text"); const attrs = options.type === "number" ? ' type="number" step="any"' : ' type="'+esc(type)+'"'; const placeholder = options.placeholder ? ' placeholder="'+esc(options.placeholder)+'"' : ""; return '<div class="'+className+'"><label for="'+esc(fieldId)+'">'+labelText+'</label><input'+attrs+placeholder+' id="'+esc(fieldId)+'" name="'+esc(key)+'" aria-label="'+esc(label)+'" autocomplete="off" data-field="'+esc(key)+'" data-original="'+esc(display)+'" value="'+esc(display)+'">'+help+'</div>'; }
622
641
  function selectHtml(upstreamModel, billingModel, options){ const values = options.includes(billingModel) ? options : [billingModel, ...options].filter(Boolean); return '<select name="billingModel" aria-label="billingModel" autocomplete="off" data-model="'+esc(upstreamModel)+'">'+values.map(value => '<option '+(value === billingModel ? "selected" : "")+'>'+esc(value)+'</option>').join("")+'</select>'; }
623
642
  function modelEnableHtml(model){ return '<input class="model-toggle" type="checkbox" aria-label="Enable '+esc(model.upstreamModel)+'" data-model-enabled="'+esc(model.upstreamModel)+'" '+(model.enabled === false ? "" : "checked")+' '+(editing ? "" : "disabled")+'>'; }
643
+ function setupModelBulkToggle(){ const bulk = document.getElementById("modelBulkToggle"); if (!bulk) return; const rows = Array.from(document.querySelectorAll("[data-model-enabled]")); rows.forEach(input => input.onchange = updateModelBulkToggle); bulk.onchange = event => { rows.forEach(input => { if (!input.disabled) input.checked = Boolean(event.target.checked); }); updateModelBulkToggle(); }; updateModelBulkToggle(); }
644
+ function updateModelBulkToggle(){ const bulk = document.getElementById("modelBulkToggle"); if (!bulk) return; const rows = Array.from(document.querySelectorAll("[data-model-enabled]")).filter(input => !input.disabled); const checked = rows.filter(input => input.checked).length; const mixed = checked > 0 && checked < rows.length; bulk.checked = rows.length > 0 && checked === rows.length; bulk.indeterminate = mixed; bulk.setAttribute("aria-checked", mixed ? "mixed" : String(bulk.checked)); bulk.disabled = !editing || rows.length === 0; }
624
645
  function registryStatusForAction(action){ if (action === "drain") return "draining"; if (action === "activate") return "active"; return "offline"; }
625
646
  function setStatusActionBusy(busy){ document.querySelectorAll("[data-status-action]").forEach(btn => { btn.disabled = Boolean(busy); }); }
626
647
  function setDetailSavingBusy(busy){ const edit = document.getElementById("editDetail"); edit.disabled = Boolean(busy); edit.textContent = busy ? "Saving" : (editing ? "Save changes" : "Edit config"); document.querySelectorAll("#detailGrid [data-field], #detailGrid [data-model], #detailGrid [data-model-enabled], [data-status-action], #deleteSeller").forEach(input => { input.disabled = Boolean(busy); }); }
@@ -628,17 +649,18 @@ document.getElementById("editDetail").onclick = async () => { if (!editing){ edi
628
649
  function modelConfigPatchFromDetail(){ const enabledByModel = {}; document.querySelectorAll("[data-model-enabled]").forEach(input => enabledByModel[input.dataset.modelEnabled] = Boolean(input.checked)); return (currentDetail?.models || []).map(model => ({ ...(model.configModel || { id:model.upstreamModel }), id:model.upstreamModel, enabled: enabledByModel[model.upstreamModel] !== false })); }
629
650
  document.getElementById("deleteSeller").onclick = async () => { if (!currentDetail) return; if (deleteReady && !confirm("Destroy deployment for "+currentDetail.row.name+"?")) return; const result = await api("/api/sellers/"+encodeURIComponent(currentDetail.row.id)+"/deployment", { method:"DELETE", body: JSON.stringify({ confirm: deleteReady }) }); showDetailStatus(result.stdout || (deleteReady ? "Deployment destroy requested." : "Dry-run ready. Click delete again to confirm destroy."), false); deleteReady = !deleteReady; document.getElementById("deleteSeller").title = deleteReady ? "Confirm destroy deployment" : "Delete deployment"; };
630
651
  document.getElementById("closeDetail").onclick = () => { deleteReady = false; document.getElementById("detailModal").classList.remove("open"); };
652
+ document.getElementById("hideNoInstanceApps").onchange = event => { hideNoInstanceApps = Boolean(event.target.checked); renderSellerRows(sellerRowsCache); };
631
653
  document.querySelectorAll("[data-status-action]").forEach(btn => btn.onclick = async () => { if (!currentDetail) return; const action = btn.dataset.statusAction; const id = currentDetail.row.id; const status = registryStatusForAction(action); try { setStatusActionBusy(true); showDetailStatus("Updating registry status", true); const result = await api("/api/sellers/"+encodeURIComponent(id)+"/"+action, { method:"POST" }); if (!result.ok) throw new Error(result.stderr || "Status update failed."); patchSellerRegistryStatus(id, status); deleteReady = false; currentDetail = null; document.getElementById("detailModal").classList.remove("open"); } catch (err) { showDetailStatus(err.message || "Status update failed.", false); } finally { setStatusActionBusy(false); } });
632
654
  document.getElementById("createSeller").onclick = () => { buildCreateForm(); document.getElementById("createModal").classList.add("open"); };
633
655
  document.getElementById("closeCreate").onclick = () => document.getElementById("createModal").classList.remove("open");
634
656
  function buildCreateForm(){ clearInterval(createJobTimer); createJobTimer = null; currentCreateJob = null; expandedProgressSteps = new Set(); createAppSuffix = randomAppSuffix(); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Create seller"; document.getElementById("createStatus").textContent = ""; document.getElementById("createStatus").classList.add("hidden"); document.getElementById("createStatus").classList.remove("loading"); document.getElementById("createProgress").classList.add("hidden"); document.getElementById("createProgress").innerHTML = ""; setCreateFormDisabled(false); const defaults = createDefaults(); document.getElementById("createFields").innerHTML = createFormHtml(defaults); setupCreateFormBehavior(); setupPaymentTabs(); }
635
- function createFormHtml(defaults){ return [createSectionHtml("基础信息设置", ["sellerName","app","region","image","flyConfig"], defaults), createSectionHtml("上游设置", ["upstreamWebsite","upstreamUrl","upstreamApiKey","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeUserId","upstreamBalanceProbeRechargeUrl"], defaults), createSectionHtml("性能与安全", ["maxConnections","maxQueueDepth","markupRatio","discountRatio"], defaults), paymentSectionHtml(defaults)].join(""); }
657
+ function createFormHtml(defaults){ return [createSectionHtml("基础信息设置", ["sellerName","app","region","image","flyConfig"], defaults), createSectionHtml("上游设置", ["upstreamWebsite","upstreamUrl","upstreamApiKey","upstreamProtocolPreset","upstreamBalanceProbeTemplate","upstreamBalanceProbeUrl","upstreamBalanceProbeUserId","upstreamBalanceProbeRechargeUrl"], defaults), createSectionHtml("性能与安全", ["maxConnections","maxQueueDepth","markupRatio","discountRatio"], defaults), paymentSectionHtml(defaults)].join(""); }
636
658
  function createSectionHtml(title, fields, defaults){ return '<section class="create-section"><h2>'+esc(title)+'</h2><div class="section-grid">'+fields.map(key => fieldHtml(key, defaults[key], createFieldEditable(key), createFieldOptions(key))).join("")+'</div></section>'; }
637
659
  function paymentSectionHtml(defaults){ const enabled = new Set(defaults.paymentMethods || []); const editableFields = [["clawtipPayTo","payTo"],["clawtipSm4KeyBase64","sm4KeyBase64"]]; const fixedFields = ["clawtipSkillSlug","clawtipSkillId","clawtipDescription","clawtipResourceUrl","clawtipActivationFeeFen","clawtipMicrosPerFen"]; return '<section class="create-section"><h2>支付设置</h2><div class="payment-tabs">'+paymentMethods.map((method,index) => '<button type="button" class="payment-tab '+(index === 0 ? "active" : "")+'" data-payment-tab="'+esc(method)+'">'+esc(method)+'</button>').join("")+'</div><div class="payment-panel" data-payment-panel="clawtip" data-enabled="'+String(enabled.has("clawtip"))+'"><div class="payment-head"><h3>ClawTip</h3>'+paymentToggleHtml("clawtip", enabled.has("clawtip"))+'</div><div class="section-grid">'+editableFields.map(([key,label]) => fieldHtml(key, defaults[key], true, { ...createFieldOptions(key), label })).join("")+fixedFields.map(key => fieldHtml(key, defaults[key], false, { hidden:true })).join("")+'<div class="field full"><label>自动生成参数</label><div class="generated-summary" data-generated-summary="clawtip"></div></div></div></div><div class="payment-panel hidden" data-payment-panel="mock" data-enabled="'+String(enabled.has("mock"))+'"><div class="payment-head"><h3>Mock</h3>'+paymentToggleHtml("mock", enabled.has("mock"))+'</div><div class="section-grid">'+fieldHtml("参数规则", "启用即可使用 mock 支付方式,无需额外参数", false, { full:true })+'</div></div></section>'; }
638
660
  function paymentToggleHtml(method, enabled){ return '<button type="button" class="pill-switch '+(enabled ? "enabled" : "")+'" data-payment-toggle="'+esc(method)+'" aria-pressed="'+String(enabled)+'">'+(enabled ? "已启用" : "未启用")+'</button>'; }
639
661
  function numericFieldOptions(key){ return ["maxConnections","maxQueueDepth","markupRatio","discountRatio","clawtipActivationFeeFen","clawtipMicrosPerFen"].includes(key) ? { type:"number" } : {}; }
640
662
  function createFieldEditable(key){ return !["app","image","flyConfig"].includes(key); }
641
- function createFieldOptions(key){ const labels = { sellerName:"Seller name", app:"Fly app name", region:"Fly region", image:"Seller image", flyConfig:"Fly config file", upstreamWebsite:"Upstream website", upstreamUrl:"OpenAI-compatible base URL", upstreamApiKey:"Upstream API key", upstreamBalanceProbeTemplate:"Balance probe template", upstreamBalanceProbeUrl:"Balance probe URL (auto from template)", upstreamBalanceProbeUserId:"Balance probe user ID", upstreamBalanceProbeRechargeUrl:"Recharge URL", maxConnections:"Max connections", maxQueueDepth:"Max queue depth", markupRatio:"Markup ratio", discountRatio:"Discount ratio", clawtipPayTo:"payTo", clawtipSm4KeyBase64:"sm4KeyBase64" }; const help = { sellerName:"Use a domain-style slug, for example openrouter-ai or moonshot-cn. The Fly app will be generated as tbs-<seller-name>-<random>.", app:"Generated from seller name. Example: tbs-openrouter-ai-k7p9x.", region:"Fly.io region, for example sin, nrt, hkg, lax.", image:"Uses the published seller image by default.", flyConfig:"Uses the standard tb-seller Fly.io config.", upstreamWebsite:"Customer-facing upstream website. Example: https://moonshot.cn", upstreamUrl:"OpenAI-compatible API base URL. Examples: https://api.moonshot.cn/v1 or https://openrouter.ai/api/v1", upstreamApiKey:"Upstream provider API key. This is submitted to the seller config and never shown in the seller list.", upstreamBalanceProbeTemplate:"Choose the parser used for balance lookup. usage_generic calls /v1/usage with the upstream API key.", upstreamBalanceProbeUrl:"Auto-filled and disabled for known templates. NewAPI generic keeps this editable. Example: https://code.shoestravel.xin/api/user/self", upstreamBalanceProbeUserId:"Only needed by NewAPI-style balance endpoints that require a user id.", upstreamBalanceProbeRechargeUrl:"Where operators recharge this upstream account. Example: https://code.shoestravel.xin/topup or https://openrouter.ai/settings/credits", maxConnections:"Seller concurrency limit. Each connection can serve one in-flight request.", maxQueueDepth:"Number of queued requests allowed when all connections are busy.", markupRatio:"Multiplier applied to upstream prices before discount. 1.0 = passthrough.", discountRatio:"Configured discount level after markup. 1.0 = standard price, 0.5 = 5折, 0.0 = free.", clawtipPayTo:"Alipay payTo target for ClawTip payments.", clawtipSm4KeyBase64:"Base64 SM4 symmetric key used to encrypt ClawTip payment requests." }; const options = { ...numericFieldOptions(key) }; if (labels[key]) options.label = labels[key]; if (help[key]) options.help = help[key]; return options; }
663
+ function createFieldOptions(key){ const labels = { sellerName:"Seller name", app:"Fly app name", region:"Fly region", image:"Seller image", flyConfig:"Fly config file", upstreamWebsite:"Upstream website", upstreamUrl:"OpenAI-compatible base URL", upstreamApiKey:"Upstream API key", upstreamProtocolPreset:"Protocol preset", upstreamBalanceProbeTemplate:"Balance probe template", upstreamBalanceProbeUrl:"Balance probe URL (auto from template)", upstreamBalanceProbeUserId:"Balance probe user ID", upstreamBalanceProbeRechargeUrl:"Recharge URL", maxConnections:"Max connections", maxQueueDepth:"Max queue depth", markupRatio:"Markup ratio", discountRatio:"Discount ratio", clawtipPayTo:"payTo", clawtipSm4KeyBase64:"sm4KeyBase64" }; const help = { sellerName:"Use a domain-style slug, for example openrouter-ai or moonshot-cn. The Fly app will be generated as tbs-<seller-name>-<random>.", app:"Generated from seller name. Example: tbs-openrouter-ai-k7p9x.", region:"Fly.io region, for example sin, nrt, hkg, lax.", image:"Uses the published seller image by default.", flyConfig:"Uses the standard tb-seller Fly.io config.", upstreamWebsite:"Customer-facing upstream website. Example: https://moonshot.cn", upstreamUrl:"OpenAI-compatible API base URL. Examples: https://api.moonshot.cn/v1 or https://openrouter.ai/api/v1", upstreamApiKey:"Upstream provider API key. This is submitted to the seller config and never shown in the seller list.", upstreamProtocolPreset:"Use image for nodes that should only publish /v1/images/generations.", upstreamBalanceProbeTemplate:"Choose the parser used for balance lookup. usage_generic calls /v1/usage with the upstream API key.", upstreamBalanceProbeUrl:"Auto-filled and disabled for known templates. NewAPI generic keeps this editable. Example: https://code.shoestravel.xin/api/user/self", upstreamBalanceProbeUserId:"Only needed by NewAPI-style balance endpoints that require a user id.", upstreamBalanceProbeRechargeUrl:"Where operators recharge this upstream account. Example: https://code.shoestravel.xin/topup or https://openrouter.ai/settings/credits", maxConnections:"Seller concurrency limit. Each connection can serve one in-flight request.", maxQueueDepth:"Number of queued requests allowed when all connections are busy.", markupRatio:"Multiplier applied to upstream prices before discount. 1.0 = passthrough.", discountRatio:"Configured ratio applied after markup. UI displays this raw value, for example 1, 0.5, or 0.01.", clawtipPayTo:"Alipay payTo target for ClawTip payments.", clawtipSm4KeyBase64:"Base64 SM4 symmetric key used to encrypt ClawTip payment requests." }; const options = { ...numericFieldOptions(key) }; if (labels[key]) options.label = labels[key]; if (help[key]) options.help = help[key]; if (["app","image","flyConfig"].includes(key)) options.submit = true; return options; }
642
664
  function randomAppSuffix(){ return Math.random().toString(36).replace(/[^a-z0-9]/g, "").slice(2, 7) || "x7p9k"; }
643
665
  function setupCreateFormBehavior(){ ["sellerName","upstreamUrl","upstreamBalanceProbeTemplate"].forEach(key => { const input = document.querySelector('#createFields [data-field="'+key+'"]'); if (!input) return; input.addEventListener("input", updateGeneratedCreateFields); input.addEventListener("change", updateGeneratedCreateFields); }); updateGeneratedCreateFields(); }
644
666
  function updateGeneratedCreateFields(){ const sellerName = createFieldString("sellerName") || "seller"; const app = appNameFromSellerName(sellerName); const template = createFieldString("upstreamBalanceProbeTemplate") || "auto"; const upstreamUrl = createFieldString("upstreamUrl") || ""; const balanceInput = document.querySelector('#createFields [data-field="upstreamBalanceProbeUrl"]'); setCreateFieldValue("app", app); if (balanceInput){ const editableBalanceUrl = template === "newapi_generic"; balanceInput.disabled = !editableBalanceUrl; balanceInput.readOnly = !editableBalanceUrl; if (editableBalanceUrl && balanceInput.dataset.autoGenerated === "true") { setCreateFieldValue("upstreamBalanceProbeUrl", ""); balanceInput.dataset.autoGenerated = "false"; } if (!editableBalanceUrl) { setCreateFieldValue("upstreamBalanceProbeUrl", balanceProbeUrlForTemplate(template, upstreamUrl)); balanceInput.dataset.autoGenerated = "true"; } } setCreateFieldValue("clawtipSkillSlug", app); setCreateFieldValue("clawtipSkillId", "si-" + app); setCreateFieldValue("clawtipDescription", "TokenBuddy Seller " + sellerSlugFromName(sellerName)); setCreateFieldValue("clawtipResourceUrl", "https://" + app + ".fly.dev"); setCreateFieldValue("clawtipActivationFeeFen", "1"); setCreateFieldValue("clawtipMicrosPerFen", "10000"); const summary = document.querySelector('[data-generated-summary="clawtip"]'); if (summary) summary.textContent = "skillSlug=" + app + " · skillId=si-" + app + " · resourceUrl=https://" + app + ".fly.dev · activationFeeFen=1"; }
@@ -654,13 +676,19 @@ function selectPaymentTab(method){ document.querySelectorAll("[data-payment-tab]
654
676
  function togglePaymentMethod(method){ const panel = document.querySelector('[data-payment-panel="'+method+'"]'); if (!panel) return; panel.dataset.enabled = String(panel.dataset.enabled !== "true"); updatePaymentPanels(); }
655
677
  function enabledPaymentMethods(){ return Array.from(document.querySelectorAll("[data-payment-panel]")).filter(panel => panel.dataset.enabled === "true").map(panel => panel.dataset.paymentPanel); }
656
678
  function updatePaymentPanels(){ document.querySelectorAll("[data-payment-panel]").forEach(panel => { const enabled = panel.dataset.enabled === "true"; const toggle = panel.querySelector("[data-payment-toggle]"); if (toggle){ toggle.classList.toggle("enabled", enabled); toggle.setAttribute("aria-pressed", String(enabled)); toggle.textContent = enabled ? "已启用" : "未启用"; } panel.querySelectorAll("[data-field]").forEach(input => { input.disabled = !enabled; }); }); }
657
- function createDefaults(){ const regions = sellerRowsCache.map(row => String(row.region || "").toLowerCase()).filter(Boolean); const region = regions[0] || "sin"; const existing = new Set(sellerRowsCache.flatMap(row => [row.id, row.name, row.app].filter(Boolean))); let index = sellerRowsCache.length + 1; let sellerName = "openrouter-ai"; while (existing.has(sellerName)) sellerName = "openrouter-ai-" + index++; const app = appNameFromSellerName(sellerName); return { sellerName, app, region, image:"registry.fly.io/tb-seller:latest", upstreamWebsite:"https://openrouter.ai", upstreamUrl:"https://openrouter.ai/api/v1", upstreamApiKey:"", upstreamBalanceProbeTemplate:"openrouter", upstreamBalanceProbeUrl:"https://openrouter.ai/api/v1/credits", upstreamBalanceProbeUserId:"", upstreamBalanceProbeRechargeUrl:"https://openrouter.ai/settings/credits", maxConnections:8, maxQueueDepth:4, markupRatio:1.2, discountRatio:1, paymentMethods:["clawtip"], clawtipPayTo:"", clawtipSm4KeyBase64:"", clawtipSkillSlug:app, clawtipSkillId:"si-"+app, clawtipDescription:"TokenBuddy Seller "+sellerName, clawtipResourceUrl:"https://"+app+".fly.dev", clawtipActivationFeeFen:1, clawtipMicrosPerFen:10000, flyConfig:"deploy/fly.io/fly.tb-seller.toml" }; }
679
+ function createDefaults(){ const regions = sellerRowsCache.map(row => String(row.region || "").toLowerCase()).filter(Boolean); const region = regions[0] || "sin"; const existing = new Set(sellerRowsCache.flatMap(row => [row.id, row.name, row.app].filter(Boolean))); let index = sellerRowsCache.length + 1; let sellerName = "openrouter-ai"; while (existing.has(sellerName)) sellerName = "openrouter-ai-" + index++; const app = appNameFromSellerName(sellerName); return { sellerName, app, region, image:"registry.fly.io/tb-seller:latest", upstreamWebsite:"https://openrouter.ai", upstreamUrl:"https://openrouter.ai/api/v1", upstreamApiKey:"", upstreamProtocolPreset:"auto", upstreamBalanceProbeTemplate:"openrouter", upstreamBalanceProbeUrl:"https://openrouter.ai/api/v1/credits", upstreamBalanceProbeUserId:"", upstreamBalanceProbeRechargeUrl:"https://openrouter.ai/settings/credits", maxConnections:8, maxQueueDepth:4, markupRatio:1.2, discountRatio:1, paymentMethods:["clawtip"], clawtipPayTo:"", clawtipSm4KeyBase64:"", clawtipSkillSlug:app, clawtipSkillId:"si-"+app, clawtipDescription:"TokenBuddy Seller "+sellerName, clawtipResourceUrl:"https://"+app+".fly.dev", clawtipActivationFeeFen:1, clawtipMicrosPerFen:10000, flyConfig:"" }; }
658
680
  document.getElementById("submitCreate").onclick = async () => { const body = { paymentMethods: enabledPaymentMethods() }; document.querySelectorAll("#createFields [data-field]").forEach(input => body[input.dataset.field] = fieldValue(input)); if (!confirm("Create seller deployment "+body.sellerName+" on Fly.io?")) return; try { setCreateFormDisabled(true); document.getElementById("submitCreate").disabled = true; document.getElementById("submitCreate").textContent = "Creating"; document.getElementById("createStatus").textContent = ""; document.getElementById("createStatus").classList.add("hidden"); document.getElementById("createStatus").classList.remove("loading"); document.getElementById("createProgress").classList.remove("hidden"); document.getElementById("createProgress").innerHTML = '<div class="progress-step running"><div class="progress-title"><span class="spinner" aria-hidden="true"></span><strong>Starting</strong></div><div class="progress-meta"><span>Preparing create workflow.</span></div></div>'; const response = await api("/api/sellers", { method:"POST", body: JSON.stringify(body) }); renderCreateJob(response.job); pollCreateJob(response.jobId); } catch (err) { setCreateFormDisabled(false); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Create seller"; document.getElementById("createStatus").classList.remove("hidden"); document.getElementById("createStatus").textContent = err.message; document.getElementById("createStatus").classList.remove("loading"); } };
659
681
  function pollCreateJob(jobId){ clearInterval(createJobTimer); createJobTimer = setInterval(async () => { try { const job = await api("/api/jobs/"+encodeURIComponent(jobId)); renderCreateJob(job); if (job.status !== "running"){ clearInterval(createJobTimer); createJobTimer = null; if (job.status === "succeeded") loadSellers(); } } catch (err) { clearInterval(createJobTimer); createJobTimer = null; document.getElementById("createStatus").classList.remove("hidden"); document.getElementById("createStatus").textContent = err.message; document.getElementById("createStatus").classList.remove("loading"); } }, 1200); }
660
682
  function renderCreateJob(job){ if (!job) return; currentCreateJob = job; const done = job.status !== "running"; const events = job.events || []; const progress = document.getElementById("createProgress"); const status = document.getElementById("createStatus"); progress.classList.remove("hidden"); status.classList.toggle("hidden", !done); status.classList.remove("loading"); if (done){ status.textContent = job.status === "succeeded" ? "Created and added to bootstrap registry." : (job.error || "Create seller failed."); status.removeAttribute("role"); } else { status.textContent = ""; } progress.innerHTML = events.map(event => progressStep(event)).join("") || '<div class="progress-step running"><div class="progress-title"><span class="spinner" aria-hidden="true"></span><strong>Starting</strong></div><div class="progress-meta"><span>Preparing create workflow.</span></div></div>'; if (done && job.status === "failed"){ setCreateFormDisabled(false); document.getElementById("submitCreate").disabled = false; document.getElementById("submitCreate").textContent = "Retry create"; } else if (done) { document.getElementById("submitCreate").disabled = true; document.getElementById("submitCreate").textContent = "Created"; } }
661
- function progressStep(event){ const result = event.result || {}; const log = [result.command ? "$ " + result.command.join(" ") : "", result.stdout || "", result.stderr || ""].filter(Boolean).join("\\n").slice(0, 1600); const expanded = expandedProgressSteps.has(event.stepId); const spinner = event.status === "running" ? '<span class="spinner" aria-hidden="true"></span>' : ""; return '<button type="button" class="progress-step '+esc(event.status)+'" data-progress-step="'+esc(event.stepId)+'" aria-expanded="'+String(expanded)+'"><div class="progress-title">'+spinner+'<strong>'+esc(event.title)+'</strong></div><div class="progress-meta"><span>'+esc(event.message || event.status)+'</span>'+(log ? '<span class="progress-toggle">'+(expanded ? "Hide details" : "Show details")+'</span>' : '')+'</div>'+(log && expanded ? '<pre class="progress-log">'+esc(log)+'</pre>' : '')+'</button>'; }
683
+ function progressLogText(event){ const result = event.result || {}; return [result.command ? "$ " + result.command.join(" ") : "", result.stdout || "", result.stderr || ""].filter(Boolean).join("\\n"); }
684
+ function progressCopyIcon(){ return '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>'; }
685
+ function progressStep(event){ const log = progressLogText(event); const expanded = expandedProgressSteps.has(event.stepId); const copied = copiedProgressStepId === event.stepId; const spinner = event.status === "running" ? '<span class="spinner" aria-hidden="true"></span>' : ""; return '<div role="button" tabindex="0" class="progress-step '+esc(event.status)+'" data-progress-step="'+esc(event.stepId)+'" aria-expanded="'+String(expanded)+'"><div class="progress-title">'+spinner+'<strong>'+esc(event.title)+'</strong></div><div class="progress-meta"><span>'+esc(event.message || event.status)+'</span>'+(log ? '<span class="progress-toggle">'+(expanded ? "Hide details" : "Show details")+'</span>' : '')+'</div>'+(log && expanded ? '<div class="progress-log-wrap"><button type="button" class="progress-copy" data-copy-progress-log="'+esc(event.stepId)+'" title="Copy output" aria-label="Copy output">'+progressCopyIcon()+'<span>'+(copied ? "Copied" : "Copy")+'</span></button><pre class="progress-log">'+esc(log)+'</pre></div>' : '')+'</div>'; }
686
+ async function copyProgressLog(stepId){ const event = (currentCreateJob?.events || []).find(item => item.stepId === stepId); if (!event) return; const text = progressLogText(event); try { if (navigator.clipboard?.writeText) await navigator.clipboard.writeText(text); else fallbackCopyText(text); copiedProgressStepId = stepId; if (currentCreateJob) renderCreateJob(currentCreateJob); setTimeout(() => { if (copiedProgressStepId === stepId) { copiedProgressStepId = ""; if (currentCreateJob) renderCreateJob(currentCreateJob); } }, 1400); } catch { fallbackCopyText(text); copiedProgressStepId = stepId; if (currentCreateJob) renderCreateJob(currentCreateJob); } }
687
+ function fallbackCopyText(text){ const area = document.createElement("textarea"); area.value = text; area.setAttribute("readonly", ""); area.style.position = "fixed"; area.style.opacity = "0"; document.body.appendChild(area); area.select(); document.execCommand("copy"); area.remove(); }
662
688
  function setCreateFormDisabled(disabled){ document.querySelectorAll("#createFields [data-field], #createFields [data-payment-tab], #createFields [data-payment-toggle]").forEach(input => { input.disabled = Boolean(disabled); }); if (!disabled) updatePaymentPanels(); }
663
- document.getElementById("createProgress").onclick = event => { const step = event.target.closest("[data-progress-step]"); if (!step) return; const id = step.dataset.progressStep; if (expandedProgressSteps.has(id)) expandedProgressSteps.delete(id); else expandedProgressSteps.add(id); if (currentCreateJob) renderCreateJob(currentCreateJob); };
689
+ function toggleProgressStep(id){ if (expandedProgressSteps.has(id)) expandedProgressSteps.delete(id); else expandedProgressSteps.add(id); if (currentCreateJob) renderCreateJob(currentCreateJob); }
690
+ document.getElementById("createProgress").onclick = event => { const copy = event.target.closest("[data-copy-progress-log]"); if (copy){ event.stopPropagation(); copyProgressLog(copy.dataset.copyProgressLog); return; } const step = event.target.closest("[data-progress-step]"); if (!step) return; toggleProgressStep(step.dataset.progressStep); };
691
+ document.getElementById("createProgress").onkeydown = event => { if (event.key !== "Enter" && event.key !== " ") return; const step = event.target.closest("[data-progress-step]"); if (!step || event.target.closest("[data-copy-progress-log]")) return; event.preventDefault(); toggleProgressStep(step.dataset.progressStep); };
664
692
  document.getElementById("closeBootstrapConfig").onclick = () => document.getElementById("bootstrapModal").classList.remove("open");
665
693
  function fieldValue(input){ return numeric(input.value); }
666
694
  function numeric(value){ const n = Number(value); return value !== "" && Number.isFinite(n) ? n : value; }
@@ -0,0 +1,21 @@
1
+ export declare const TB_ADMIN_WORKDIR_ENV = "TB_ADMIN_WORKDIR";
2
+ export declare const TOKENBUDDY_ADMIN_CONFIG_ENV = "TOKENBUDDY_ADMIN_CONFIG";
3
+ export declare const DEFAULT_ADMIN_WORKDIR_RELATIVE: string;
4
+ export interface AdminWorkdirOptions {
5
+ cliWorkdir?: string;
6
+ env?: NodeJS.ProcessEnv;
7
+ homeDir?: string;
8
+ }
9
+ export interface AdminConfigPathInput {
10
+ workdir?: string;
11
+ cliConfig?: string;
12
+ env?: NodeJS.ProcessEnv;
13
+ homeDir?: string;
14
+ }
15
+ export declare function defaultAdminWorkdir(homeDir?: string): string;
16
+ export declare function resolveAdminWorkdir(options?: AdminWorkdirOptions): string;
17
+ export declare function resolveWorkdirPath(workdir: string, input: string): string;
18
+ export declare function defaultAdminConfigPath(workdir: string): string;
19
+ export declare function resolveAdminConfigPath(input?: AdminConfigPathInput): string;
20
+ export declare function defaultProviderPath(workdir: string, provider: "fly.io"): string;
21
+ //# sourceMappingURL=workdir.d.ts.map
@@ -0,0 +1,50 @@
1
+ import * as os from "os";
2
+ import * as path from "path";
3
+ export const TB_ADMIN_WORKDIR_ENV = "TB_ADMIN_WORKDIR";
4
+ export const TOKENBUDDY_ADMIN_CONFIG_ENV = "TOKENBUDDY_ADMIN_CONFIG";
5
+ export const DEFAULT_ADMIN_WORKDIR_RELATIVE = path.join(".config", "tokenbuddy");
6
+ function expandHome(input, homeDir) {
7
+ if (input === "~") {
8
+ return homeDir;
9
+ }
10
+ if (input.startsWith("~/")) {
11
+ return path.join(homeDir, input.slice(2));
12
+ }
13
+ return input;
14
+ }
15
+ export function defaultAdminWorkdir(homeDir = os.homedir()) {
16
+ return path.join(homeDir, DEFAULT_ADMIN_WORKDIR_RELATIVE);
17
+ }
18
+ export function resolveAdminWorkdir(options = {}) {
19
+ const env = options.env || process.env;
20
+ const homeDir = options.homeDir || os.homedir();
21
+ const raw = options.cliWorkdir || env[TB_ADMIN_WORKDIR_ENV] || defaultAdminWorkdir(homeDir);
22
+ const expanded = expandHome(raw, homeDir);
23
+ return path.resolve(expanded);
24
+ }
25
+ export function resolveWorkdirPath(workdir, input) {
26
+ const expanded = expandHome(input, os.homedir());
27
+ if (path.isAbsolute(expanded)) {
28
+ return path.resolve(expanded);
29
+ }
30
+ return path.resolve(workdir, expanded);
31
+ }
32
+ export function defaultAdminConfigPath(workdir) {
33
+ return path.join(workdir, "admin.toml");
34
+ }
35
+ export function resolveAdminConfigPath(input = {}) {
36
+ const env = input.env || process.env;
37
+ const workdir = input.workdir || resolveAdminWorkdir({
38
+ env,
39
+ homeDir: input.homeDir
40
+ });
41
+ const configuredPath = input.cliConfig || env[TOKENBUDDY_ADMIN_CONFIG_ENV];
42
+ if (configuredPath) {
43
+ return resolveWorkdirPath(workdir, configuredPath);
44
+ }
45
+ return defaultAdminConfigPath(workdir);
46
+ }
47
+ export function defaultProviderPath(workdir, provider) {
48
+ return path.join(workdir, "providers", `${provider}.toml`);
49
+ }
50
+ //# sourceMappingURL=workdir.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenbuddy/tb-admin",
3
- "version": "1.0.35",
3
+ "version": "1.0.37",
4
4
  "description": "Remote admin CLI for TokenBuddy seller apps",
5
5
  "main": "dist/src/index.js",
6
6
  "types": "dist/src/index.d.ts",
@@ -9,12 +9,18 @@
9
9
  "tb-admin": "./bin/tb-admin.js"
10
10
  },
11
11
  "private": false,
12
+ "files": [
13
+ "bin/*.js",
14
+ "dist/src/**/*.js",
15
+ "dist/src/**/*.d.ts",
16
+ "templates/"
17
+ ],
12
18
  "scripts": {
13
19
  "build": "tsc"
14
20
  },
15
21
  "dependencies": {
16
22
  "@iarna/toml": "^2.2.5",
17
- "@tokenbuddy/contracts": "^1.0.0",
23
+ "@tokenbuddy/contracts": "^1.0.37",
18
24
  "@types/js-yaml": "^4.0.9",
19
25
  "cli-table3": "^0.6.4",
20
26
  "commander": "^12.0.0",
@@ -0,0 +1,18 @@
1
+ default_profile = "bootstrap"
2
+
3
+ [profiles.bootstrap]
4
+ url = "https://tb-registry.fly.dev"
5
+ token = "<operator-secret>"
6
+
7
+ [profiles.seller-example]
8
+ url = "https://tbs-example.fly.dev"
9
+ token = "<operator-secret>"
10
+
11
+ [seller_providers.fly]
12
+ flyctl_path = "flyctl"
13
+ default_region = "sin"
14
+ default_image = "registry.fly.io/tb-seller:<version>"
15
+ default_config = "deploy-secrets/seller-configs/seller.example.yaml"
16
+ operator_secret = "<operator-secret>"
17
+ volume_name = "tb_seller_data"
18
+ volume_size_gb = 1
@@ -0,0 +1,18 @@
1
+ # Bootstrap Runtime Configs
2
+
3
+ Store real bootstrap registry runtime files in this directory inside your admin
4
+ workdir.
5
+
6
+ Suggested copies:
7
+
8
+ ```bash
9
+ cp tb-registry.example.yaml tb-registry.yaml
10
+ cp registry.example.json registry.json
11
+ cp admin-web.example.env admin-web.env
12
+ cp cloudflare-r2.example.env cloudflare-r2.env
13
+ cp registry-signing-key.example.json registry-signing-key.json
14
+ chmod 600 tb-registry.yaml registry.json admin-web.env cloudflare-r2.env registry-signing-key.json
15
+ ```
16
+
17
+ Never commit files from this directory. They may contain registry signing keys,
18
+ R2 credentials, operator secrets, and admin web secrets.
@@ -0,0 +1,3 @@
1
+ TOKENBUDDY_REGISTRY_SUPER_ADMIN_KEY=<super-admin-key>
2
+ TOKENBUDDY_REGISTRY_SESSION_SECRET=<session-secret>
3
+ TOKENBUDDY_REGISTRY_SUPER_ADMIN_LABEL=default
@@ -0,0 +1,6 @@
1
+ TB_REGISTRY_R2_ACCESS_KEY_ID=<r2-access-key-id>
2
+ TB_REGISTRY_R2_SECRET_ACCESS_KEY=<r2-secret-access-key>
3
+ TB_REGISTRY_R2_BUCKET=tokenbuddy-registry
4
+ TB_REGISTRY_R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
5
+ TB_REGISTRY_R2_REGION=auto
6
+ TB_REGISTRY_PUBLIC_BASE_URL=https://registry.tokenbuddy.ai
@@ -0,0 +1,6 @@
1
+ {
2
+ "keyId": "registry-ed25519-<date>",
3
+ "algorithm": "Ed25519",
4
+ "publicKeyBase64": "<ed25519-public-key-base64>",
5
+ "privateKeyPkcs8DerBase64": "<ed25519-private-key-pkcs8-der-base64>"
6
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": 1,
3
+ "sellers": [
4
+ {
5
+ "id": "tbs-example",
6
+ "name": "Example TokenBuddy Seller",
7
+ "baseUrl": "https://tbs-example.fly.dev",
8
+ "status": "active",
9
+ "paymentMethods": ["clawtip"],
10
+ "supportedProtocols": ["chat.completions"],
11
+ "models": []
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,14 @@
1
+ bind:
2
+ host: 0.0.0.0
3
+ port: 8080
4
+ clawtip:
5
+ payTo: <pay-to-redacted>
6
+ sm4KeyBase64: <sm4-key-redacted>
7
+ skillSlug: tb-registry
8
+ skillId: si-tb-registry
9
+ description: TokenBuddy ClawTip wallet activation
10
+ resourceUrl: https://tb-registry.fly.dev
11
+ activationFeeFen: 1
12
+ microsPerFen: 1000000
13
+ sellerRegistryPath: /home/bootstrap/data/sellers.json
14
+ allowLocalSellerUrls: false
@@ -0,0 +1,13 @@
1
+ # Seller Runtime Configs
2
+
3
+ Store real seller runtime YAML files in this directory inside your admin workdir.
4
+
5
+ Start by copying `seller.example.yaml` to a seller-specific file, for example:
6
+
7
+ ```bash
8
+ cp seller.example.yaml tbs-example.yaml
9
+ chmod 600 tbs-example.yaml
10
+ ```
11
+
12
+ Never commit files from this directory. They may contain upstream API keys,
13
+ operator secrets, and payment provider credentials.
@@ -0,0 +1,35 @@
1
+ sellerId: tbs-example
2
+ manifestVersion: manifest.v1
3
+ allowMock: true
4
+ publicMockPayments: false
5
+
6
+ upstreamUrl: https://api.example.com
7
+ upstreamApiKey: <upstream-api-key>
8
+ upstreamCapabilities:
9
+ chatCompletions: supported
10
+ responses: supported
11
+ messages: supported
12
+ imagesGenerations: unsupported
13
+
14
+ enableProtocolAdapters: false
15
+ maxConnections: 8
16
+ maxQueueDepth: 4
17
+ queueTimeoutSecs: 60
18
+ markupRatio: 1.0
19
+ discountRatio: 1.0
20
+
21
+ operatorSecret: <operator-secret>
22
+
23
+ models: []
24
+ modelAliases: {}
25
+
26
+ # Optional ClawTip payment config.
27
+ # clawtip:
28
+ # payTo: <pay-to-redacted>
29
+ # sm4KeyBase64: <sm4-key-redacted>
30
+ # skillSlug: tokenbuddy-seller
31
+ # skillId: si-tokenbuddy-seller
32
+ # description: TokenBuddy Seller
33
+ # resourceUrl: https://tbs-example.fly.dev
34
+ # activationFeeFen: 1
35
+ # microsPerFen: 1000000
@@ -0,0 +1,12 @@
1
+ # TokenBuddy admin deploy environment.
2
+ # Copy to env/deploy.env and fill real values in your admin workdir only.
3
+
4
+ RELEASE_VERSION="REPLACE_ME"
5
+ NPM_TOKEN=""
6
+ FLY_API_TOKEN=""
7
+ FLY_ACCESS_TOKEN=""
8
+ OPERATOR_SECRET=""
9
+
10
+ # Optional defaults used by deploy wrappers.
11
+ TB_ADMIN_PROVIDER=fly.io
12
+ TB_ADMIN_ARTIFACTS_DIR=artifacts
@@ -0,0 +1,31 @@
1
+ app = "tb-registry"
2
+ primary_region = "sin"
3
+
4
+ # Deploys must pass --image registry.fly.io/tb-registry:<v>.
5
+ # Do not add [build] here; release image builds live outside this template.
6
+
7
+ [env]
8
+ TOKENBUDDY_REGISTRY_CONFIG = "/home/bootstrap/data/tb-registry.yaml"
9
+
10
+ [mounts]
11
+ source = "tb_registry_data"
12
+ destination = "/home/bootstrap/data"
13
+
14
+ [http_service]
15
+ internal_port = 8080
16
+ force_https = true
17
+ auto_stop_machines = "stop"
18
+ auto_start_machines = true
19
+ min_machines_running = 0
20
+
21
+ [[http_service.checks]]
22
+ interval = "30s"
23
+ timeout = "5s"
24
+ grace_period = "10s"
25
+ method = "GET"
26
+ path = "/healthz"
27
+
28
+ [[vm]]
29
+ size = "shared-cpu-1x"
30
+ memory = "256mb"
31
+ cpus = 1
@@ -0,0 +1,25 @@
1
+ app = "tbs-<name>"
2
+ primary_region = "sin"
3
+
4
+ # Deploys must pass --image registry.fly.io/tb-seller:<v>.
5
+ # Do not add [build] here; release image builds live outside this template.
6
+
7
+ [env]
8
+ PORT = "8000"
9
+ TOKENBUDDY_SELLER_CONFIG_PATH = "/data/seller-config.yaml"
10
+
11
+ [http_service]
12
+ internal_port = 8000
13
+ force_https = true
14
+ auto_stop_machines = false
15
+ auto_start_machines = true
16
+ min_machines_running = 1
17
+
18
+ [mounts]
19
+ source = "tb_seller_data"
20
+ destination = "/data"
21
+
22
+ [[vm]]
23
+ memory = "512mb"
24
+ cpu_kind = "shared"
25
+ cpus = 1
@@ -0,0 +1,10 @@
1
+ provider = "fly.io"
2
+ flyctl_path = "flyctl"
3
+ default_region = "sin"
4
+ seller_fly_config = "fly/fly.tb-seller.toml"
5
+ registry_fly_config = "fly/fly.tb-registry.toml"
6
+ deploy_env = "env/deploy.env"
7
+ artifacts_dir = "artifacts"
8
+
9
+ # Use FLY_API_TOKEN in the environment for non-interactive auth.
10
+ # Do not store real Fly.io tokens in this example file.