relmio 0.4.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/ui/local.css CHANGED
@@ -18,6 +18,128 @@
18
18
  grid-column: 2;
19
19
  }
20
20
 
21
+ .local-wizard .header-actions {
22
+ flex-wrap: wrap;
23
+ justify-content: flex-end;
24
+ }
25
+
26
+ .local-wizard .local-support-button,
27
+ .local-wizard .local-repository-button {
28
+ display: inline-flex;
29
+ align-items: center;
30
+ justify-content: center;
31
+ border: 1px solid var(--border-strong);
32
+ border-radius: 999px;
33
+ text-decoration: none;
34
+ transition:
35
+ border-color 180ms var(--ease),
36
+ background 180ms var(--ease),
37
+ color 180ms var(--ease),
38
+ transform 180ms var(--ease);
39
+ }
40
+
41
+ .local-wizard .local-support-button {
42
+ width: 2.5rem;
43
+ min-width: 2.5rem;
44
+ min-height: 2.5rem;
45
+ background: var(--surface);
46
+ color: var(--text);
47
+ }
48
+
49
+ .local-wizard .local-support-button svg {
50
+ width: 1.125rem;
51
+ height: 1.125rem;
52
+ fill: none;
53
+ stroke: currentColor;
54
+ stroke-linecap: round;
55
+ stroke-linejoin: round;
56
+ stroke-width: 1.8;
57
+ }
58
+
59
+ .local-wizard .local-repository-button {
60
+ min-height: 2.5rem;
61
+ gap: 0.5rem;
62
+ padding: 0.3125rem 0.375rem 0.3125rem 0.75rem;
63
+ border-color: var(--text);
64
+ background: var(--text);
65
+ color: var(--background);
66
+ font-size: 0.75rem;
67
+ }
68
+
69
+ .local-wizard .local-repository-button > svg {
70
+ width: 1.125rem;
71
+ height: 1.125rem;
72
+ flex: none;
73
+ }
74
+
75
+ .local-wizard .local-repository-action,
76
+ .local-wizard .package-version {
77
+ min-height: 1.75rem;
78
+ display: inline-flex;
79
+ align-items: center;
80
+ padding: 0 0.5625rem;
81
+ border-radius: 999px;
82
+ white-space: nowrap;
83
+ font: 700 0.6875rem/1 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
84
+ }
85
+
86
+ .local-wizard .local-repository-action {
87
+ background: var(--background);
88
+ color: var(--text);
89
+ }
90
+
91
+ .local-wizard .package-version {
92
+ background: var(--background);
93
+ color: var(--text);
94
+ }
95
+
96
+ .local-wizard .local-support-button:hover,
97
+ .local-wizard .local-repository-button:hover {
98
+ border-color: var(--accent);
99
+ color: var(--accent-deep);
100
+ transform: translateY(-1px);
101
+ }
102
+
103
+ .local-wizard .local-repository-button:hover {
104
+ background: var(--accent);
105
+ color: #fff;
106
+ }
107
+
108
+ .local-wizard .local-support-button:focus-visible,
109
+ .local-wizard .local-repository-button:focus-visible {
110
+ outline: 3px solid #f2a94a;
111
+ outline-offset: 2px;
112
+ }
113
+
114
+ .local-wizard .toast-stack {
115
+ display: grid;
116
+ min-width: 0;
117
+ overflow: visible;
118
+ }
119
+
120
+ .local-wizard .toast-stack .toast {
121
+ width: 100%;
122
+ min-width: 0;
123
+ flex: 0 1 auto;
124
+ }
125
+
126
+ .local-wizard .safety-note > span {
127
+ display: block;
128
+ min-width: 0;
129
+ overflow-wrap: anywhere;
130
+ white-space: normal;
131
+ }
132
+
133
+ .local-wizard #global-message-text,
134
+ .local-wizard .error > span {
135
+ min-width: 0;
136
+ overflow: visible;
137
+ overflow-wrap: anywhere;
138
+ text-overflow: clip;
139
+ white-space: normal;
140
+ word-break: break-word;
141
+ }
142
+
21
143
  .target-picker {
22
144
  display: grid;
23
145
  grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -137,7 +259,8 @@
137
259
  .policy-note,
138
260
  .high-trust-warning,
139
261
  .one-time-note,
140
- .codex-login {
262
+ .codex-login,
263
+ .credential-rotation-note {
141
264
  margin-top: 1rem;
142
265
  padding: 0.875rem 1rem;
143
266
  border: 1px solid var(--accent-border);
@@ -148,7 +271,8 @@
148
271
  .policy-note p,
149
272
  .high-trust-warning p,
150
273
  .one-time-note p,
151
- .codex-login p {
274
+ .codex-login p,
275
+ .credential-rotation-note p {
152
276
  margin: 0.35rem 0 0;
153
277
  color: var(--text-muted);
154
278
  font-size: 0.875rem;
@@ -175,6 +299,20 @@
175
299
  color: var(--warning);
176
300
  }
177
301
 
302
+ .credential-rotation-note {
303
+ border-color: var(--warning-border);
304
+ background: var(--warning-soft);
305
+ }
306
+
307
+ .credential-rotation-note strong,
308
+ .credential-rotation-note p {
309
+ color: var(--warning);
310
+ }
311
+
312
+ .credential-rotation-note .button {
313
+ margin-top: 0.875rem;
314
+ }
315
+
178
316
  .local-review-grid {
179
317
  margin-top: 1rem;
180
318
  }
package/src/ui/local.html CHANGED
@@ -14,6 +14,7 @@
14
14
  />
15
15
  <link rel="stylesheet" href="/styles.css" />
16
16
  <link rel="stylesheet" href="/local.css" />
17
+ <script src="/theme.js" type="module"></script>
17
18
  <script src="/local.js" type="module"></script>
18
19
  </head>
19
20
  <body class="local-wizard" data-current-step="1">
@@ -32,7 +33,58 @@
32
33
  </svg>
33
34
  <span>Relmio</span>
34
35
  </div>
35
- <span class="local-badge">Bound to this computer only</span>
36
+ <div class="header-actions">
37
+ <fieldset class="theme-picker">
38
+ <legend class="visually-hidden">Color theme</legend>
39
+ <label title="Use system appearance">
40
+ <input type="radio" name="color-theme" value="system" checked />
41
+ <span class="theme-icon theme-icon-system" aria-hidden="true"></span>
42
+ <span class="visually-hidden">System</span>
43
+ </label>
44
+ <label title="Use light appearance">
45
+ <input type="radio" name="color-theme" value="light" />
46
+ <span class="theme-icon theme-icon-light" aria-hidden="true"></span>
47
+ <span class="visually-hidden">Light</span>
48
+ </label>
49
+ <label title="Use dark appearance">
50
+ <input type="radio" name="color-theme" value="dark" />
51
+ <span class="theme-icon theme-icon-dark" aria-hidden="true"></span>
52
+ <span class="visually-hidden">Dark</span>
53
+ </label>
54
+ </fieldset>
55
+ <a
56
+ class="local-support-button"
57
+ href="https://ko-fi.com/paldogies"
58
+ target="_blank"
59
+ rel="noopener noreferrer"
60
+ aria-label="Support Relmio on Ko-fi (opens in a new tab)"
61
+ title="Support Relmio on Ko-fi"
62
+ >
63
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
64
+ <path d="M5 8h12v5a6 6 0 0 1-12 0V8Z" />
65
+ <path d="M17 9h1.5a2.5 2.5 0 0 1 0 5H17M8 3v2m4-2v2m4-2v2" />
66
+ </svg>
67
+ </a>
68
+ <a
69
+ id="local-repository-button"
70
+ class="local-repository-button"
71
+ href="https://github.com/Demonbane18/relmio"
72
+ target="_blank"
73
+ rel="noopener noreferrer"
74
+ aria-label="Open Relmio version __RELMIO_PACKAGE_VERSION__ on GitHub to leave a star (opens in a new tab)"
75
+ >
76
+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
77
+ <path fill="currentColor" d="M12 .7a11.5 11.5 0 0 0-3.64 22.41c.58.11.79-.25.79-.56v-2.23c-3.22.7-3.9-1.37-3.9-1.37-.53-1.34-1.29-1.7-1.29-1.7-1.05-.72.08-.71.08-.71 1.16.08 1.77 1.19 1.77 1.19 1.04 1.77 2.72 1.26 3.38.96.1-.75.4-1.26.73-1.55-2.57-.29-5.28-1.29-5.28-5.69 0-1.26.45-2.28 1.19-3.09-.12-.29-.52-1.46.11-3.05 0 0 .97-.31 3.16 1.18a10.9 10.9 0 0 1 5.76 0c2.19-1.49 3.16-1.18 3.16-1.18.63 1.59.23 2.76.11 3.05.74.81 1.19 1.83 1.19 3.09 0 4.42-2.71 5.4-5.29 5.69.42.36.79 1.06.79 2.14v3.17c0 .31.21.68.8.56A11.5 11.5 0 0 0 12 .7Z" />
78
+ </svg>
79
+ <strong>GitHub</strong>
80
+ <span class="local-repository-action">
81
+ <span aria-hidden="true">★</span>
82
+ <span id="local-repository-stars">?</span>
83
+ </span>
84
+ <span class="package-version">v__RELMIO_PACKAGE_VERSION__</span>
85
+ </a>
86
+ <span class="local-badge">Bound to this computer only</span>
87
+ </div>
36
88
  </header>
37
89
 
38
90
  <main id="main-content" class="shell" tabindex="-1">
@@ -40,8 +92,8 @@
40
92
  <section class="intro" aria-labelledby="page-title">
41
93
  <h1 id="page-title">Run Relmio on localhost</h1>
42
94
  <p class="lede">
43
- Choose the official credential path that matches your client. Both
44
- options run in a hardened Docker container and bind only to
95
+ Choose the official credential path that matches your client. All
96
+ three options run in hardened Docker containers and bind only to
45
97
  <code>127.0.0.1</code>.
46
98
  </p>
47
99
  </section>
@@ -148,6 +200,20 @@
148
200
  </span>
149
201
  </span>
150
202
  </label>
203
+
204
+ <label class="target-card warning-card">
205
+ <input type="radio" name="target" value="codex-chat" />
206
+ <span class="target-card-copy">
207
+ <strong>Codex Chat Adapter</strong>
208
+ <span class="target-meta">Experimental · trusted local backends or development servers only</span>
209
+ <span>
210
+ Relmio-specific authenticated <code>POST /chat</code>, backed by
211
+ official Codex App Server and ChatGPT sign-in. This is not an
212
+ OpenAI-compatible <code>/v1</code> endpoint and browser bundles
213
+ must never connect to it.
214
+ </span>
215
+ </span>
216
+ </label>
151
217
  </fieldset>
152
218
 
153
219
  <div class="field-grid local-fields">
@@ -306,8 +372,8 @@
306
372
  </div>
307
373
 
308
374
  <aside id="codex-install-warning" class="high-trust-warning" hidden>
309
- <strong>High-trust capability</strong>
310
- <p>
375
+ <strong id="codex-install-warning-title">High-trust capability</strong>
376
+ <p id="codex-install-warning-detail">
311
377
  Anyone holding the generated client credential can control Codex
312
378
  inside its isolated container, act through your signed-in ChatGPT
313
379
  session, and may be able to recover that container's ChatGPT session
@@ -343,6 +409,22 @@
343
409
  </p>
344
410
  </aside>
345
411
 
412
+ <aside class="credential-rotation-note">
413
+ <strong>Rotate client credential</strong>
414
+ <p>
415
+ Relmio shows the replacement first, then activates and verifies it.
416
+ After successful activation, the previous credential no longer works.
417
+ Copy the replacement before leaving this page.
418
+ </p>
419
+ <button
420
+ id="rotate-credential-button"
421
+ class="button secondary"
422
+ type="button"
423
+ >
424
+ Rotate client credential
425
+ </button>
426
+ </aside>
427
+
346
428
  <dl class="result-list">
347
429
  <div>
348
430
  <dt>Endpoint</dt>
@@ -377,10 +459,8 @@
377
459
  </dl>
378
460
 
379
461
  <aside id="codex-production-warning" class="high-trust-warning" hidden>
380
- <strong>Experimental WebSocket transport</strong>
381
- <p>
382
- Codex App Server WebSocket is experimental and unsupported for production workloads.
383
- </p>
462
+ <strong id="codex-production-warning-title">Experimental Codex integration</strong>
463
+ <p id="codex-production-warning-detail"></p>
384
464
  </aside>
385
465
 
386
466
  <section id="codex-login" class="codex-login" aria-labelledby="codex-login-title" hidden>
package/src/ui/local.js CHANGED
@@ -9,6 +9,7 @@ const state = {
9
9
  dockerAvailable: false,
10
10
  planId: null,
11
11
  plan: null,
12
+ installedTarget: null,
12
13
  };
13
14
 
14
15
  const messageBox = element("global-message");
@@ -128,6 +129,14 @@ function readAllowedOrigins() {
128
129
  .filter((value) => value !== "");
129
130
  }
130
131
 
132
+ function isCodexChat(target) {
133
+ return target === "codex-chat";
134
+ }
135
+
136
+ function isOpenAiApi(target) {
137
+ return target === "openai-api";
138
+ }
139
+
131
140
  function invalidatePlan() {
132
141
  state.planId = null;
133
142
  state.plan = null;
@@ -139,15 +148,24 @@ function renderTarget() {
139
148
  state.target = selectedTarget();
140
149
  invalidatePlan();
141
150
 
142
- const isOpenAiApi = state.target === "openai-api";
143
- element("local-port").value = isOpenAiApi ? "12435" : "14500";
144
- element("origins-field").hidden = !isOpenAiApi;
145
- element("target-guidance-title").textContent = isOpenAiApi
151
+ const openAiApi = isOpenAiApi(state.target);
152
+ const codexChat = isCodexChat(state.target);
153
+ element("local-port").value = openAiApi
154
+ ? "12435"
155
+ : codexChat
156
+ ? "14501"
157
+ : "14500";
158
+ element("origins-field").hidden = !openAiApi;
159
+ element("target-guidance-title").textContent = openAiApi
146
160
  ? "Uses an OpenAI Platform API key"
147
- : "Uses the official Codex ChatGPT sign-in";
148
- element("target-guidance-detail").textContent = isOpenAiApi
161
+ : codexChat
162
+ ? "Uses official Codex ChatGPT sign-in through the experimental Relmio-specific Chat Adapter"
163
+ : "Uses the official Codex ChatGPT sign-in";
164
+ element("target-guidance-detail").textContent = openAiApi
149
165
  ? "Your Platform key is seeded over stdin into a private Docker volume and is never returned to the browser. A separate local client credential is generated for your apps."
150
- : "This runs Codex App Server as its own experimental protocol. It does not translate the ChatGPT session into a generic OpenAI /v1 API and it does not accept direct browser connections.";
166
+ : codexChat
167
+ ? "This exposes Relmio-specific HTTP POST /chat for a trusted local backend or development server. It is not OpenAI /v1, has no CORS, and browser bundles must never connect directly."
168
+ : "This runs Codex App Server as its own experimental protocol. It does not translate the ChatGPT session into a generic OpenAI /v1 API and it does not accept direct browser connections.";
151
169
  }
152
170
 
153
171
  function appendPolicyNotice(container, heading, detail) {
@@ -159,33 +177,44 @@ function appendPolicyNotice(container, heading, detail) {
159
177
  }
160
178
 
161
179
  function renderPlan(plan) {
162
- const isOpenAiApi = plan.target === "openai-api";
180
+ const openAiApi = isOpenAiApi(plan.target);
181
+ const codexChat = isCodexChat(plan.target);
163
182
  element("review-endpoint").textContent = plan.endpoint;
164
- element("review-protocol").textContent = isOpenAiApi
183
+ element("review-protocol").textContent = openAiApi
165
184
  ? "OpenAI-compatible HTTP /v1"
166
- : "Codex App Server JSON-RPC over WebSocket";
167
- element("review-auth").textContent = isOpenAiApi
185
+ : codexChat
186
+ ? "Relmio Codex Chat HTTP: POST /chat"
187
+ : "Codex App Server JSON-RPC over WebSocket";
188
+ element("review-auth").textContent = openAiApi
168
189
  ? "OpenAI Platform API key"
169
190
  : "ChatGPT sign-in through official Codex";
170
- element("review-browser").textContent = isOpenAiApi
191
+ element("review-browser").textContent = openAiApi
171
192
  ? plan.allowedOrigins.length > 0
172
193
  ? `Only ${plan.allowedOrigins.length} exact allowed origin(s)`
173
194
  : "Native clients only until exact origins are added"
174
- : "No — trusted native local clients only";
175
- element("review-origins-row").hidden = !isOpenAiApi;
176
- element("review-origins").textContent = isOpenAiApi
195
+ : codexChat
196
+ ? "No trusted local backends and development servers only"
197
+ : "No trusted native local clients only";
198
+ element("review-origins-row").hidden = !openAiApi;
199
+ element("review-origins").textContent = openAiApi
177
200
  ? plan.allowedOrigins.length > 0
178
201
  ? plan.allowedOrigins.join(", ")
179
202
  : "None"
180
203
  : "";
181
204
  element("review-path").textContent = plan.managedPath;
182
205
 
183
- if (isOpenAiApi) {
206
+ if (openAiApi) {
184
207
  appendPolicyNotice(
185
208
  element("review-policy"),
186
209
  "OpenAI Platform terms and billing apply",
187
210
  "This option sends requests to the OpenAI API with your developer Platform key. ChatGPT subscriptions and open-source program benefits do not turn a ChatGPT credential into an API key.",
188
211
  );
212
+ } else if (codexChat) {
213
+ appendPolicyNotice(
214
+ element("review-policy"),
215
+ "Experimental Codex Chat Adapter — trusted local backends or development servers only",
216
+ "This option runs a small authenticated Relmio HTTP adapter on loopback. It accepts only POST /chat from a trusted local backend or development server. It has no CORS and is not OpenAI /v1. ChatGPT credentials stay in the isolated Codex Docker volume and never become Platform API keys.",
217
+ );
189
218
  } else {
190
219
  appendPolicyNotice(
191
220
  element("review-policy"),
@@ -196,18 +225,31 @@ function renderPlan(plan) {
196
225
  }
197
226
 
198
227
  function prepareInstallPanel() {
199
- const isOpenAiApi = state.plan.target === "openai-api";
228
+ const openAiApi = isOpenAiApi(state.plan.target);
229
+ const codexChat = isCodexChat(state.plan.target);
200
230
  const apiKey = element("platform-api-key");
201
- element("api-key-field").hidden = !isOpenAiApi;
202
- element("codex-install-warning").hidden = isOpenAiApi;
203
- apiKey.required = isOpenAiApi;
231
+ element("api-key-field").hidden = !openAiApi;
232
+ element("codex-install-warning").hidden = openAiApi;
233
+ element("codex-install-warning-title").textContent = codexChat
234
+ ? "Credential for trusted local backends or development servers only"
235
+ : "High-trust capability";
236
+ element("codex-install-warning-detail").textContent = codexChat
237
+ ? "The generated bearer authorizes chat turns through your signed-in Codex container. Keep it only in a trusted local backend or development server; never put it in browser code."
238
+ : "Anyone holding the generated client credential can control Codex inside its isolated container, act through your signed-in ChatGPT session, and may be able to recover that container's ChatGPT session credential. Treat this capability like your ChatGPT password and give it only to a trusted native local app.";
239
+ apiKey.required = openAiApi;
204
240
  apiKey.value = "";
205
- element("install-intro").textContent = isOpenAiApi
241
+ element("install-intro").textContent = openAiApi
206
242
  ? "Enter the OpenAI Platform API key this endpoint will use upstream. It is sent only to this local Relmio process."
207
- : "Relmio will install the official Codex App Server first. You will complete ChatGPT device sign-in after the container is ready.";
243
+ : codexChat
244
+ ? "Relmio will install the experimental Codex Chat Adapter and official Codex App Server first. You will complete ChatGPT device sign-in after the container is ready."
245
+ : "Relmio will install the official Codex App Server first. You will complete ChatGPT device sign-in after the container is ready.";
208
246
  setButtonLabel(
209
247
  element("install-button"),
210
- isOpenAiApi ? "Install OpenAI API endpoint" : "Install Codex App Server",
248
+ openAiApi
249
+ ? "Install OpenAI API endpoint"
250
+ : codexChat
251
+ ? "Install Codex Chat Adapter"
252
+ : "Install Codex App Server",
211
253
  );
212
254
  }
213
255
 
@@ -215,28 +257,46 @@ function renderInstallResult(result) {
215
257
  if (
216
258
  typeof result.endpoint !== "string" ||
217
259
  typeof result.clientCredential !== "string" ||
218
- !["openai-api", "codex-chatgpt"].includes(result.target)
260
+ !["openai-api", "codex-chatgpt", "codex-chat"].includes(result.target)
219
261
  ) {
220
262
  throw new Error("The local installer returned an unexpected response.");
221
263
  }
222
264
 
223
- const isOpenAiApi = result.target === "openai-api";
265
+ const openAiApi = isOpenAiApi(result.target);
266
+ const codexChat = isCodexChat(result.target);
267
+ state.installedTarget = result.target;
224
268
  element("result-endpoint").textContent = result.endpoint;
225
269
  element("result-credential").textContent = result.clientCredential;
226
- element("codex-production-warning").hidden = isOpenAiApi;
227
- element("codex-login").hidden = isOpenAiApi;
228
- element("done-title").textContent = isOpenAiApi
270
+ element("codex-production-warning").hidden = openAiApi;
271
+ element("codex-login").hidden = openAiApi;
272
+ element("codex-production-warning-title").textContent = codexChat
273
+ ? "Experimental Chat Adapter — trusted local backends or development servers only"
274
+ : "Experimental WebSocket transport";
275
+ element("codex-production-warning-detail").textContent = codexChat
276
+ ? "The adapter is for trusted local backends or development servers only. It has a Relmio-specific POST /chat contract, no CORS, and is not OpenAI /v1."
277
+ : "Codex App Server WebSocket is experimental and unsupported for production workloads.";
278
+ element("done-title").textContent = openAiApi
229
279
  ? "OpenAI API endpoint is ready"
230
- : "Codex App Server is installed";
231
- element("done-detail").textContent = isOpenAiApi
280
+ : codexChat
281
+ ? "Codex Chat Adapter is installed"
282
+ : "Codex App Server is installed";
283
+ element("done-detail").textContent = openAiApi
232
284
  ? "Copy the endpoint and generated bearer credential into your local app."
233
- : "Copy the endpoint and capability, then sign the isolated Codex container in to ChatGPT.";
285
+ : codexChat
286
+ ? "Copy the endpoint and generated bearer credential into your trusted local backend or development server, then sign the isolated Codex container in to ChatGPT."
287
+ : "Copy the endpoint and capability, then sign the isolated Codex container in to ChatGPT.";
234
288
  appendPolicyNotice(
235
289
  element("client-warning"),
236
- isOpenAiApi ? "For local OpenAI-compatible clients" : "For trusted native Codex clients only",
237
- isOpenAiApi
290
+ openAiApi
291
+ ? "For local OpenAI-compatible clients"
292
+ : codexChat
293
+ ? "For trusted local backends or development servers only"
294
+ : "For trusted native Codex clients only",
295
+ openAiApi
238
296
  ? "Use the generated client credential as the bearer API key. Your upstream Platform key remains private in the managed Docker volume."
239
- : "This capability is not an OpenAI API key. Treat it like your ChatGPT password: the client is trusted to control the isolated container and may recover its ChatGPT session credential. It must speak official Codex App Server JSON-RPC over WebSocket.",
297
+ : codexChat
298
+ ? "Use this one-time credential only as a Bearer token for the Relmio-specific POST /chat endpoint. It is not an OpenAI API key, has no browser CORS support, and must remain in a trusted local backend or development server."
299
+ : "This capability is not an OpenAI API key. Treat it like your ChatGPT password: the client is trusted to control the isolated container and may recover its ChatGPT session credential. It must speak official Codex App Server JSON-RPC over WebSocket.",
240
300
  );
241
301
  }
242
302
 
@@ -430,7 +490,9 @@ element("install-button").addEventListener("click", async (event) => {
430
490
  setMessage(
431
491
  result.target === "openai-api"
432
492
  ? "Local OpenAI API endpoint verified. Copy its one-time client credential now."
433
- : "Codex App Server verified. Copy its one-time capability and complete ChatGPT sign-in.",
493
+ : result.target === "codex-chat"
494
+ ? "Codex Chat Adapter for trusted local backends or development servers verified. Copy its one-time bearer and complete ChatGPT sign-in."
495
+ : "Codex App Server verified. Copy its one-time capability and complete ChatGPT sign-in.",
434
496
  );
435
497
  } catch (error) {
436
498
  invalidatePlan();
@@ -444,6 +506,44 @@ element("install-button").addEventListener("click", async (event) => {
444
506
  }
445
507
  });
446
508
 
509
+ element("rotate-credential-button").addEventListener("click", async (event) => {
510
+ const button = event.currentTarget;
511
+ clearError();
512
+ if (!state.installedTarget) {
513
+ showError(new Error("Install a local endpoint before rotating its credential."));
514
+ return;
515
+ }
516
+
517
+ setBusy(button, true, "Rotating credential…");
518
+ setMessage(
519
+ "Generating a replacement credential before activating it…",
520
+ );
521
+ try {
522
+ const staged = await api("/api/local/client-credential/rotate", {
523
+ method: "POST",
524
+ body: { target: state.installedTarget },
525
+ });
526
+ renderInstallResult(staged);
527
+ setMessage("Replacement credential received. Activating and verifying it now…");
528
+ await new Promise((resolvePromise) => window.requestAnimationFrame(resolvePromise));
529
+ await new Promise((resolvePromise) => window.requestAnimationFrame(resolvePromise));
530
+ const activated = await api("/api/local/client-credential/activate", {
531
+ method: "POST",
532
+ body: {
533
+ rotationId: staged.rotationId,
534
+ clientCredential: staged.clientCredential,
535
+ },
536
+ });
537
+ renderInstallResult({ ...staged, ...activated });
538
+ setMessage("Client credential rotated. Copy the new one now; the previous one no longer works.");
539
+ } catch (error) {
540
+ setMessage("The replacement credential was not confirmed active. Follow the error guidance before retrying.");
541
+ showError(error);
542
+ } finally {
543
+ setBusy(button, false);
544
+ }
545
+ });
546
+
447
547
  element("codex-login-button").addEventListener("click", async (event) => {
448
548
  const button = event.currentTarget;
449
549
  const resultBox = element("device-code-result");
@@ -454,7 +554,7 @@ element("codex-login-button").addEventListener("click", async (event) => {
454
554
  try {
455
555
  const result = await api("/api/local/codex/login", {
456
556
  method: "POST",
457
- body: {},
557
+ body: { target: state.installedTarget },
458
558
  });
459
559
  const verificationUrl = validateVerificationUrl(result.verificationUrl);
460
560
  const userCode = validateDeviceCode(result.userCode);
@@ -464,7 +564,9 @@ element("codex-login-button").addEventListener("click", async (event) => {
464
564
  resultBox.hidden = false;
465
565
  setMessage("Open the official OpenAI page and enter the displayed device code.");
466
566
  await waitForCodexLogin();
467
- status.textContent = "ChatGPT sign-in completed. Codex is ready for your trusted native client.";
567
+ status.textContent = isCodexChat(state.installedTarget)
568
+ ? "ChatGPT sign-in completed. Codex Chat Adapter is ready for your trusted local backend or development server."
569
+ : "ChatGPT sign-in completed. Codex is ready for your trusted native client.";
468
570
  setMessage("Codex ChatGPT sign-in completed successfully.");
469
571
  } catch (error) {
470
572
  status.textContent = "ChatGPT sign-in did not complete.";
@@ -546,5 +648,23 @@ async function refreshDockerStatus() {
546
648
  }
547
649
  }
548
650
 
651
+ async function refreshProjectMeta() {
652
+ const result = await api("/api/local/project-meta");
653
+ const stars = Number.isSafeInteger(result.stars) && result.stars >= 0
654
+ ? new Intl.NumberFormat("en", {
655
+ notation: result.stars >= 1_000 ? "compact" : "standard",
656
+ maximumFractionDigits: 1,
657
+ }).format(result.stars)
658
+ : "?";
659
+ element("local-repository-stars").textContent = stars;
660
+ element("local-repository-button").setAttribute(
661
+ "aria-label",
662
+ `Open Relmio version ${result.version} on GitHub. ${
663
+ stars === "?" ? "GitHub star count is unavailable." : `${result.stars} GitHub stars.`
664
+ } Opens in a new tab.`,
665
+ );
666
+ }
667
+
549
668
  renderTarget();
550
669
  refreshDockerStatus().catch(showError);
670
+ refreshProjectMeta().catch(() => {});