@monster-orange/den 0.4.3 → 0.4.5

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 (85) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/README.md +12 -11
  3. package/docs/distributions.md +438 -91
  4. package/docs/reference.md +8 -3
  5. package/package.json +1 -1
  6. package/src/shared/agents/catalog-runtime.ts +178 -0
  7. package/src/shared/agents/catalog.ts +343 -0
  8. package/src/shared/agents/den-claude.ts +14 -18
  9. package/src/shared/agents/runtime-search.ts +142 -15
  10. package/src/shared/agents/runtime.ts +18 -1
  11. package/src/shared/agents/runtimes.ts +23 -1
  12. package/src/shared/auth/sudo-vault.ts +131 -11
  13. package/src/shared/cli-command.ts +186 -0
  14. package/src/shared/confirm-code.ts +34 -0
  15. package/src/shared/den-config.ts +64 -3
  16. package/src/shared/dev-metrics/emit.ts +31 -1
  17. package/src/shared/edge/config.ts +39 -0
  18. package/src/shared/edge/render.ts +92 -18
  19. package/src/shared/edge/runtime.ts +57 -1
  20. package/src/shared/edge/status.ts +134 -0
  21. package/src/shared/edge/types.ts +11 -1
  22. package/src/shared/environment-manager/health.ts +82 -8
  23. package/src/shared/environment-manager/workspaces.ts +54 -0
  24. package/src/shared/github-app.ts +94 -0
  25. package/src/shared/github-device.ts +286 -0
  26. package/src/shared/health/not-checked.ts +42 -0
  27. package/src/shared/health/rollup.ts +4 -0
  28. package/src/shared/process/spawn-streaming.ts +8 -3
  29. package/src/shared/secrets/onepassword.ts +27 -4
  30. package/src/shared/sessions/client.ts +5 -1
  31. package/src/shared/sessions/host.ts +29 -1
  32. package/src/shared/sessions/protocol.ts +6 -1
  33. package/src/shared/sessions/types.ts +6 -0
  34. package/src/shared/stacks/status.ts +24 -0
  35. package/src/shared/strategies/registry.ts +5 -0
  36. package/src/shared/types/index.ts +6 -0
  37. package/src/shared/web-owner.ts +91 -0
  38. package/src/shared/web-session.ts +135 -1
  39. package/src/ui/cli/agent-ops.ts +210 -19
  40. package/src/ui/cli/agents-command.ts +50 -21
  41. package/src/ui/cli/edge-command.ts +16 -3
  42. package/src/ui/cli/flag-parser.ts +1 -1
  43. package/src/ui/cli/health-display.ts +4 -1
  44. package/src/ui/cli/web-command.ts +512 -33
  45. package/src/ui/mcp/scope.ts +23 -3
  46. package/src/ui/mcp/server.ts +31 -7
  47. package/src/ui/mcp/tools.ts +327 -20
  48. package/src/ui/mcp/views.ts +450 -0
  49. package/src/ui/server/agent-routes.ts +118 -28
  50. package/src/ui/server/device-flows.ts +274 -0
  51. package/src/ui/server/edge-exposure.ts +16 -4
  52. package/src/ui/server/github-routes.ts +321 -0
  53. package/src/ui/server/http-errors.ts +10 -0
  54. package/src/ui/server/index.ts +540 -33
  55. package/src/ui/server/login-waits.ts +203 -0
  56. package/src/ui/server/pair-requests.ts +237 -0
  57. package/src/ui/server/pair-routes.ts +196 -0
  58. package/src/ui/server/public/agent-start.js +46 -7
  59. package/src/ui/server/public/agents-page.js +232 -97
  60. package/src/ui/server/public/agents-section.js +3 -5
  61. package/src/ui/server/public/agents.html +12 -28
  62. package/src/ui/server/public/error-page.js +452 -40
  63. package/src/ui/server/public/error.html +59 -0
  64. package/src/ui/server/public/graph-model.js +2 -0
  65. package/src/ui/server/public/header.js +2 -3
  66. package/src/ui/server/public/health-rollup.js +20 -8
  67. package/src/ui/server/public/i18n/es.js +89 -21
  68. package/src/ui/server/public/i18n/ja.js +88 -21
  69. package/src/ui/server/public/index.html +17 -4
  70. package/src/ui/server/public/local-dev.html +44 -21
  71. package/src/ui/server/public/qr.js +317 -0
  72. package/src/ui/server/public/services.html +21 -5
  73. package/src/ui/server/public/settings-page.js +10 -2
  74. package/src/ui/server/public/settings-registry.js +617 -1
  75. package/src/ui/server/public/settings.html +90 -1
  76. package/src/ui/server/public/shell.js +151 -33
  77. package/src/ui/server/public/topbar.js +170 -206
  78. package/src/ui/server/public/workspace-picker.js +1 -1
  79. package/src/ui/server/readonly-routes.ts +29 -5
  80. package/src/ui/server/request-guard.ts +103 -36
  81. package/src/ui/server/route-classes.ts +116 -4
  82. package/src/ui/server/session-routes.ts +9 -1
  83. package/src/ui/server/sudo-store.ts +170 -6
  84. package/src/ui/server/ui-assets.ts +2 -0
  85. package/src/ui/server/workspace-routes.ts +10 -3
@@ -215,6 +215,13 @@
215
215
  font-weight: 600;
216
216
  background: color-mix(in srgb, var(--accent) 4%, var(--surface));
217
217
  }
218
+ /* The CLI default (focus): a word, never a bare glyph. */
219
+ .ws-cli-default {
220
+ font-size: 10px;
221
+ color: var(--accent-text);
222
+ margin-left: 4px;
223
+ cursor: help;
224
+ }
218
225
  .ws-item-right {
219
226
  display: flex;
220
227
  align-items: center;
@@ -1487,8 +1494,8 @@
1487
1494
  initChangelog,
1488
1495
  copyToClipboard,
1489
1496
  computePrimaryByRepo,
1490
- requireUnlock,
1491
1497
  setServiceRunner,
1498
+ sudoRun,
1492
1499
  takePendingAction,
1493
1500
  problemCards,
1494
1501
  problemFix,
@@ -1544,15 +1551,18 @@
1544
1551
  .replace(/</g, "&lt;")
1545
1552
  .replace(/>/g, "&gt;")
1546
1553
  .replace(/"/g, "&quot;");
1554
+ /** A shell string, with the English when the shell isn't up yet. */
1555
+ const wsT = (key, fallback) => {
1556
+ const v = window.denShell?.t?.(key);
1557
+ return v && v !== key ? v : fallback;
1558
+ };
1547
1559
 
1548
- // ── The lock (R8): the shell's lock chip and popover (topbar.js) ──────────
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
1552
- // field and the command doesn't run.
1553
- function requireSudo() {
1554
- return requireUnlock();
1555
- }
1560
+ // ── The admin password (R8, LK-0) ─────────────────────────────────────────
1561
+ // It is for sudo, and nothing else: no action here asks for it up
1562
+ // front. A run whose command is about to run sudo without it gets a
1563
+ // needs-sudo event from the server (sudoRun().see); the shell's
1564
+ // prompt then says what needs it, and after the password the action
1565
+ // runs again (sudoRun().done).
1556
1566
 
1557
1567
  // ── Bootstrap ─────────────────────────────────────────────────────────────
1558
1568
  // Systems with auto-start toggled OFF in the viewed workspace (`autostart:
@@ -1572,7 +1582,7 @@
1572
1582
  ]);
1573
1583
  initLogBadge();
1574
1584
  // Start and Restart from the status drawer and the ⌘K palette run
1575
- // here, with this page's lock rule and output (R15, R17).
1585
+ // here, with this page's output (R15, R17).
1576
1586
  setServiceRunner((cmd, s) =>
1577
1587
  window.runCmd(s.address, s.repo, s.service, cmd),
1578
1588
  );
@@ -1603,7 +1613,7 @@
1603
1613
  ? `<button class="danger" data-action="delete">Delete</button>`
1604
1614
  : "";
1605
1615
  return `<div class="ws-item ${n === activeWs ? "active" : ""}" data-name="${escHtml(n)}" data-action="select">
1606
- <span>${escHtml(n)}${isWsActive(n) ? ' <span style="font-size:10px;color:var(--muted)">(active)</span>' : ""}${n === focusName() ? ' <span style="font-size:10px;color:var(--accent-text)">●</span>' : ""}</span>
1616
+ <span>${escHtml(n)}${isWsActive(n) ? ' <span style="font-size:10px;color:var(--muted)">(active)</span>' : ""}${n === focusName() ? ` <span class="ws-cli-default" title="${escHtml(wsT("ws.focusTip", "CLI commands run here when you don't name a workspace. Picking a workspace in the top bar only changes what pages show."))}">${escHtml(wsT("ws.focus", "CLI default"))}</span>` : ""}</span>
1607
1617
  <span class="ws-item-right">
1608
1618
  <button class="ws-term-btn" title="Open in terminal" data-open-term="${escHtml(dirPath)}">&#62;_</button>
1609
1619
  <button class="ws-open-btn" title="Open in editor" data-open-path="${escHtml(wsData.codeWorkspacePaths?.[n] || dirPath)}">↗</button>
@@ -2023,11 +2033,11 @@
2023
2033
  <h2>${escHtml(wsName)}</h2>
2024
2034
  ${
2025
2035
  isActive
2026
- ? `<span class="badge active-ws">active${isFocus ? " · focus" : ""}</span>
2027
- ${!isFocus ? `<button class="switch-btn" data-action="focus-ws">set focus</button>` : ""}
2036
+ ? `<span class="badge active-ws"${isFocus ? ` title="${escHtml(wsT("ws.focusTip", ""))}"` : ""}>active${isFocus ? ` · ${escHtml(wsT("ws.focus", "CLI default"))}` : ""}</span>
2037
+ ${!isFocus ? `<button class="switch-btn" data-action="focus-ws" title="${escHtml(wsT("ws.setFocusTip", ""))}">Make CLI default</button>` : ""}
2028
2038
  <button class="switch-btn" data-action="deactivate-ws">deactivate</button>`
2029
2039
  : `<button class="switch-btn" data-action="activate-ws">activate</button>
2030
- <button class="switch-btn" data-action="switch-ws">activate + focus</button>`
2040
+ <button class="switch-btn" data-action="switch-ws">activate + make CLI default</button>`
2031
2041
  }
2032
2042
  ${stackNsChip}
2033
2043
  <span class="badge ws-agents" id="ws-agents-count" hidden></span>
@@ -3051,9 +3061,9 @@
3051
3061
  // ── Workspace-level command (SSE) ─────────────────────────────────────────
3052
3062
  // Routes output to each service's own tab as `label` events arrive.
3053
3063
  window.runWorkspaceSync = async function () {
3054
- if (!(await requireSudo())) return;
3055
3064
  setAllButtons(true);
3056
3065
  setWsStatus("syncing…");
3066
+ const sudo = sudoRun();
3057
3067
  const es = new EventSource("/api/workspace/sync");
3058
3068
  // Clear previous sync output then open/activate the tab (explicit action).
3059
3069
  clearDismissed("⟳ sync");
@@ -3062,6 +3072,7 @@
3062
3072
  setBusy("⟳ sync", true);
3063
3073
  es.onmessage = (e) => {
3064
3074
  const msg = JSON.parse(e.data);
3075
+ sudo.see(msg);
3065
3076
  if (msg.type === "line") appendToTab("⟳ sync", msg.text);
3066
3077
  if (msg.type === "error")
3067
3078
  appendToTab("⟳ sync", "Error: " + msg.text, "err");
@@ -3073,6 +3084,7 @@
3073
3084
  setTimeout(() => setWsStatus(""), 3000);
3074
3085
  // See refreshAfterSync — a sync that clones brings new commands with it.
3075
3086
  refreshAfterSync();
3087
+ void sudo.done(() => window.runWorkspaceSync());
3076
3088
  }
3077
3089
  };
3078
3090
  es.onerror = () => {
@@ -3103,9 +3115,9 @@
3103
3115
  // system. Buttons are not globally disabled: it is one repo.
3104
3116
  const _syncing = new Set();
3105
3117
  window.runRepoSync = async function (repo) {
3106
- if (!(await requireSudo())) return;
3107
3118
  if (_syncing.has(repo)) return; // already in flight — ignore re-entry
3108
3119
  _syncing.add(repo);
3120
+ const sudo = sudoRun();
3109
3121
 
3110
3122
  const route = tabRouteFor(repo);
3111
3123
  clearDismissed(route); // explicit action — undo any prior dismissal
@@ -3130,12 +3142,14 @@
3130
3142
  );
3131
3143
  es.onmessage = (e) => {
3132
3144
  const msg = JSON.parse(e.data);
3145
+ sudo.see(msg);
3133
3146
  if (msg.type === "line") appendToTab(route, msg.text);
3134
3147
  if (msg.type === "error")
3135
3148
  appendToTab(route, "Error: " + msg.text, "err");
3136
3149
  if (msg.type === "done") {
3137
3150
  es.close();
3138
3151
  finish("sync complete");
3152
+ void sudo.done(() => window.runRepoSync(repo));
3139
3153
  // A pull can replace this repo's health probes on disk. The health
3140
3154
  // response is cached ~8s, so force a fresh poll rather than leaving
3141
3155
  // dots that reflect the code we just replaced.
@@ -3156,7 +3170,7 @@
3156
3170
  };
3157
3171
 
3158
3172
  window.runWorkspaceCmd = async function (cmd, addresses) {
3159
- if (!(await requireSudo())) return;
3173
+ const sudo = sudoRun();
3160
3174
  const only =
3161
3175
  Array.isArray(addresses) && addresses.length
3162
3176
  ? `&addresses=${encodeURIComponent(addresses.join(","))}`
@@ -3174,6 +3188,7 @@
3174
3188
 
3175
3189
  esWs.onmessage = (e) => {
3176
3190
  const msg = JSON.parse(e.data);
3191
+ sudo.see(msg);
3177
3192
  if (msg.type === "label") {
3178
3193
  // New service section — route to the SYSTEM's primary tab (one tab
3179
3194
  // per system; sidecar output lands in its repo's tab with a header).
@@ -3239,6 +3254,7 @@
3239
3254
  idle();
3240
3255
  // Lifecycle changed run-state — sync dots now, don't wait for the poll.
3241
3256
  syncHealthNow();
3257
+ void sudo.done(() => window.runWorkspaceCmd(cmd, addresses));
3242
3258
  }
3243
3259
  };
3244
3260
  esWs.onerror = () => {
@@ -3270,7 +3286,6 @@
3270
3286
  // its rules are load-bearing, and the already-healthy skip it relies on is
3271
3287
  // suppressed whenever an explicit address list is sent.
3272
3288
  window.runUpWithDeps = async function (repo) {
3273
- if (!(await requireSudo())) return;
3274
3289
  void runWorkspaceCmdAsync("up", null, {
3275
3290
  seed: repo,
3276
3291
  undismiss: true,
@@ -3282,7 +3297,6 @@
3282
3297
  // same reason as its counterpart: the already-stopped skip is suppressed
3283
3298
  // when an explicit address list is sent.
3284
3299
  window.runDownWithDeps = async function (repo) {
3285
- if (!(await requireSudo())) return;
3286
3300
  void runWorkspaceCmdAsync("down", null, {
3287
3301
  seed: repo,
3288
3302
  undismiss: true,
@@ -3290,7 +3304,6 @@
3290
3304
  };
3291
3305
 
3292
3306
  window.runCmd = async function (address, repo, service, cmd) {
3293
- if (!(await requireSudo())) return;
3294
3307
  // Explicit single-service action — undo any prior dismissal so its pane returns.
3295
3308
  clearDismissed(address);
3296
3309
  // up/restart/down go through the workspace-exec runner: it uses the full
@@ -3319,9 +3332,12 @@
3319
3332
  setTabStatus(address, { es: esCmd });
3320
3333
  const _cmdTab = _tabs.get(address);
3321
3334
  if (_cmdTab) _cmdTab.esKind = "exec";
3335
+ const sudo = sudoRun();
3336
+ const again = () => window.runCmd(address, repo, service, cmd);
3322
3337
 
3323
3338
  esCmd.onmessage = (e) => {
3324
3339
  const msg = JSON.parse(e.data);
3340
+ sudo.see(msg);
3325
3341
  if (msg.type === "cmd") {
3326
3342
  appendToTab(address, msg.text, "cmd");
3327
3343
  } else if (msg.type === "started") {
@@ -3349,6 +3365,7 @@
3349
3365
  "done-fail",
3350
3366
  );
3351
3367
  setTabStatus(address, { live: false, errored: true, es: null });
3368
+ void sudo.done(again);
3352
3369
  }
3353
3370
  } else if (msg.type === "line") {
3354
3371
  appendToTab(address, msg.text, "");
@@ -3369,6 +3386,7 @@
3369
3386
  );
3370
3387
  _tabs.get(address) && (_tabs.get(address).busy = false);
3371
3388
  setTabStatus(address, { live: false, errored: !ok, es: null });
3389
+ void sudo.done(again);
3372
3390
  }
3373
3391
  };
3374
3392
  esCmd.onerror = () => {
@@ -3853,9 +3871,11 @@
3853
3871
  // address filter — the filter suppresses the already-healthy skip and
3854
3872
  // the auto-start-off handling that make "with deps" behave.
3855
3873
  if (opts.seed) params.set("seed", opts.seed);
3874
+ const sudo = sudoRun();
3856
3875
  const es = new EventSource(`/api/workspace/exec?${params}`);
3857
3876
  es.onmessage = (e) => {
3858
3877
  const msg = JSON.parse(e.data);
3878
+ sudo.see(msg);
3859
3879
  if (msg.type === "label") {
3860
3880
  currentAddress = msg.text;
3861
3881
  // Route to the system's primary tab; skip dismissed panes on
@@ -3924,7 +3944,10 @@
3924
3944
  idle();
3925
3945
  // Lifecycle changed run-state — sync dots now, don't wait for the poll.
3926
3946
  syncHealthNow();
3927
- resolve(ok);
3947
+ // LK-0: after the admin password, the same run again.
3948
+ void sudo
3949
+ .done(() => runWorkspaceCmdAsync(cmd, addresses, opts))
3950
+ .then((again) => resolve(again ?? ok));
3928
3951
  }
3929
3952
  };
3930
3953
  es.onerror = () => {
@@ -0,0 +1,317 @@
1
+ /**
2
+ * A tiny QR code encoder (SEC-2a, "Pair a device"): byte mode, error
3
+ * correction level M, versions 1 to 10 (up to 213 bytes, enough for a
4
+ * sign-in link). Written for Den from ISO/IEC 18004; no dependency, no CDN.
5
+ * Its matrices are checked against an independent encoder's in
6
+ * tests/unit/server/qr.test.ts.
7
+ *
8
+ * qrMatrix(text, { mask }?) rows of booleans (true: dark), no quiet zone
9
+ * qrSvg(text, { scale, margin }?) an SVG string, dark on light
10
+ */
11
+
12
+ // Level M, versions 1..10: ECC codewords per block, and blocks.
13
+ const ECC_PER_BLOCK = [0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26];
14
+ const ECC_BLOCKS = [0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5];
15
+ const MAX_VERSION = 10;
16
+ const FORMAT_BITS_M = 0;
17
+
18
+ function gfMul(x, y) {
19
+ let z = 0;
20
+ for (let i = 7; i >= 0; i--) {
21
+ z = (z << 1) ^ ((z >>> 7) * 0x11d);
22
+ z ^= ((y >>> i) & 1) * x;
23
+ }
24
+ return z & 0xff;
25
+ }
26
+
27
+ function rsDivisor(degree) {
28
+ const out = new Array(degree).fill(0);
29
+ out[degree - 1] = 1;
30
+ let root = 1;
31
+ for (let i = 0; i < degree; i++) {
32
+ for (let j = 0; j < out.length; j++) {
33
+ out[j] = gfMul(out[j], root);
34
+ if (j + 1 < out.length) out[j] ^= out[j + 1];
35
+ }
36
+ root = gfMul(root, 0x02);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function rsRemainder(data, divisor) {
42
+ const out = divisor.map(() => 0);
43
+ for (const b of data) {
44
+ const factor = b ^ out.shift();
45
+ out.push(0);
46
+ divisor.forEach((c, i) => (out[i] ^= gfMul(c, factor)));
47
+ }
48
+ return out;
49
+ }
50
+
51
+ function alignPositions(ver) {
52
+ if (ver === 1) return [];
53
+ const size = ver * 4 + 17;
54
+ const n = Math.floor(ver / 7) + 2;
55
+ const step = Math.floor((ver * 8 + n * 3 + 5) / (n * 4 - 4)) * 2;
56
+ const out = [6];
57
+ for (let pos = size - 7; out.length < n; pos -= step) out.splice(1, 0, pos);
58
+ return out;
59
+ }
60
+
61
+ function rawModules(ver) {
62
+ let r = (16 * ver + 128) * ver + 64;
63
+ if (ver >= 2) {
64
+ const n = Math.floor(ver / 7) + 2;
65
+ r -= (25 * n - 10) * n - 55;
66
+ if (ver >= 7) r -= 36;
67
+ }
68
+ return r;
69
+ }
70
+
71
+ function dataCodewords(ver) {
72
+ return Math.floor(rawModules(ver) / 8) - ECC_PER_BLOCK[ver] * ECC_BLOCKS[ver];
73
+ }
74
+
75
+ function utf8(text) {
76
+ return Array.from(new TextEncoder().encode(String(text)));
77
+ }
78
+
79
+ /** The data codewords for `bytes` at version `ver` (mode, count, pad). */
80
+ function encodeData(bytes, ver) {
81
+ const bits = [];
82
+ const put = (v, n) => {
83
+ for (let i = n - 1; i >= 0; i--) bits.push((v >>> i) & 1);
84
+ };
85
+ put(0b0100, 4);
86
+ put(bytes.length, ver <= 9 ? 8 : 16);
87
+ for (const b of bytes) put(b, 8);
88
+ const cap = dataCodewords(ver) * 8;
89
+ put(0, Math.min(4, cap - bits.length));
90
+ put(0, (8 - (bits.length % 8)) % 8);
91
+ for (let pad = 0xec; bits.length < cap; pad ^= 0xec ^ 0x11) put(pad, 8);
92
+ const out = [];
93
+ for (let i = 0; i < bits.length; i += 8)
94
+ out.push(bits.slice(i, i + 8).reduce((a, b) => (a << 1) | b, 0));
95
+ return out;
96
+ }
97
+
98
+ function withEcc(data, ver) {
99
+ const nBlocks = ECC_BLOCKS[ver];
100
+ const eccLen = ECC_PER_BLOCK[ver];
101
+ const raw = Math.floor(rawModules(ver) / 8);
102
+ const nShort = nBlocks - (raw % nBlocks);
103
+ const shortLen = Math.floor(raw / nBlocks);
104
+ const div = rsDivisor(eccLen);
105
+ const blocks = [];
106
+ for (let i = 0, k = 0; i < nBlocks; i++) {
107
+ const dat = data.slice(k, k + shortLen - eccLen + (i < nShort ? 0 : 1));
108
+ k += dat.length;
109
+ const ecc = rsRemainder(dat, div);
110
+ if (i < nShort) dat.push(0);
111
+ blocks.push(dat.concat(ecc));
112
+ }
113
+ const out = [];
114
+ for (let i = 0; i < blocks[0].length; i++)
115
+ blocks.forEach((b, j) => {
116
+ if (i !== shortLen - eccLen || j >= nShort) out.push(b[i]);
117
+ });
118
+ return out;
119
+ }
120
+
121
+ function makeGrid(ver) {
122
+ const size = ver * 4 + 17;
123
+ const mods = Array.from({ length: size }, () => new Array(size).fill(false));
124
+ const fn = Array.from({ length: size }, () => new Array(size).fill(false));
125
+ const set = (x, y, dark) => {
126
+ mods[y][x] = dark;
127
+ fn[y][x] = true;
128
+ };
129
+ for (let i = 0; i < size; i++) {
130
+ set(6, i, i % 2 === 0);
131
+ set(i, 6, i % 2 === 0);
132
+ }
133
+ for (const [cx, cy] of [
134
+ [3, 3],
135
+ [size - 4, 3],
136
+ [3, size - 4],
137
+ ])
138
+ for (let dy = -4; dy <= 4; dy++)
139
+ for (let dx = -4; dx <= 4; dx++) {
140
+ const d = Math.max(Math.abs(dx), Math.abs(dy));
141
+ const x = cx + dx;
142
+ const y = cy + dy;
143
+ if (x >= 0 && x < size && y >= 0 && y < size)
144
+ set(x, y, d !== 2 && d !== 4);
145
+ }
146
+ const al = alignPositions(ver);
147
+ const last = al.length - 1;
148
+ al.forEach((ax, i) =>
149
+ al.forEach((ay, j) => {
150
+ if ((i === 0 && j === 0) || (i === 0 && j === last)) return;
151
+ if (i === last && j === 0) return;
152
+ for (let dy = -2; dy <= 2; dy++)
153
+ for (let dx = -2; dx <= 2; dx++)
154
+ set(ax + dx, ay + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
155
+ }),
156
+ );
157
+ drawFormat(set, size, 0);
158
+ if (ver >= 7) {
159
+ let rem = ver;
160
+ for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25);
161
+ const bits = (ver << 12) | rem;
162
+ for (let i = 0; i < 18; i++) {
163
+ const dark = ((bits >>> i) & 1) === 1;
164
+ const a = size - 11 + (i % 3);
165
+ const b = Math.floor(i / 3);
166
+ set(a, b, dark);
167
+ set(b, a, dark);
168
+ }
169
+ }
170
+ return { size, mods, fn, set };
171
+ }
172
+
173
+ function drawFormat(set, size, mask) {
174
+ const data = (FORMAT_BITS_M << 3) | mask;
175
+ let rem = data;
176
+ for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
177
+ const bits = ((data << 10) | rem) ^ 0x5412;
178
+ const bit = (i) => ((bits >>> i) & 1) === 1;
179
+ for (let i = 0; i <= 5; i++) set(8, i, bit(i));
180
+ set(8, 7, bit(6));
181
+ set(8, 8, bit(7));
182
+ set(7, 8, bit(8));
183
+ for (let i = 9; i < 15; i++) set(14 - i, 8, bit(i));
184
+ for (let i = 0; i < 8; i++) set(size - 1 - i, 8, bit(i));
185
+ for (let i = 8; i < 15; i++) set(8, size - 15 + i, bit(i));
186
+ set(8, size - 8, true);
187
+ }
188
+
189
+ const MASKS = [
190
+ (x, y) => (x + y) % 2 === 0,
191
+ (x, y) => y % 2 === 0,
192
+ (x) => x % 3 === 0,
193
+ (x, y) => (x + y) % 3 === 0,
194
+ (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0,
195
+ (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0,
196
+ (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0,
197
+ (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0,
198
+ ];
199
+
200
+ function penalty(m) {
201
+ const n = m.length;
202
+ let score = 0;
203
+ const lines = [];
204
+ for (let i = 0; i < n; i++) {
205
+ lines.push(m[i]);
206
+ lines.push(m.map((row) => row[i]));
207
+ }
208
+ const finder = [true, false, true, true, true, false, true];
209
+ for (const line of lines) {
210
+ for (let i = 0; i < n; ) {
211
+ let j = i;
212
+ while (j < n && line[j] === line[i]) j++;
213
+ if (j - i >= 5) score += 3 + (j - i - 5);
214
+ i = j;
215
+ }
216
+ for (let i = 0; i + 7 <= n; i++) {
217
+ if (!finder.every((v, k) => line[i + k] === v)) continue;
218
+ const light = (a, b) => {
219
+ for (let k = a; k < b; k++)
220
+ if (k >= 0 && k < n && line[k]) return false;
221
+ return true;
222
+ };
223
+ if (light(i - 4, i) || light(i + 7, i + 11)) score += 40;
224
+ }
225
+ }
226
+ let dark = 0;
227
+ for (let y = 0; y < n; y++)
228
+ for (let x = 0; x < n; x++) {
229
+ if (m[y][x]) dark++;
230
+ if (
231
+ x + 1 < n &&
232
+ y + 1 < n &&
233
+ m[y][x] === m[y][x + 1] &&
234
+ m[y][x] === m[y + 1][x] &&
235
+ m[y][x] === m[y + 1][x + 1]
236
+ )
237
+ score += 3;
238
+ }
239
+ const total = n * n;
240
+ score += 10 * Math.floor(Math.abs(dark * 20 - total * 10) / total);
241
+ return score;
242
+ }
243
+
244
+ /**
245
+ * The QR code for `text` (UTF-8, byte mode, level M), as rows of booleans
246
+ * (true: dark), no quiet zone. `mask` 0..7 fixes the mask; by default the
247
+ * one with the lowest penalty. Throws when `text` is longer than version
248
+ * 10 holds (213 bytes).
249
+ */
250
+ export function qrMatrix(text, opts = {}) {
251
+ const bytes = utf8(text);
252
+ let ver = 1;
253
+ const need = (v) => 4 + (v <= 9 ? 8 : 16) + bytes.length * 8;
254
+ while (ver <= MAX_VERSION && need(ver) > dataCodewords(ver) * 8) ver++;
255
+ if (ver > MAX_VERSION)
256
+ throw new Error(`QR: ${bytes.length} bytes is too long (213 at most)`);
257
+ const code = withEcc(encodeData(bytes, ver), ver);
258
+ const { size, mods, fn } = makeGrid(ver);
259
+ let i = 0;
260
+ for (let right = size - 1; right >= 1; right -= 2) {
261
+ if (right === 6) right = 5;
262
+ for (let vert = 0; vert < size; vert++)
263
+ for (let j = 0; j < 2; j++) {
264
+ const x = right - j;
265
+ const up = ((right + 1) & 2) === 0;
266
+ const y = up ? size - 1 - vert : vert;
267
+ if (!fn[y][x] && i < code.length * 8) {
268
+ mods[y][x] = ((code[i >>> 3] >>> (7 - (i & 7))) & 1) === 1;
269
+ i++;
270
+ }
271
+ }
272
+ }
273
+ const masked = (mask) => {
274
+ const m = mods.map((row) => row.slice());
275
+ const put = (x, y, dark) => (m[y][x] = dark);
276
+ for (let y = 0; y < size; y++)
277
+ for (let x = 0; x < size; x++)
278
+ if (!fn[y][x] && MASKS[mask](x, y)) m[y][x] = !m[y][x];
279
+ drawFormat(put, size, mask);
280
+ return m;
281
+ };
282
+ if (Number.isInteger(opts.mask) && opts.mask >= 0 && opts.mask <= 7)
283
+ return masked(opts.mask);
284
+ let best = null;
285
+ let bestScore = Infinity;
286
+ for (let k = 0; k < 8; k++) {
287
+ const m = masked(k);
288
+ const s = penalty(m);
289
+ if (s < bestScore) {
290
+ best = m;
291
+ bestScore = s;
292
+ }
293
+ }
294
+ return best;
295
+ }
296
+
297
+ /** `text`'s QR code as an SVG (one path), with a quiet zone of `margin` modules. */
298
+ export function qrSvg(text, opts = {}) {
299
+ const m = qrMatrix(text);
300
+ const scale = opts.scale ?? 4;
301
+ const margin = opts.margin ?? 4;
302
+ const n = m.length + margin * 2;
303
+ let d = "";
304
+ m.forEach((row, y) =>
305
+ row.forEach((dark, x) => {
306
+ if (dark) d += `M${x + margin} ${y + margin}h1v1h-1z`;
307
+ }),
308
+ );
309
+ return (
310
+ `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n} ${n}" ` +
311
+ `width="${n * scale}" height="${n * scale}" shape-rendering="crispEdges" role="img">` +
312
+ // Black on white whatever the theme: a scanner needs that contrast
313
+ // (an inverted code fails on many phones), so no theme token here.
314
+ `<rect width="${n}" height="${n}" fill="white"/>` +
315
+ `<path d="${d}" fill="black"/></svg>`
316
+ );
317
+ }
@@ -333,6 +333,7 @@
333
333
  initChangelog,
334
334
  computePrimaryByRepo,
335
335
  envReady,
336
+ sudoRun,
336
337
  } from "/header.js";
337
338
  import { ownersLabel } from "/workspace-picker.js";
338
339
 
@@ -613,10 +614,16 @@
613
614
  const usableCmds = isSub
614
615
  ? s.commands.filter((cmd) => !LIFECYCLE_CMDS.has(cmd))
615
616
  : s.commands;
617
+ // The workspace the row was built from (the first in view that
618
+ // defines it, focus first): its command runs there, not in
619
+ // whichever workspace is focused.
620
+ const wsAttr = s.inWorkspaces?.[0]
621
+ ? ` data-workspace="${esc(s.inWorkspaces[0])}"`
622
+ : "";
616
623
  const cmdButtons = usableCmds
617
624
  .map(
618
625
  (cmd) =>
619
- `<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec">${cmd}</button>`,
626
+ `<button class="cmd-btn" data-repo="${s.repo}" data-service="${s.service}" data-cmd="${cmd}" data-type="exec"${wsAttr}>${cmd}</button>`,
620
627
  )
621
628
  .join("");
622
629
  const logsButton = s.logs
@@ -743,8 +750,9 @@
743
750
  // Attach click handlers
744
751
  pane.querySelectorAll(".cmd-btn").forEach((btn) => {
745
752
  btn.addEventListener("click", () => {
746
- const { repo, service, cmd, type } = btn.dataset;
747
- if (type === "exec") runExec(repo, service, cmd, btn);
753
+ const { repo, service, cmd, type, workspace } = btn.dataset;
754
+ if (type === "exec")
755
+ runExec(repo, service, cmd, btn, workspace);
748
756
  else runLogs(repo, service, btn);
749
757
  });
750
758
  });
@@ -765,7 +773,7 @@
765
773
  }
766
774
  }
767
775
 
768
- function runExec(repo, service, cmd, btn) {
776
+ function runExec(repo, service, cmd, btn, workspace) {
769
777
  endStream();
770
778
  const env = document.getElementById("env-sel").value;
771
779
 
@@ -781,8 +789,13 @@
781
789
  appendLine(`\n▶ ${repo}/${service} · ${cmd} · ${env}\n`, "cmd");
782
790
 
783
791
  const params = new URLSearchParams({ repo, service, cmd, env });
792
+ if (workspace) params.set("workspace", workspace);
784
793
  es = new EventSource(`/api/service/exec?${params}`);
785
- handleSSE(es, repo, service);
794
+ // LK-0: a command that needs sudo without the password asks,
795
+ // then runs again.
796
+ handleSSE(es, repo, service, {
797
+ again: () => runExec(repo, service, cmd, btn, workspace),
798
+ });
786
799
  }
787
800
 
788
801
  function runLogs(repo, service, btn) {
@@ -819,8 +832,10 @@
819
832
  const isStream = opts.stream === true;
820
833
  const id = runId;
821
834
  const appendLine = (text, cls) => runs().append(id, text, cls);
835
+ const sudo = sudoRun();
822
836
  evtSrc.onmessage = (e) => {
823
837
  const msg = JSON.parse(e.data);
838
+ sudo.see(msg);
824
839
  if (msg.type === "cmd") {
825
840
  appendLine(msg.text, "cmd");
826
841
  } else if (msg.type === "line") {
@@ -842,6 +857,7 @@
842
857
  } else runs().set(id, { running: false });
843
858
  evtSrc.close();
844
859
  es = null;
860
+ if (opts.again) void sudo.done(opts.again);
845
861
  }
846
862
  };
847
863
  evtSrc.onerror = () => {
@@ -293,6 +293,14 @@ export async function mountSettings(deps) {
293
293
  const v = typeof o === "function" ? o(ctx) : o;
294
294
  return Array.isArray(v) ? v : [];
295
295
  }
296
+ // A custom control draws itself (it may act through its own API rather
297
+ // than save a value: Settings › Access), so it renders whether or not the
298
+ // row has a write.
299
+ function drawsItself(r) {
300
+ return (
301
+ r.control?.type === "custom" && typeof r.control.render === "function"
302
+ );
303
+ }
296
304
  function drawRow(r) {
297
305
  const id = slug(r.id);
298
306
  const lid = `l-${id}`;
@@ -300,7 +308,7 @@ export async function mountSettings(deps) {
300
308
  const did = `d-${id}`;
301
309
  const v = value(r);
302
310
  const t = r.control?.type || "readonly";
303
- const ro = !r.write;
311
+ const ro = !r.write && !drawsItself(r);
304
312
  const single = ["number", "select", "toggle", "text"].includes(t);
305
313
  const label = single
306
314
  ? `<label class="l" id="${lid}" for="${cid}">${esc(r.label)}</label>`
@@ -499,7 +507,7 @@ export async function mountSettings(deps) {
499
507
  }, 2000);
500
508
  }
501
509
  function wireRow(r) {
502
- if (!r.write) return;
510
+ if (!r.write && !drawsItself(r)) return;
503
511
  const row = $(`setting-${slug(r.id)}`);
504
512
  if (!row) return;
505
513
  const t = r.control?.type;