@monster-orange/den 0.4.2 → 0.4.3
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/CHANGELOG.md +94 -0
- package/README.md +7 -7
- package/docs/distributions.md +126 -13
- package/package.json +1 -1
- package/src/shared/agents/den-claude.ts +57 -12
- package/src/shared/agents/generic.ts +1 -0
- package/src/shared/agents/runtime-search.ts +416 -0
- package/src/shared/agents/runtime.ts +26 -1
- package/src/shared/agents/runtimes.ts +30 -3
- package/src/shared/den-config.ts +36 -0
- package/src/shared/edge/render.ts +31 -0
- package/src/ui/cli/agent-ops.ts +107 -19
- package/src/ui/cli/agents-command.ts +99 -3
- package/src/ui/cli/sessions-command.ts +9 -4
- package/src/ui/cli/web-command.ts +53 -8
- package/src/ui/server/agent-routes.ts +9 -2
- package/src/ui/server/edge-exposure.ts +143 -0
- package/src/ui/server/index.ts +94 -19
- package/src/ui/server/public/agent-start.js +202 -0
- package/src/ui/server/public/agents-page.js +83 -17
- package/src/ui/server/public/agents-section.js +6 -18
- package/src/ui/server/public/agents.html +70 -0
- package/src/ui/server/public/i18n/es.js +11 -0
- package/src/ui/server/public/i18n/ja.js +11 -0
- package/src/ui/server/public/local-dev.html +43 -14
- package/src/ui/server/public/settings-registry.js +1 -1
- package/src/ui/server/public/shell.js +40 -0
- package/src/ui/server/public/topbar.js +127 -28
- package/src/ui/server/request-guard.ts +122 -7
- package/src/ui/server/ui-assets.ts +2 -0
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* (.den-sec, .den-btn, .den-dot) are reused as they are.
|
|
17
17
|
*/
|
|
18
18
|
import { requireUnlock } from "/topbar.js";
|
|
19
|
+
import { fillStartForm } from "/agent-start.js";
|
|
19
20
|
|
|
20
21
|
const t = (key, vars) => window.denShell?.t?.(key, vars) ?? key;
|
|
21
22
|
const esc = (s) =>
|
|
@@ -62,6 +63,8 @@ export function agentsSectionHtml(ws) {
|
|
|
62
63
|
<form class="agent-form" id="agent-start-form" hidden>
|
|
63
64
|
<label>${esc(t("ag.persona"))} <select class="form-sel" name="persona" required></select></label>
|
|
64
65
|
<label>${esc(t("ag.runtime"))} <select class="form-sel" name="runtime"></select></label>
|
|
66
|
+
<p class="agent-form-help" data-persona-help hidden></p>
|
|
67
|
+
<p class="agent-form-ready" data-runtime-ready role="status" hidden></p>
|
|
65
68
|
<button type="submit" class="den-btn sm primary" id="agent-start-go">${esc(t("ag.go"))}</button>
|
|
66
69
|
<button type="button" class="den-btn sm" data-agent-cancel>${esc(t("ag.cancel"))}</button>
|
|
67
70
|
</form>
|
|
@@ -185,22 +188,7 @@ async function refresh(ws) {
|
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
function fillForm(form, data) {
|
|
188
|
-
|
|
189
|
-
ps.innerHTML = (data.personas ?? [])
|
|
190
|
-
.map(
|
|
191
|
-
(p) =>
|
|
192
|
-
`<option value="${esc(p.id)}">${esc(p.id)}${p.role ? ` — ${esc(p.role)}` : ""}</option>`,
|
|
193
|
-
)
|
|
194
|
-
.join("");
|
|
195
|
-
const rs = form.querySelector('select[name="runtime"]');
|
|
196
|
-
rs.innerHTML =
|
|
197
|
-
`<option value="">${esc(t("ag.runtime.auto"))}</option>` +
|
|
198
|
-
(data.runtimes ?? [])
|
|
199
|
-
.map(
|
|
200
|
-
(r) =>
|
|
201
|
-
`<option value="${esc(r.id)}"${r.available ? "" : " disabled"}>${esc(r.id)}${r.available ? "" : ` — ${esc(t("ag.unavailable"))}`}</option>`,
|
|
202
|
-
)
|
|
203
|
-
.join("");
|
|
191
|
+
fillStartForm(form, data, t);
|
|
204
192
|
}
|
|
205
193
|
|
|
206
194
|
async function openForm(sec) {
|
|
@@ -229,7 +217,7 @@ async function openForm(sec) {
|
|
|
229
217
|
}
|
|
230
218
|
|
|
231
219
|
async function act(ws, verb, id, button) {
|
|
232
|
-
if (!requireUnlock()) return;
|
|
220
|
+
if (!(await requireUnlock())) return;
|
|
233
221
|
button.disabled = true;
|
|
234
222
|
try {
|
|
235
223
|
await api("POST", `${base(ws)}/${encodeURIComponent(id)}/${verb}`);
|
|
@@ -243,7 +231,7 @@ async function act(ws, verb, id, button) {
|
|
|
243
231
|
}
|
|
244
232
|
|
|
245
233
|
async function start(ws, form) {
|
|
246
|
-
if (!requireUnlock()) return;
|
|
234
|
+
if (!(await requireUnlock())) return;
|
|
247
235
|
const go = form.querySelector("#agent-start-go");
|
|
248
236
|
const persona = form.querySelector('select[name="persona"]').value;
|
|
249
237
|
const runtime = form.querySelector('select[name="runtime"]').value;
|
|
@@ -311,8 +311,56 @@
|
|
|
311
311
|
}
|
|
312
312
|
.ag-dlg form {
|
|
313
313
|
display: grid;
|
|
314
|
+
/* AG-10.0: minmax(0, 1fr), so a long option can't widen the form
|
|
315
|
+
past the dialog and push Cancel and Start out of sight. */
|
|
316
|
+
grid-template-columns: minmax(0, 1fr);
|
|
314
317
|
gap: 12px;
|
|
315
318
|
}
|
|
319
|
+
.ag-dlg .help {
|
|
320
|
+
margin: -6px 0 0;
|
|
321
|
+
font: 12.5px/1.45 var(--sans);
|
|
322
|
+
color: var(--muted);
|
|
323
|
+
overflow-wrap: anywhere;
|
|
324
|
+
}
|
|
325
|
+
.ag-dlg .help[hidden],
|
|
326
|
+
.ag-dlg .ready[hidden],
|
|
327
|
+
.ag-dlg .err[hidden],
|
|
328
|
+
.ag-dlg .ag-lock[hidden] {
|
|
329
|
+
display: none;
|
|
330
|
+
}
|
|
331
|
+
/* AG-0.2: the runtime it will run, or why it can't, before Start
|
|
332
|
+
(the mock's .helpl: 12px, faint, 1.4). */
|
|
333
|
+
.ag-dlg .ready {
|
|
334
|
+
margin: -6px 0 0;
|
|
335
|
+
font: 12px/1.4 var(--sans);
|
|
336
|
+
color: var(--faint);
|
|
337
|
+
overflow-wrap: anywhere;
|
|
338
|
+
}
|
|
339
|
+
.ag-dlg .ready[data-tone="warn"] {
|
|
340
|
+
color: var(--warn);
|
|
341
|
+
}
|
|
342
|
+
.ag-dlg .ready .mono {
|
|
343
|
+
font-family: var(--mono);
|
|
344
|
+
}
|
|
345
|
+
/* LK-1: locked, the password is asked here, at the top of the dialog,
|
|
346
|
+
and Start unlocks then starts. */
|
|
347
|
+
.ag-dlg .ag-lock {
|
|
348
|
+
display: grid;
|
|
349
|
+
gap: 6px;
|
|
350
|
+
padding: 10px 12px;
|
|
351
|
+
border: 1px solid
|
|
352
|
+
color-mix(in srgb, var(--warn) 45%, var(--border));
|
|
353
|
+
border-radius: var(--radius-button);
|
|
354
|
+
background: color-mix(in srgb, var(--warn) 8%, var(--raised));
|
|
355
|
+
}
|
|
356
|
+
.ag-dlg .ag-lock .help {
|
|
357
|
+
margin: 0;
|
|
358
|
+
}
|
|
359
|
+
.ag-dlg .err {
|
|
360
|
+
margin: 0;
|
|
361
|
+
font: 12.5px/1.45 var(--sans);
|
|
362
|
+
color: var(--bad);
|
|
363
|
+
}
|
|
316
364
|
.ag-dlg h2 {
|
|
317
365
|
margin: 0;
|
|
318
366
|
font: 600 17px var(--display);
|
|
@@ -325,7 +373,10 @@
|
|
|
325
373
|
text-transform: uppercase;
|
|
326
374
|
letter-spacing: 0.07em;
|
|
327
375
|
}
|
|
376
|
+
.ag-dlg input[type="password"],
|
|
328
377
|
.ag-dlg select {
|
|
378
|
+
width: 100%;
|
|
379
|
+
min-width: 0;
|
|
329
380
|
font: 13px var(--sans);
|
|
330
381
|
text-transform: none;
|
|
331
382
|
letter-spacing: 0;
|
|
@@ -396,6 +447,17 @@
|
|
|
396
447
|
<dialog class="ag-dlg" id="ag-dlg" aria-labelledby="ag-dlg-h">
|
|
397
448
|
<form method="dialog" id="ag-form">
|
|
398
449
|
<h2 id="ag-dlg-h">Start an agent</h2>
|
|
450
|
+
<div class="ag-lock" id="ag-lock" hidden>
|
|
451
|
+
<label
|
|
452
|
+
><span id="ag-lock-t">Admin password</span>
|
|
453
|
+
<input
|
|
454
|
+
type="password"
|
|
455
|
+
id="ag-lock-pw"
|
|
456
|
+
autocomplete="current-password"
|
|
457
|
+
/></label>
|
|
458
|
+
<p class="help" id="ag-lock-note"></p>
|
|
459
|
+
<p class="err" id="ag-lock-err" role="alert" hidden></p>
|
|
460
|
+
</div>
|
|
399
461
|
<label id="ag-ws-l"
|
|
400
462
|
><span id="ag-ws-t">Workspace</span>
|
|
401
463
|
<select name="ws" id="ag-ws"></select
|
|
@@ -404,10 +466,18 @@
|
|
|
404
466
|
><span id="ag-p-t">Persona</span>
|
|
405
467
|
<select name="persona" id="ag-persona" required></select
|
|
406
468
|
></label>
|
|
469
|
+
<p class="help" id="ag-p-help" data-persona-help hidden></p>
|
|
407
470
|
<label
|
|
408
471
|
><span id="ag-r-t">Runtime</span>
|
|
409
472
|
<select name="runtime" id="ag-runtime"></select
|
|
410
473
|
></label>
|
|
474
|
+
<p
|
|
475
|
+
class="ready"
|
|
476
|
+
id="ag-r-ready"
|
|
477
|
+
data-runtime-ready
|
|
478
|
+
role="status"
|
|
479
|
+
hidden
|
|
480
|
+
></p>
|
|
411
481
|
<div class="btns">
|
|
412
482
|
<button type="button" class="den-btn" id="ag-cancel">Cancel</button>
|
|
413
483
|
<button type="submit" class="den-btn primary" id="ag-go">
|
|
@@ -330,6 +330,7 @@
|
|
|
330
330
|
"ag.persona": "Persona",
|
|
331
331
|
"ag.runtime": "Runtime",
|
|
332
332
|
"ag.runtime.auto": "La de la persona",
|
|
333
|
+
"ag.runtime.default": "{name} (la de la persona)",
|
|
333
334
|
"ag.unavailable": "no disponible",
|
|
334
335
|
"ag.go": "Iniciar",
|
|
335
336
|
"ag.starting": "Iniciando…",
|
|
@@ -372,5 +373,15 @@
|
|
|
372
373
|
"ag.palStart": "Iniciar agente…",
|
|
373
374
|
"ag.cap":
|
|
374
375
|
"Hasta 4 paneles: se cerró el de {id} para hacer sitio (sigue en marcha)",
|
|
376
|
+
"ag.rt.ready": "Inicia {name} {version} desde",
|
|
377
|
+
"ag.rt.readyNoVer": "Inicia {name} desde",
|
|
378
|
+
"ag.rt.notFound": "No se encontró {name}.",
|
|
379
|
+
"ag.rt.searched": "Se buscó en:",
|
|
380
|
+
"ag.rt.more": "y {n} más",
|
|
381
|
+
"ag.rt.nowhere": "ningún directorio",
|
|
382
|
+
"ag.rt.refused": "{name} no puede iniciarse: {why}.",
|
|
383
|
+
"ag.rt.fix":
|
|
384
|
+
"Instálalo, o pon su ruta en {key} en {file}, o ejecuta {cmd}.",
|
|
385
|
+
"ag.rt.unknown": "Aquí no hay ningún runtime {id}.",
|
|
375
386
|
};
|
|
376
387
|
})((window.denMessages = window.denMessages || {}));
|
|
@@ -318,6 +318,7 @@
|
|
|
318
318
|
"ag.persona": "ペルソナ",
|
|
319
319
|
"ag.runtime": "ランタイム",
|
|
320
320
|
"ag.runtime.auto": "ペルソナの既定",
|
|
321
|
+
"ag.runtime.default": "{name}(ペルソナの既定)",
|
|
321
322
|
"ag.unavailable": "利用不可",
|
|
322
323
|
"ag.go": "開始",
|
|
323
324
|
"ag.starting": "開始中…",
|
|
@@ -357,5 +358,15 @@
|
|
|
357
358
|
"ag.palStart": "エージェントを開始…",
|
|
358
359
|
"ag.cap":
|
|
359
360
|
"ペインは最大 4 つです。{id} のペインを閉じました(実行は続きます)",
|
|
361
|
+
"ag.rt.ready": "{name} {version} を起動:",
|
|
362
|
+
"ag.rt.readyNoVer": "{name} を起動:",
|
|
363
|
+
"ag.rt.notFound": "{name} が見つかりません。",
|
|
364
|
+
"ag.rt.searched": "検索した場所:",
|
|
365
|
+
"ag.rt.more": "ほか {n} 件",
|
|
366
|
+
"ag.rt.nowhere": "なし",
|
|
367
|
+
"ag.rt.refused": "{name} を起動できません: {why}。",
|
|
368
|
+
"ag.rt.fix":
|
|
369
|
+
"インストールするか、{file} の {key} にパスを設定するか、{cmd} を実行してください。",
|
|
370
|
+
"ag.rt.unknown": "ランタイム {id} はここにありません。",
|
|
360
371
|
};
|
|
361
372
|
})((window.denMessages = window.denMessages || {}));
|
|
@@ -1371,6 +1371,34 @@
|
|
|
1371
1371
|
}
|
|
1372
1372
|
.agent-form select {
|
|
1373
1373
|
max-width: 320px;
|
|
1374
|
+
min-width: 0;
|
|
1375
|
+
}
|
|
1376
|
+
.agent-form-help {
|
|
1377
|
+
flex-basis: 100%;
|
|
1378
|
+
margin: 0;
|
|
1379
|
+
font-size: 12px;
|
|
1380
|
+
color: var(--muted);
|
|
1381
|
+
overflow-wrap: anywhere;
|
|
1382
|
+
}
|
|
1383
|
+
.agent-form-help[hidden],
|
|
1384
|
+
.agent-form-ready[hidden] {
|
|
1385
|
+
display: none;
|
|
1386
|
+
}
|
|
1387
|
+
/* AG-0.2: the runtime it will run, or why it can't (the mock's .helpl). */
|
|
1388
|
+
.agent-form-ready {
|
|
1389
|
+
flex-basis: 100%;
|
|
1390
|
+
order: 1;
|
|
1391
|
+
margin: 0;
|
|
1392
|
+
font-size: 12px;
|
|
1393
|
+
line-height: 1.4;
|
|
1394
|
+
color: var(--faint);
|
|
1395
|
+
overflow-wrap: anywhere;
|
|
1396
|
+
}
|
|
1397
|
+
.agent-form-ready[data-tone="warn"] {
|
|
1398
|
+
color: var(--warn);
|
|
1399
|
+
}
|
|
1400
|
+
.agent-form-ready .mono {
|
|
1401
|
+
font-family: var(--mono);
|
|
1374
1402
|
}
|
|
1375
1403
|
.agent-cwd {
|
|
1376
1404
|
max-width: 280px;
|
|
@@ -1518,8 +1546,9 @@
|
|
|
1518
1546
|
.replace(/"/g, """);
|
|
1519
1547
|
|
|
1520
1548
|
// ── The lock (R8): the shell's lock chip and popover (topbar.js) ──────────
|
|
1521
|
-
// requireSudo() is this page's lock rule, shared with every page
|
|
1522
|
-
// when unlocked
|
|
1549
|
+
// requireSudo() is this page's lock rule, shared with every page
|
|
1550
|
+
// (async): true when unlocked, after asking the server and the vault
|
|
1551
|
+
// (LK-2, LK-3); otherwise the lock popover opens on its password
|
|
1523
1552
|
// field and the command doesn't run.
|
|
1524
1553
|
function requireSudo() {
|
|
1525
1554
|
return requireUnlock();
|
|
@@ -3021,8 +3050,8 @@
|
|
|
3021
3050
|
|
|
3022
3051
|
// ── Workspace-level command (SSE) ─────────────────────────────────────────
|
|
3023
3052
|
// Routes output to each service's own tab as `label` events arrive.
|
|
3024
|
-
window.runWorkspaceSync = function () {
|
|
3025
|
-
if (!requireSudo()) return;
|
|
3053
|
+
window.runWorkspaceSync = async function () {
|
|
3054
|
+
if (!(await requireSudo())) return;
|
|
3026
3055
|
setAllButtons(true);
|
|
3027
3056
|
setWsStatus("syncing…");
|
|
3028
3057
|
const es = new EventSource("/api/workspace/sync");
|
|
@@ -3073,8 +3102,8 @@
|
|
|
3073
3102
|
// rather than a shared pane, so it sits with everything else for that
|
|
3074
3103
|
// system. Buttons are not globally disabled: it is one repo.
|
|
3075
3104
|
const _syncing = new Set();
|
|
3076
|
-
window.runRepoSync = function (repo) {
|
|
3077
|
-
if (!requireSudo()) return;
|
|
3105
|
+
window.runRepoSync = async function (repo) {
|
|
3106
|
+
if (!(await requireSudo())) return;
|
|
3078
3107
|
if (_syncing.has(repo)) return; // already in flight — ignore re-entry
|
|
3079
3108
|
_syncing.add(repo);
|
|
3080
3109
|
|
|
@@ -3126,8 +3155,8 @@
|
|
|
3126
3155
|
};
|
|
3127
3156
|
};
|
|
3128
3157
|
|
|
3129
|
-
window.runWorkspaceCmd = function (cmd, addresses) {
|
|
3130
|
-
if (!requireSudo()) return;
|
|
3158
|
+
window.runWorkspaceCmd = async function (cmd, addresses) {
|
|
3159
|
+
if (!(await requireSudo())) return;
|
|
3131
3160
|
const only =
|
|
3132
3161
|
Array.isArray(addresses) && addresses.length
|
|
3133
3162
|
? `&addresses=${encodeURIComponent(addresses.join(","))}`
|
|
@@ -3240,8 +3269,8 @@
|
|
|
3240
3269
|
// is derived on the SERVER (see the `seed` param in /api/workspace/exec):
|
|
3241
3270
|
// its rules are load-bearing, and the already-healthy skip it relies on is
|
|
3242
3271
|
// suppressed whenever an explicit address list is sent.
|
|
3243
|
-
window.runUpWithDeps = function (repo) {
|
|
3244
|
-
if (!requireSudo()) return;
|
|
3272
|
+
window.runUpWithDeps = async function (repo) {
|
|
3273
|
+
if (!(await requireSudo())) return;
|
|
3245
3274
|
void runWorkspaceCmdAsync("up", null, {
|
|
3246
3275
|
seed: repo,
|
|
3247
3276
|
undismiss: true,
|
|
@@ -3252,16 +3281,16 @@
|
|
|
3252
3281
|
// same closure as "up with deps", reversed. Derived server-side for the
|
|
3253
3282
|
// same reason as its counterpart: the already-stopped skip is suppressed
|
|
3254
3283
|
// when an explicit address list is sent.
|
|
3255
|
-
window.runDownWithDeps = function (repo) {
|
|
3256
|
-
if (!requireSudo()) return;
|
|
3284
|
+
window.runDownWithDeps = async function (repo) {
|
|
3285
|
+
if (!(await requireSudo())) return;
|
|
3257
3286
|
void runWorkspaceCmdAsync("down", null, {
|
|
3258
3287
|
seed: repo,
|
|
3259
3288
|
undismiss: true,
|
|
3260
3289
|
});
|
|
3261
3290
|
};
|
|
3262
3291
|
|
|
3263
|
-
window.runCmd = function (address, repo, service, cmd) {
|
|
3264
|
-
if (!requireSudo()) return;
|
|
3292
|
+
window.runCmd = async function (address, repo, service, cmd) {
|
|
3293
|
+
if (!(await requireSudo())) return;
|
|
3265
3294
|
// Explicit single-service action — undo any prior dismissal so its pane returns.
|
|
3266
3295
|
clearDismissed(address);
|
|
3267
3296
|
// up/restart/down go through the workspace-exec runner: it uses the full
|
|
@@ -472,7 +472,7 @@ export const DEN_SETTINGS = [
|
|
|
472
472
|
category: "cli",
|
|
473
473
|
label: "Browser sign-in",
|
|
474
474
|
description:
|
|
475
|
-
"Signing in is needed only when this server is reached
|
|
475
|
+
"Signing in is needed only when this server is reached from another machine (through the edge, or a LAN address); on localhost, and through the edge from this machine, nothing asks for it. __DEN_CLI__ web login prints a sign-in link; __DEN_CLI__ web sessions lists signed-in browsers.",
|
|
476
476
|
keywords: ["sign in", "sign out", "login", "logout", "session", "browser"],
|
|
477
477
|
scope: "user",
|
|
478
478
|
control: {
|
|
@@ -444,6 +444,7 @@
|
|
|
444
444
|
"ag.persona": "Persona",
|
|
445
445
|
"ag.runtime": "Runtime",
|
|
446
446
|
"ag.runtime.auto": "The persona's",
|
|
447
|
+
"ag.runtime.default": "{name} (persona default)",
|
|
447
448
|
"ag.unavailable": "unavailable",
|
|
448
449
|
"ag.go": "Start",
|
|
449
450
|
"ag.starting": "Starting…",
|
|
@@ -484,6 +485,16 @@
|
|
|
484
485
|
"ag.pal": "Agents › {ws} / {name}",
|
|
485
486
|
"ag.palStart": "Start agent…",
|
|
486
487
|
"ag.cap": "Up to 4 panes: {id}'s closed to make room (it keeps running)",
|
|
488
|
+
"ag.rt.ready": "Starts {name} {version} from",
|
|
489
|
+
"ag.rt.readyNoVer": "Starts {name} from",
|
|
490
|
+
"ag.rt.notFound": "{name} not found.",
|
|
491
|
+
"ag.rt.searched": "Searched:",
|
|
492
|
+
"ag.rt.more": "and {n} more",
|
|
493
|
+
"ag.rt.nowhere": "no directories",
|
|
494
|
+
"ag.rt.refused": "{name} can't start: {why}.",
|
|
495
|
+
"ag.rt.fix":
|
|
496
|
+
"Install it, or set {key} to its path in {file}, or run {cmd}.",
|
|
497
|
+
"ag.rt.unknown": "No runtime {id} here.",
|
|
487
498
|
};
|
|
488
499
|
// The languages the UI has a catalog for, each named in itself (a name is
|
|
489
500
|
// never translated). The server's list is ui-language.ts's UI_LANGUAGES.
|
|
@@ -3361,6 +3372,31 @@
|
|
|
3361
3372
|
}
|
|
3362
3373
|
}
|
|
3363
3374
|
|
|
3375
|
+
// 0.4.3: the edge is reachable from other machines (the server fills
|
|
3376
|
+
// data-edge-exposed on #den-shell). One line; dismissed per browser.
|
|
3377
|
+
var EDGE_LS = "den.edgeExposed.dismissed";
|
|
3378
|
+
var edgeDismissed = false;
|
|
3379
|
+
try {
|
|
3380
|
+
edgeDismissed = localStorage.getItem(EDGE_LS) === "1";
|
|
3381
|
+
} catch (e) {
|
|
3382
|
+
edgeDismissed = false;
|
|
3383
|
+
}
|
|
3384
|
+
if (host.dataset.edgeExposed && !edgeDismissed)
|
|
3385
|
+
showBanner("edge-exposed", {
|
|
3386
|
+
text: host.dataset.edgeExposed,
|
|
3387
|
+
action: {
|
|
3388
|
+
label: t("api.close"),
|
|
3389
|
+
run: function () {
|
|
3390
|
+
try {
|
|
3391
|
+
localStorage.setItem(EDGE_LS, "1");
|
|
3392
|
+
} catch (e) {
|
|
3393
|
+
/* no storage: dismissed for this page only */
|
|
3394
|
+
}
|
|
3395
|
+
hideBanner("edge-exposed");
|
|
3396
|
+
},
|
|
3397
|
+
},
|
|
3398
|
+
});
|
|
3399
|
+
|
|
3364
3400
|
// The config fails validation (0.4.1): the server marks every page it
|
|
3365
3401
|
// serves while it does (data-config-invalid on #den-shell), and the first
|
|
3366
3402
|
// /api answer of 422 config_invalid marks it too. From then on the shell
|
|
@@ -3817,6 +3853,10 @@
|
|
|
3817
3853
|
" max-height: calc(100vh - 72px); overflow-y: auto; box-sizing: border-box;",
|
|
3818
3854
|
" font: 14px/1.45 var(--sans); color: var(--text); }",
|
|
3819
3855
|
".den-pop:focus { outline: none; }",
|
|
3856
|
+
// LK-1: the lock popover is a <dialog> in the top layer (a popover, or
|
|
3857
|
+
// a modal over another modal): placed by its top/left, no UA box.
|
|
3858
|
+
"dialog.den-pop { margin: 0; inset: auto; max-width: none; }",
|
|
3859
|
+
"dialog.den-pop::backdrop { background: transparent; }",
|
|
3820
3860
|
".den-pop :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }",
|
|
3821
3861
|
".den-pop .den-ml { font: 11px var(--mono); color: var(--faint); text-transform: uppercase;",
|
|
3822
3862
|
" letter-spacing: .07em; padding: 6px 8px 2px; }",
|
|
@@ -15,9 +15,18 @@
|
|
|
15
15
|
* form in a popover (Local Dev's sudo banner, moved)
|
|
16
16
|
*
|
|
17
17
|
* Exports for pages (through header.js): requireUnlock() (Local Dev's lock
|
|
18
|
-
* rule: true when unlocked, else the lock popover opens),
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* rule, async: true when unlocked, else the lock popover opens), and
|
|
19
|
+
* checkUnlock() and unlockWith(password) for a modal that asks inline
|
|
20
|
+
* (LK-1), isUnlocked(), onLockChange(fn), setServiceRunner(fn) (the page
|
|
21
|
+
* that runs Start and Restart itself), takePendingAction().
|
|
22
|
+
*
|
|
23
|
+
* The lock (LK, 0.4.3): the page's own idea of it is never the last word.
|
|
24
|
+
* When it thinks it's locked, an action asks the server again first
|
|
25
|
+
* (another tab or device may have unlocked it, LK-2), then, when a
|
|
26
|
+
* password is stored in the vault, loads it from there (LK-3, once a page,
|
|
27
|
+
* on any page; a page view alone never wakes the vault). The popover is in
|
|
28
|
+
* the top layer (a popover, or its own modal <dialog> when a modal is
|
|
29
|
+
* open), so no modal ever covers it (LK-1).
|
|
21
30
|
*
|
|
22
31
|
* The pure parts (statusSummary, statusChip, problemCards, drawerGroups,
|
|
23
32
|
* checkLine, rankCommands, runOf) take plain data, so tests need no
|
|
@@ -745,9 +754,9 @@ export function setServiceRunner(fn) {
|
|
|
745
754
|
* stores it for this tab (sessionStorage, not a link anyone could open)
|
|
746
755
|
* and Local Dev takes it once (takePendingAction).
|
|
747
756
|
*/
|
|
748
|
-
export function runServiceAction(cmd, svc) {
|
|
757
|
+
export async function runServiceAction(cmd, svc) {
|
|
749
758
|
if (serviceRunner) return serviceRunner(cmd, svc);
|
|
750
|
-
if (!requireUnlock()) return;
|
|
759
|
+
if (!(await requireUnlock())) return;
|
|
751
760
|
try {
|
|
752
761
|
sessionStorage.setItem(
|
|
753
762
|
SS_PENDING,
|
|
@@ -1135,15 +1144,88 @@ export function onLockChange(fn) {
|
|
|
1135
1144
|
}
|
|
1136
1145
|
|
|
1137
1146
|
/**
|
|
1138
|
-
*
|
|
1139
|
-
*
|
|
1147
|
+
* Whether the lock is open, asking before saying no (never prompts): the
|
|
1148
|
+
* server's status (LK-2), then, when a password is stored, the vault, once
|
|
1149
|
+
* a page (LK-3). Resolves true when unlocked.
|
|
1150
|
+
*/
|
|
1151
|
+
let rechecking = null;
|
|
1152
|
+
let vaultTried = false;
|
|
1153
|
+
export function checkUnlock() {
|
|
1154
|
+
if (lock.unlocked) return Promise.resolve(true);
|
|
1155
|
+
rechecking ??= (async () => {
|
|
1156
|
+
const st = await getJson("/api/sudo-password/status", null);
|
|
1157
|
+
if (st && (st.set || st.passwordless)) {
|
|
1158
|
+
setUnlocked(true, !!st.passwordless && !st.set);
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
if (!vaultTried) {
|
|
1162
|
+
await checkVault();
|
|
1163
|
+
if (
|
|
1164
|
+
!lock.unlocked &&
|
|
1165
|
+
lock.vault.backendAvailable &&
|
|
1166
|
+
lock.vault.hasStored
|
|
1167
|
+
) {
|
|
1168
|
+
vaultTried = true;
|
|
1169
|
+
const loaded = await getJson(
|
|
1170
|
+
"/api/sudo-password/load-from-vault",
|
|
1171
|
+
{ ok: false },
|
|
1172
|
+
{ method: "POST" },
|
|
1173
|
+
);
|
|
1174
|
+
if (loaded.ok && !lock.unlocked) setUnlocked(true);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return lock.unlocked;
|
|
1178
|
+
})().finally(() => {
|
|
1179
|
+
rechecking = null;
|
|
1180
|
+
});
|
|
1181
|
+
return rechecking;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
/**
|
|
1185
|
+
* Local Dev's lock rule, for every page (async): true when unlocked, after
|
|
1186
|
+
* checkUnlock; otherwise the lock popover opens on its password field and
|
|
1187
|
+
* the action doesn't run.
|
|
1140
1188
|
*/
|
|
1141
|
-
export function requireUnlock() {
|
|
1142
|
-
if (
|
|
1189
|
+
export async function requireUnlock() {
|
|
1190
|
+
if (await checkUnlock()) return true;
|
|
1143
1191
|
openLock();
|
|
1144
1192
|
return false;
|
|
1145
1193
|
}
|
|
1146
1194
|
|
|
1195
|
+
/**
|
|
1196
|
+
* Unlock with `password` (the popover's form, or a modal's own field):
|
|
1197
|
+
* { ok, savedToVault?, vaultError?, error? }. On success every page
|
|
1198
|
+
* subscriber hears it (onLockChange). Never logs the password.
|
|
1199
|
+
*/
|
|
1200
|
+
export async function unlockWith(password, { saveToVault = false } = {}) {
|
|
1201
|
+
if (!password) return { ok: false, error: t("lock.wrong") };
|
|
1202
|
+
try {
|
|
1203
|
+
const r = await fetch("/api/sudo-password", {
|
|
1204
|
+
method: "POST",
|
|
1205
|
+
headers: { "Content-Type": "application/json" },
|
|
1206
|
+
body: JSON.stringify({ password, saveToVault }),
|
|
1207
|
+
});
|
|
1208
|
+
const data = await r.json().catch(() => ({}));
|
|
1209
|
+
if (r.ok) {
|
|
1210
|
+
if (data.savedToVault) lock.vault.hasStored = true;
|
|
1211
|
+
setUnlocked(true);
|
|
1212
|
+
return {
|
|
1213
|
+
ok: true,
|
|
1214
|
+
savedToVault: !!data.savedToVault,
|
|
1215
|
+
...(data.vaultError ? { vaultError: data.vaultError } : {}),
|
|
1216
|
+
};
|
|
1217
|
+
}
|
|
1218
|
+
// A wrong password is said in the page's language; another refusal
|
|
1219
|
+
// is the server's own message.
|
|
1220
|
+
return {
|
|
1221
|
+
ok: false,
|
|
1222
|
+
error: r.status === 401 || !data.error ? t("lock.wrong") : data.error,
|
|
1223
|
+
};
|
|
1224
|
+
} catch {
|
|
1225
|
+
return { ok: false, error: t("lock.reqFailed") };
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1147
1229
|
function setUnlocked(unlocked, passwordless = false) {
|
|
1148
1230
|
const changed = lock.unlocked !== unlocked || !lock.known;
|
|
1149
1231
|
lock.known = true;
|
|
@@ -1250,6 +1332,7 @@ async function initLock() {
|
|
|
1250
1332
|
await checkVault();
|
|
1251
1333
|
if (!lock.vault.backendAvailable || !lock.vault.hasStored || lock.unlocked)
|
|
1252
1334
|
return;
|
|
1335
|
+
vaultTried = true;
|
|
1253
1336
|
const loaded = await getJson(
|
|
1254
1337
|
"/api/sudo-password/load-from-vault",
|
|
1255
1338
|
{ ok: false },
|
|
@@ -1300,7 +1383,10 @@ function openLock() {
|
|
|
1300
1383
|
closeOverlay(false);
|
|
1301
1384
|
sh()?.closeMenu?.();
|
|
1302
1385
|
if (!lock.unlocked) void checkVault();
|
|
1303
|
-
|
|
1386
|
+
// LK-1: in the top layer, so no modal covers it. Under an open modal
|
|
1387
|
+
// <dialog> only another modal is reachable (a popover there is inert),
|
|
1388
|
+
// so it opens as its own modal; elsewhere it is a popover.
|
|
1389
|
+
const pop = document.createElement("dialog");
|
|
1304
1390
|
pop.className = "den-pop den-lock-pop";
|
|
1305
1391
|
pop.id = "den-lock-pop";
|
|
1306
1392
|
pop.setAttribute("role", "dialog");
|
|
@@ -1335,38 +1421,25 @@ function openLock() {
|
|
|
1335
1421
|
err.hidden = true;
|
|
1336
1422
|
const saveToVault = !!(lock.vault.backendAvailable && saveCb?.checked);
|
|
1337
1423
|
try {
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
headers: { "Content-Type": "application/json" },
|
|
1341
|
-
body: JSON.stringify({ password: pw, saveToVault }),
|
|
1342
|
-
});
|
|
1343
|
-
const data = await r.json();
|
|
1344
|
-
if (r.ok) {
|
|
1424
|
+
const res = await unlockWith(pw, { saveToVault });
|
|
1425
|
+
if (res.ok) {
|
|
1345
1426
|
input.value = "";
|
|
1346
|
-
if (data.savedToVault) lock.vault.hasStored = true;
|
|
1347
1427
|
forceForm = false;
|
|
1348
|
-
setUnlocked(true);
|
|
1349
1428
|
closeOverlay(true);
|
|
1350
1429
|
toast(
|
|
1351
|
-
saveToVault &&
|
|
1430
|
+
saveToVault && res.vaultError
|
|
1352
1431
|
? t("lock.saveFailed", {
|
|
1353
1432
|
s: lock.vault.label || lock.vault.backend,
|
|
1354
|
-
err:
|
|
1433
|
+
err: res.vaultError,
|
|
1355
1434
|
})
|
|
1356
1435
|
: t("lock.session"),
|
|
1357
1436
|
);
|
|
1358
1437
|
return;
|
|
1359
1438
|
}
|
|
1360
|
-
|
|
1361
|
-
// is the server's own message.
|
|
1362
|
-
err.textContent =
|
|
1363
|
-
r.status === 401 || !data.error ? t("lock.wrong") : data.error;
|
|
1439
|
+
err.textContent = res.error;
|
|
1364
1440
|
err.hidden = false;
|
|
1365
1441
|
input.value = "";
|
|
1366
1442
|
input.focus();
|
|
1367
|
-
} catch {
|
|
1368
|
-
err.textContent = t("lock.reqFailed");
|
|
1369
|
-
err.hidden = false;
|
|
1370
1443
|
} finally {
|
|
1371
1444
|
if (btn.isConnected) {
|
|
1372
1445
|
btn.textContent = t("lock.go");
|
|
@@ -1376,6 +1449,23 @@ function openLock() {
|
|
|
1376
1449
|
};
|
|
1377
1450
|
draw();
|
|
1378
1451
|
document.body.appendChild(pop);
|
|
1452
|
+
const underModal = [...document.querySelectorAll("dialog")].some(
|
|
1453
|
+
(d) => d !== pop && d.open && d.matches(":modal"),
|
|
1454
|
+
);
|
|
1455
|
+
try {
|
|
1456
|
+
if (underModal) pop.showModal();
|
|
1457
|
+
else if (typeof pop.showPopover === "function") {
|
|
1458
|
+
pop.setAttribute("popover", "manual");
|
|
1459
|
+
pop.showPopover();
|
|
1460
|
+
} else pop.show();
|
|
1461
|
+
} catch {
|
|
1462
|
+
pop.setAttribute("open", "");
|
|
1463
|
+
}
|
|
1464
|
+
// A cancel (Escape on a modal) closes it as the chip would.
|
|
1465
|
+
pop.addEventListener("cancel", (e) => {
|
|
1466
|
+
e.preventDefault();
|
|
1467
|
+
closeOverlay(true);
|
|
1468
|
+
});
|
|
1379
1469
|
const r = chip.getBoundingClientRect();
|
|
1380
1470
|
pop.style.top = `${r.bottom + 6}px`;
|
|
1381
1471
|
pop.style.left = `${Math.max(8, Math.min(r.left, window.innerWidth - pop.offsetWidth - 8))}px`;
|
|
@@ -1441,6 +1531,15 @@ export function initTopbar(opts = {}) {
|
|
|
1441
1531
|
$("den-lock")?.addEventListener("click", () =>
|
|
1442
1532
|
overlay?.kind === "lock" ? closeOverlay(true) : openLock(),
|
|
1443
1533
|
);
|
|
1534
|
+
// LK-2: back on this tab, the lock may have been opened elsewhere.
|
|
1535
|
+
document.addEventListener("visibilitychange", () => {
|
|
1536
|
+
if (document.visibilityState !== "visible" || lock.unlocked) return;
|
|
1537
|
+
if (sh()?.apiOff?.() === true) return;
|
|
1538
|
+
void getJson("/api/sudo-password/status", null).then((st) => {
|
|
1539
|
+
if (st && (st.set || st.passwordless))
|
|
1540
|
+
setUnlocked(true, !!st.passwordless && !st.set);
|
|
1541
|
+
});
|
|
1542
|
+
});
|
|
1444
1543
|
document.addEventListener("keydown", (e) => {
|
|
1445
1544
|
if ((e.metaKey || e.ctrlKey) && !e.altKey && e.key.toLowerCase() === "k") {
|
|
1446
1545
|
e.preventDefault();
|