sideby 0.16.0 → 0.17.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/README.md +5 -3
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +16 -0
- package/dist/src/cli/commands.js +460 -5
- package/dist/src/cli/json-schemas.js +115 -0
- package/dist/src/cli/main.js +93 -3
- package/dist/src/core/ask.d.ts +150 -0
- package/dist/src/core/ask.js +365 -0
- package/dist/src/core/attachments.d.ts +50 -0
- package/dist/src/core/attachments.js +442 -0
- package/dist/src/core/autostart.d.ts +88 -0
- package/dist/src/core/autostart.js +256 -0
- package/dist/src/core/config.d.ts +97 -0
- package/dist/src/core/config.js +278 -10
- package/dist/src/core/launch.d.ts +15 -4
- package/dist/src/core/launch.js +27 -23
- package/dist/src/core/local-build.d.ts +17 -0
- package/dist/src/core/local-build.js +39 -3
- package/dist/src/core/probe.d.ts +78 -0
- package/dist/src/core/probe.js +127 -0
- package/dist/src/core/same-login.d.ts +9 -4
- package/dist/src/core/same-login.js +9 -4
- package/dist/src/core/secret-file.d.ts +26 -0
- package/dist/src/core/secret-file.js +126 -7
- package/dist/src/core/terminal.d.ts +93 -0
- package/dist/src/core/terminal.js +371 -0
- package/dist/src/core/update.d.ts +17 -0
- package/dist/src/core/update.js +63 -1
- package/dist/src/families/claude/index.js +3 -0
- package/dist/src/families/claude/quota-cache.d.ts +24 -7
- package/dist/src/families/claude/quota-cache.js +72 -21
- package/dist/src/families/codex/index.js +7 -0
- package/dist/src/families/codex/login-key.d.ts +8 -0
- package/dist/src/families/codex/login-key.js +33 -0
- package/dist/src/families/grok/index.js +3 -0
- package/dist/src/families/login-keys.d.ts +6 -0
- package/dist/src/families/login-keys.js +6 -0
- package/dist/src/families/pi/index.js +4 -0
- package/dist/src/panel/background.js +3 -2
- package/dist/src/panel/handler.d.ts +29 -2
- package/dist/src/panel/handler.js +396 -9
- package/dist/src/panel/i18n.d.ts +175 -2
- package/dist/src/panel/i18n.js +381 -35
- package/dist/src/panel/page-logic.d.ts +214 -0
- package/dist/src/panel/page-logic.js +394 -0
- package/dist/src/panel/page-script.js +984 -134
- package/dist/src/panel/page-styles.js +69 -19
- package/dist/src/panel/page.d.ts +5 -0
- package/dist/src/panel/page.js +2 -0
- package/dist/src/panel/server.d.ts +16 -2
- package/dist/src/panel/server.js +41 -13
- package/dist/src/plugins/check.d.ts +47 -0
- package/dist/src/plugins/check.js +544 -0
- package/dist/src/plugins/scaffold.d.ts +21 -0
- package/dist/src/plugins/scaffold.js +123 -0
- package/dist/src/quota/statusline-tap.js +1 -1
- package/dist/src/runtime.d.ts +10 -0
- package/dist/src/runtime.js +47 -16
- package/dist/src/types.d.ts +19 -0
- package/package.json +2 -2
- package/schemas/ask.json +180 -0
- package/schemas/autostart.json +165 -0
- package/schemas/config-get.json +132 -0
- package/schemas/config-set.json +107 -0
- package/schemas/config.json +73 -6
- package/schemas/key.json +93 -0
- package/schemas/local.json +4 -0
- package/schemas/plugins-check.json +113 -0
- package/schemas/plugins-new.json +87 -0
- package/schemas/terminal.json +152 -0
- package/schemas/try.json +109 -0
- package/schemas/update.json +4 -0
- package/skills/sideby/SKILL.md +12 -6
- package/skills/sideby/references/accounts.md +1 -1
- package/skills/sideby/references/ai-tasks.md +116 -0
- package/skills/sideby/references/panel.md +14 -0
- package/skills/sideby/references/update.md +1 -1
|
@@ -197,7 +197,8 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
|
|
|
197
197
|
.next-why { margin: 0 0 10px; max-width: 280px; line-height: 1.5; }
|
|
198
198
|
.next-cmd { display: block; margin-top: 8px; color: var(--muted); font-size: 11px; }
|
|
199
199
|
/* A Same Login (spec §3.21, ADR-0026): one row; opened, a short line per directory. */
|
|
200
|
-
.
|
|
200
|
+
.login-mark { display: inline-flex; flex: none; color: var(--muted); cursor: help; }
|
|
201
|
+
.login-mark .ic { width: 13px; height: 13px; }
|
|
201
202
|
.login-detail { flex-direction: column; gap: 8px; }
|
|
202
203
|
.login-detail .rd-note { margin: 0; }
|
|
203
204
|
/* The directories share one set of columns (subgrid), so a wider cell in one line moves every line alike. */
|
|
@@ -220,9 +221,9 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
|
|
|
220
221
|
.arrange-tools { display: inline-flex; align-items: center; gap: 2px; }
|
|
221
222
|
.arrange-tools .icon-btn { width: 28px; height: 28px; }
|
|
222
223
|
.arrange-tools .icon-btn .ic { width: 14px; height: 14px; }
|
|
223
|
-
|
|
224
|
-
.pin-btn
|
|
225
|
-
.pin-btn[aria-
|
|
224
|
+
/* One width for "Pin to top" and "Unpin", so the arrows line up down the list. */
|
|
225
|
+
.arrange-tools .pin-btn { min-width: 96px; margin-left: 6px; justify-content: center; }
|
|
226
|
+
.pin-btn[aria-disabled="true"] { color: var(--faint); cursor: not-allowed; }
|
|
226
227
|
.drag-handle { width: 20px; height: 28px; color: var(--muted); cursor: grab; }
|
|
227
228
|
.drag-handle:hover { color: var(--accent); }
|
|
228
229
|
.drag-handle .ic { width: 14px; height: 14px; }
|
|
@@ -251,9 +252,12 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
|
|
|
251
252
|
.col-head > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
|
|
252
253
|
.col-head .quota > span { overflow: hidden; text-overflow: ellipsis; }
|
|
253
254
|
.ident { grid-area: id; display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
254
|
-
.ident-text { min-width: 0; flex: 1; }
|
|
255
|
+
.ident-text { min-width: 0; flex: 1; position: relative; padding-left: 18px; }
|
|
256
|
+
/* The pin sits in the text column's own gutter, so the name and the email below it start at the same place. */
|
|
257
|
+
.ident-text .pin-slot, .ident-text .pin-mark { position: absolute; left: 0; top: 2px; margin: 0; }
|
|
255
258
|
.ident-name { display: flex; align-items: center; gap: 6px; min-width: 0; flex-wrap: wrap; row-gap: 3px; }
|
|
256
259
|
.ident-name .name { font-weight: var(--strong-weight); font-size: var(--font-size); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
|
260
|
+
.ident-name .alias { color: var(--muted); font-family: var(--mono); font-size: 12px; white-space: nowrap; }
|
|
257
261
|
.ident-email { color: var(--muted); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
|
258
262
|
.email-btn, .model-btn { all: unset; cursor: pointer; border-radius: 4px; }
|
|
259
263
|
.model-btn:hover { color: var(--text); text-decoration: underline dotted; }
|
|
@@ -263,11 +267,12 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
|
|
|
263
267
|
.br-list { list-style: none; margin: 0 0 12px; padding: 0; display: flex; flex-direction: column; gap: 6px; }
|
|
264
268
|
.br-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px; border: 1px solid var(--border); border-radius: var(--ctl-radius); }
|
|
265
269
|
.email-btn:hover { color: var(--text); text-decoration: underline dotted; }
|
|
270
|
+
/* A button is laid out as one box, so the parent's ellipsis does not reach its text: it ends its own. */
|
|
271
|
+
.ident-email .email-btn { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
|
|
266
272
|
.email-btn:focus-visible { outline: var(--focus-outline); outline-offset: 2px; }
|
|
267
|
-
.
|
|
268
|
-
.
|
|
269
|
-
.
|
|
270
|
-
.br-btn { display: inline-flex; align-items: center; gap: 3px; vertical-align: bottom; }
|
|
273
|
+
.br-btn { display: inline-flex; align-items: center; gap: 4px; color: var(--muted); font-size: 12px; max-width: 100%; }
|
|
274
|
+
.br-btn:hover { color: var(--text); text-decoration: underline dotted; }
|
|
275
|
+
.card > .rd-browser { flex-direction: row; align-items: center; gap: 8px; }
|
|
271
276
|
.br-ico { width: 12px; height: 12px; flex: none; }
|
|
272
277
|
.ident-email + .ident-sub { color: var(--faint); margin-top: 0; }
|
|
273
278
|
.tools .tb-icon { width: var(--ctl-h); height: var(--ctl-h); border-color: var(--border-strong); background: var(--surface); }
|
|
@@ -282,19 +287,21 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
|
|
|
282
287
|
.row > .quota { grid-area: quota; }
|
|
283
288
|
.row > .last { grid-area: last; }
|
|
284
289
|
.row > .row-end { grid-area: more; display: flex; align-items: center; justify-content: flex-end; gap: 4px; }
|
|
285
|
-
/* The row's pin (spec §3.25): filled and always there on a pinned row; on the others it shows on hover or focus. */
|
|
286
290
|
.row-run { width: 28px; height: 28px; color: var(--muted); }
|
|
287
291
|
.row-run .ic { width: 15px; height: 15px; }
|
|
288
292
|
.row-run:hover { color: var(--accent); }
|
|
289
|
-
.row-pin { width: 28px; height: 28px; color: var(--faint); opacity: 0; transition: opacity .12s, color .12s; }
|
|
290
|
-
.row-pin .ic { width: 15px; height: 15px; }
|
|
291
|
-
.row:hover .row-pin, .card:hover .row-pin, .row-pin:focus-visible, .row-pin.is-on { opacity: 1; }
|
|
292
293
|
.card-head > .row-end { display: flex; align-items: center; gap: 2px; margin-left: auto; }
|
|
293
|
-
|
|
294
|
-
.
|
|
295
|
-
.
|
|
296
|
-
|
|
297
|
-
|
|
294
|
+
/* A pinned row's mark (spec §3.25, §3.27): a small muted outline pin right before the name, not a button. */
|
|
295
|
+
.pin-mark { display: inline-flex; flex: none; margin-right: -2px; color: var(--faint); }
|
|
296
|
+
.pin-mark .ic { width: 12px; height: 12px; }
|
|
297
|
+
/* The pin before the name (spec §3.27): always there when pinned; a fainter one on hover or focus in the row
|
|
298
|
+
otherwise. The slot keeps its width either way, so the name never moves. */
|
|
299
|
+
.pin-slot { display: inline-grid; place-items: center; flex: none; width: 16px; height: 16px; margin: 0 -4px 0 -2px; padding: 0; border: 0; border-radius: 4px; background: none; color: var(--faint); cursor: pointer; opacity: 0; }
|
|
300
|
+
.pin-slot .ic { width: 12px; height: 12px; }
|
|
301
|
+
.pin-slot.is-on { opacity: 1; }
|
|
302
|
+
.row:hover .pin-slot:not(.is-on), .row:focus-within .pin-slot:not(.is-on), .card:hover .pin-slot:not(.is-on), .card:focus-within .pin-slot:not(.is-on) { opacity: .5; }
|
|
303
|
+
.pin-slot:hover, .pin-slot:focus-visible { opacity: 1 !important; color: var(--accent); background: var(--hover); outline: none; }
|
|
304
|
+
.ctx-anchor { position: fixed; width: 0; height: 0; pointer-events: none; }
|
|
298
305
|
.row-notes { grid-area: notes; display: flex; flex-direction: column; gap: 6px; margin-top: 8px; }
|
|
299
306
|
|
|
300
307
|
/* Quota meters: percent and countdown over a bar colored by level (ok, warn at 60%, fail at 85% used). */
|
|
@@ -521,6 +528,31 @@ input[type=text]:hover:not(:disabled), input[type=number]:hover:not(:disabled),
|
|
|
521
528
|
input[type=text]:focus, input[type=search]:focus, select:focus { border-color: var(--focus); }
|
|
522
529
|
input[type=text]:disabled, select:disabled { background-color: var(--surface-2); color: var(--faint); cursor: not-allowed; }
|
|
523
530
|
input.invalid { border-color: var(--fail); }
|
|
531
|
+
/* AI Tasks (spec §3.28): the text box, the key field, the example chips and the account and terminal pickers. */
|
|
532
|
+
input[type=password] { height: var(--field-h); border-radius: var(--ctl-radius); border: 1px solid var(--border-strong); background-color: var(--surface); color: var(--text); font: inherit; padding: 0 12px; width: 100%; }
|
|
533
|
+
textarea.ask-text { min-height: 64px; resize: vertical; border-radius: var(--ctl-radius); border: 1px solid var(--border-strong); background-color: var(--surface); color: var(--text); font: inherit; padding: 8px 12px; width: 100%; line-height: 1.45; }
|
|
534
|
+
input[type=password]:focus, textarea.ask-text:focus { border-color: var(--focus); }
|
|
535
|
+
textarea.ask-text:focus-visible, input[type=password]:focus-visible { outline: var(--focus-outline); outline-offset: 2px; box-shadow: var(--focus-ring); }
|
|
536
|
+
textarea::placeholder { color: var(--faint); }
|
|
537
|
+
.ask-chips { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
538
|
+
.ask-chips .chip { font: inherit; font-size: var(--small); text-align: left; white-space: normal; }
|
|
539
|
+
.ask-attach-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 10px; }
|
|
540
|
+
.ask-files-wrap { display: grid; gap: 6px; }
|
|
541
|
+
.ask-files-wrap:empty { display: none; }
|
|
542
|
+
.ask-files { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
|
|
543
|
+
.ask-file { position: relative; display: grid; gap: 2px; width: 104px; padding: 4px; border: 1px solid var(--border); border-radius: var(--ctl-radius); background: var(--surface); }
|
|
544
|
+
.ask-file.is-error { border-color: var(--fail); }
|
|
545
|
+
.ask-file-img, .ask-file-face { width: 94px; height: 64px; border-radius: calc(var(--ctl-radius) - 2px); }
|
|
546
|
+
.ask-file-img { object-fit: cover; display: block; }
|
|
547
|
+
.ask-file-face { display: grid; place-items: center; align-content: center; gap: 2px; background: var(--surface-2); color: var(--muted); }
|
|
548
|
+
.ask-file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
549
|
+
.ask-file-x { position: absolute; top: 2px; right: 2px; width: 22px; height: 22px; background: var(--surface); }
|
|
550
|
+
.dialog.drop-on { outline: 2px dashed var(--focus); outline-offset: -6px; }
|
|
551
|
+
.ask-pick {display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
|
|
552
|
+
.ask-setup .link-btn { display: inline-flex; align-items: center; gap: 6px; }
|
|
553
|
+
.ask-setup .ic { width: 14px; height: 14px; }
|
|
554
|
+
.fam-other { background: transparent; color: inherit; font: inherit; text-align: left; border-style: dashed; }
|
|
555
|
+
.q-ask { margin-left: 6px; }
|
|
524
556
|
/* The select draws its own chevron with two gradient strokes (the CSP allows no external images), so it follows the
|
|
525
557
|
text color in both schemes and on hover. */
|
|
526
558
|
select { --chevron: var(--muted); appearance: none; padding-right: 32px; cursor: pointer; text-overflow: ellipsis;
|
|
@@ -581,7 +613,7 @@ select:is(:hover, :focus) { --chevron: var(--text); }
|
|
|
581
613
|
.dlg-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 18px; border-top: 1px solid var(--border); background: var(--surface-2); border-radius: 0 0 var(--radius) var(--radius); }
|
|
582
614
|
|
|
583
615
|
/* Menu, tooltips, toasts */
|
|
584
|
-
.menu { position: fixed; z-index: 40; min-width: 230px; max-width: min(380px, calc(100vw - 16px)); padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--ctl-radius) + 2px); box-shadow: var(--float-shadow); }
|
|
616
|
+
.menu { position: fixed; z-index: 40; min-width: 230px; max-width: min(380px, calc(100vw - 16px)); padding: 4px; background: var(--surface); border: 1px solid var(--border); border-radius: calc(var(--ctl-radius) + 2px); box-shadow: var(--float-shadow); overflow-y: auto; overscroll-behavior: contain; }
|
|
585
617
|
.menu-item { display: flex; align-items: center; gap: 10px; width: 100%; height: 32px; padding: 0 10px; border: 0; border-radius: calc(var(--ctl-radius) - 2px); background: none; color: var(--text); font: inherit; font-size: var(--control-size); text-align: left; cursor: pointer; }
|
|
586
618
|
.menu-item .ic { flex: none; color: var(--muted); }
|
|
587
619
|
.menu-item:hover:not(:disabled), .menu-item:focus-visible { background: var(--hover); outline: none; }
|
|
@@ -604,6 +636,24 @@ select:is(:hover, :focus) { --chevron: var(--text); }
|
|
|
604
636
|
.set-hint { color: var(--faint); font-size: 12px; max-width: 260px; }
|
|
605
637
|
.set-link { margin: 0 -8px; width: calc(100% + 16px); }
|
|
606
638
|
.set-link .chip { margin-left: auto; }
|
|
639
|
+
/* Groups in the settings (spec §3.29): a small muted title, then its rows; a line between groups. */
|
|
640
|
+
.pop-settings { width: 340px; }
|
|
641
|
+
.set-group { display: flex; flex-direction: column; gap: 8px; }
|
|
642
|
+
.set-check .set-label { white-space: normal; }
|
|
643
|
+
.set-link .menu-label { flex: 1 1 auto; min-width: 0; white-space: normal; line-height: 1.3; }
|
|
644
|
+
.set-link { height: auto; min-height: 32px; padding-top: 6px; padding-bottom: 6px; }
|
|
645
|
+
.set-link .chip { flex: none; }
|
|
646
|
+
.set-group-title { color: var(--faint); font-size: 11px; font-weight: var(--strong-weight); letter-spacing: .02em; }
|
|
647
|
+
.popover .set-sep { margin: 0 -14px; }
|
|
648
|
+
.set-hint .link-btn { font-size: 12px; }
|
|
649
|
+
/* "More settings…" stays at the bottom of a popover that scrolls. */
|
|
650
|
+
.set-more { position: sticky; bottom: -12px; margin: 0 -14px -12px; width: calc(100% + 28px); height: 40px; padding: 0 16px 0 17px; border-top: 1px solid var(--border); border-radius: 0; background: var(--surface); }
|
|
651
|
+
.set-more:hover { background: var(--hover); }
|
|
652
|
+
.set-more .ic { margin-left: auto; }
|
|
653
|
+
.set-dialog { display: flex; flex-direction: column; gap: 12px; }
|
|
654
|
+
.set-dialog .set-hint { max-width: none; }
|
|
655
|
+
.set-dialog .set-link { margin: 0 -10px; width: calc(100% + 20px); }
|
|
656
|
+
.as-code { display: block; margin: -4px 0 4px; padding: 8px 10px; border-radius: var(--ctl-radius); background: var(--hover); font-family: var(--mono); font-size: 12px; overflow-wrap: anywhere; }
|
|
607
657
|
.set-dot { background: var(--warn-bar); }
|
|
608
658
|
.tip { display: inline-flex; position: relative; vertical-align: middle; }
|
|
609
659
|
.tip-btn { display: inline-grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 1px solid var(--border-strong); border-radius: 50%; background: var(--surface); color: var(--muted); font: 700 10px/1 var(--font); cursor: help; }
|
package/dist/src/panel/page.d.ts
CHANGED
|
@@ -21,6 +21,11 @@ export interface PageOptions {
|
|
|
21
21
|
lang?: 'auto' | 'en' | 'zh';
|
|
22
22
|
/** createPanelHandler's `updates`: false hides everything about sideby updates. */
|
|
23
23
|
updates?: boolean;
|
|
24
|
+
/** Config `panel` (spec §3.29): the theme and language saved for the Panel. */
|
|
25
|
+
panel?: {
|
|
26
|
+
theme?: string;
|
|
27
|
+
language?: string;
|
|
28
|
+
};
|
|
24
29
|
}
|
|
25
30
|
/** JSON that is safe inside a `<script>` element: no `</script>`, `<!--` or line separators survive. */
|
|
26
31
|
export declare function jsonForScript(value: unknown): string;
|
package/dist/src/panel/page.js
CHANGED
|
@@ -42,6 +42,7 @@ export function renderPage(opts) {
|
|
|
42
42
|
updates: opts.updates !== false,
|
|
43
43
|
colorScheme: theme.colorScheme,
|
|
44
44
|
lang,
|
|
45
|
+
panel: opts.panel ?? {},
|
|
45
46
|
warn: QUOTA_WARN_PERCENT,
|
|
46
47
|
fail: QUOTA_FAIL_PERCENT,
|
|
47
48
|
usageDays: USAGE_DAYS,
|
|
@@ -74,6 +75,7 @@ export function renderPage(opts) {
|
|
|
74
75
|
</div>
|
|
75
76
|
<div class="top-actions">
|
|
76
77
|
<button id="update" class="btn update-btn" type="button" hidden></button>
|
|
78
|
+
<button id="ask-ai" class="btn" type="button" aria-haspopup="dialog" aria-label="Let AI do it"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 9h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM12 5v4M9.5 13.5v1M14.5 13.5v1"/></svg><span class="hide-sm" data-i18n="ask.button">Let AI do it</span></button>
|
|
77
79
|
<button id="settings" class="btn" type="button" aria-haspopup="dialog" aria-expanded="false"${lang === 'auto' ? '' : ' hidden'}><span data-i18n="set.button">Settings</span></button>
|
|
78
80
|
<button id="refresh" class="btn" type="button" aria-label="Refresh"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 11a8 8 0 1 0-2.3 5.7"/><path d="M20 4v7h-7"/></svg><span class="hide-sm" data-i18n="top.refresh">Refresh</span></button>
|
|
79
81
|
</div>
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { Runtime } from '../runtime.ts';
|
|
2
2
|
export declare const DEFAULT_PANEL_PORT = 17420;
|
|
3
|
+
/**
|
|
4
|
+
* Whose Panel this is: a hash of the system user and HOME, so `sideby ui` reuses only its own user's Panel. Another
|
|
5
|
+
* user of the same Mac may run one on the same port; reusing it would show that user's accounts and start none of
|
|
6
|
+
* ours. A hash, so the health answer names no path. It only tells Panels apart; it is not authentication. The real
|
|
7
|
+
* path, so one HOME reached through a symlink gives one owner.
|
|
8
|
+
*/
|
|
9
|
+
export declare function panelOwner(home?: string): string;
|
|
3
10
|
export interface PanelServerOptions {
|
|
4
11
|
runtimeFactory: () => Promise<Runtime>;
|
|
5
12
|
port?: number;
|
|
@@ -11,6 +18,8 @@ export interface PanelServerOptions {
|
|
|
11
18
|
* --stop` can tell this Panel from a reused pid or from another Panel before it sends a signal.
|
|
12
19
|
*/
|
|
13
20
|
instance?: string;
|
|
21
|
+
/** panelOwner() of the HOME this Panel serves; defaults to this process's HOME. */
|
|
22
|
+
owner?: string;
|
|
14
23
|
}
|
|
15
24
|
export interface PanelServer {
|
|
16
25
|
url: string;
|
|
@@ -22,11 +31,16 @@ export interface PanelIdentity {
|
|
|
22
31
|
pid?: number;
|
|
23
32
|
/** buildId() of the code serving it; absent from Panels older than this field. */
|
|
24
33
|
build?: string;
|
|
34
|
+
/** panelOwner() of the user it serves; absent from Panels older than this field. */
|
|
35
|
+
owner?: string;
|
|
25
36
|
}
|
|
26
37
|
/** What the sideby Panel on this port says about itself, or undefined when none answers. */
|
|
27
38
|
export declare function panelIdentity(port: number): Promise<PanelIdentity | undefined>;
|
|
28
|
-
/**
|
|
29
|
-
|
|
39
|
+
/**
|
|
40
|
+
* True when a sideby Panel of this user answers on this port. One that names another owner belongs to another user
|
|
41
|
+
* and counts as a busy port; one too old to name an owner is taken as ours, as before.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isSidebyPanel(port: number, owner?: string): Promise<boolean>;
|
|
30
44
|
/** Opens `url` in the default browser; returns false (and never throws) when that is not possible. */
|
|
31
45
|
export declare function openBrowser(url: string): Promise<boolean>;
|
|
32
46
|
export declare function startPanelServer(opts: PanelServerOptions): Promise<PanelServer>;
|
package/dist/src/panel/server.js
CHANGED
|
@@ -1,11 +1,34 @@
|
|
|
1
1
|
// Standalone Panel server for `sideby ui`: loopback only, reuses a running Panel, shifts past busy ports.
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { realpathSync } from 'node:fs';
|
|
3
5
|
import { createServer } from 'node:http';
|
|
4
|
-
import {
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { resolve } from 'node:path';
|
|
8
|
+
import { buildId, packageVersion } from '../core/version.js';
|
|
5
9
|
import { createPanelHandler } from './handler.js';
|
|
6
10
|
export const DEFAULT_PANEL_PORT = 17420;
|
|
7
11
|
const PORT_ATTEMPTS = 20;
|
|
8
12
|
const LOOPBACK = '127.0.0.1';
|
|
13
|
+
/**
|
|
14
|
+
* Whose Panel this is: a hash of the system user and HOME, so `sideby ui` reuses only its own user's Panel. Another
|
|
15
|
+
* user of the same Mac may run one on the same port; reusing it would show that user's accounts and start none of
|
|
16
|
+
* ours. A hash, so the health answer names no path. It only tells Panels apart; it is not authentication. The real
|
|
17
|
+
* path, so one HOME reached through a symlink gives one owner.
|
|
18
|
+
*/
|
|
19
|
+
export function panelOwner(home = process.env.HOME || homedir()) {
|
|
20
|
+
let dir = resolve(home);
|
|
21
|
+
try {
|
|
22
|
+
dir = realpathSync(dir);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// keep the resolved path when HOME does not exist
|
|
26
|
+
}
|
|
27
|
+
return createHash('sha256')
|
|
28
|
+
.update(`sideby-panel\0${process.getuid?.() ?? ''}\0${dir}`)
|
|
29
|
+
.digest('hex')
|
|
30
|
+
.slice(0, 16);
|
|
31
|
+
}
|
|
9
32
|
function listen(server, port) {
|
|
10
33
|
return new Promise((resolve, reject) => {
|
|
11
34
|
const onError = (err) => {
|
|
@@ -37,15 +60,20 @@ export async function panelIdentity(port) {
|
|
|
37
60
|
...(typeof body.instance === 'string' ? { instance: body.instance } : {}),
|
|
38
61
|
...(Number.isInteger(body.pid) ? { pid: body.pid } : {}),
|
|
39
62
|
...(typeof body.build === 'string' ? { build: body.build } : {}),
|
|
63
|
+
...(typeof body.owner === 'string' ? { owner: body.owner } : {}),
|
|
40
64
|
};
|
|
41
65
|
}
|
|
42
66
|
catch {
|
|
43
67
|
return undefined;
|
|
44
68
|
}
|
|
45
69
|
}
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
70
|
+
/**
|
|
71
|
+
* True when a sideby Panel of this user answers on this port. One that names another owner belongs to another user
|
|
72
|
+
* and counts as a busy port; one too old to name an owner is taken as ours, as before.
|
|
73
|
+
*/
|
|
74
|
+
export async function isSidebyPanel(port, owner = panelOwner()) {
|
|
75
|
+
const id = await panelIdentity(port);
|
|
76
|
+
return id !== undefined && (id.owner === undefined || id.owner === owner);
|
|
49
77
|
}
|
|
50
78
|
/** Opens `url` in the default browser; returns false (and never throws) when that is not possible. */
|
|
51
79
|
export function openBrowser(url) {
|
|
@@ -69,6 +97,9 @@ export function openBrowser(url) {
|
|
|
69
97
|
export async function startPanelServer(opts) {
|
|
70
98
|
const log = opts.log ?? ((s) => console.log(s));
|
|
71
99
|
const first = opts.port ?? DEFAULT_PANEL_PORT;
|
|
100
|
+
const owner = opts.owner ?? panelOwner();
|
|
101
|
+
// /api/health always names app and version (ADR-0006), also when the caller did not pass one.
|
|
102
|
+
const version = opts.version ?? packageVersion();
|
|
72
103
|
const announce = async (url, reused) => {
|
|
73
104
|
log(reused ? `sideby panel is already running at ${url}` : `sideby panel: ${url}`);
|
|
74
105
|
if (opts.open && !(await openBrowser(url)))
|
|
@@ -79,20 +110,17 @@ export async function startPanelServer(opts) {
|
|
|
79
110
|
const handler = createPanelHandler({
|
|
80
111
|
runtime: opts.runtimeFactory,
|
|
81
112
|
allowedHosts: hosts,
|
|
82
|
-
|
|
113
|
+
version,
|
|
83
114
|
});
|
|
84
115
|
const server = createServer((req, res) => {
|
|
85
|
-
if (
|
|
86
|
-
req.method === 'GET' &&
|
|
87
|
-
req.url === '/api/health' &&
|
|
88
|
-
hosts.includes(req.headers.host ?? '')) {
|
|
116
|
+
if (req.method === 'GET' && req.url === '/api/health' && hosts.includes(req.headers.host ?? '')) {
|
|
89
117
|
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
90
118
|
res.end(JSON.stringify({
|
|
91
119
|
app: 'sideby',
|
|
92
|
-
|
|
120
|
+
version,
|
|
93
121
|
build: buildId(),
|
|
94
|
-
|
|
95
|
-
pid: process.pid,
|
|
122
|
+
owner,
|
|
123
|
+
...(opts.instance ? { instance: opts.instance, pid: process.pid } : {}),
|
|
96
124
|
}));
|
|
97
125
|
return;
|
|
98
126
|
}
|
|
@@ -124,7 +152,7 @@ export async function startPanelServer(opts) {
|
|
|
124
152
|
}),
|
|
125
153
|
};
|
|
126
154
|
}
|
|
127
|
-
if (await isSidebyPanel(port)) {
|
|
155
|
+
if (await isSidebyPanel(port, owner)) {
|
|
128
156
|
await announce(url, true);
|
|
129
157
|
return { url, reused: true, close: async () => { } };
|
|
130
158
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Plugin } from '../types.ts';
|
|
2
|
+
export interface PluginProblem {
|
|
3
|
+
/** Stable machine code such as `shared-item.parent`. */
|
|
4
|
+
code: string;
|
|
5
|
+
message: string;
|
|
6
|
+
/** One sentence saying what to change. */
|
|
7
|
+
fix: string;
|
|
8
|
+
}
|
|
9
|
+
export interface PluginCheckResult {
|
|
10
|
+
ok: boolean;
|
|
11
|
+
/** The plugin's name, when its manifest or export could be read. */
|
|
12
|
+
plugin?: string;
|
|
13
|
+
/** Family ids the plugin registered. */
|
|
14
|
+
families: string[];
|
|
15
|
+
problems: PluginProblem[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Checks the Family Plugin in `dir` (the folder holding `plugin.json`): manifest, trust (owner, modes), that it
|
|
19
|
+
* only `import type`s from sideby, and a leak scan for home paths, personal emails and token-shaped strings. When the
|
|
20
|
+
* loader would trust it, also its FamilyDef fields and a discover / launch / doctor round, run by {@link checkRound} in
|
|
21
|
+
* a child process with a throwaway HOME, killed after `timeoutMs` (20 s). An untrusted plugin is never copied,
|
|
22
|
+
* changed or run. Never throws for a broken plugin: every problem is in `problems`, each with a `fix`.
|
|
23
|
+
*/
|
|
24
|
+
export declare function checkPlugin(opts: {
|
|
25
|
+
dir: string;
|
|
26
|
+
timeoutMs?: number;
|
|
27
|
+
}): Promise<PluginCheckResult>;
|
|
28
|
+
/**
|
|
29
|
+
* The child side of {@link runRound}: reads the job, runs {@link checkRound} and writes its report to fd 3, then
|
|
30
|
+
* exits so a timer or handle the plugin left open cannot keep it alive.
|
|
31
|
+
*/
|
|
32
|
+
export declare function checkRoundMain(arg: string | undefined): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* `checkPlugin` for a Plugin that ships inside sideby: `dir` is its source folder, scanned for leaks. The same field
|
|
35
|
+
* checks and discover / launch / doctor round run; it loads as the only built-in.
|
|
36
|
+
*/
|
|
37
|
+
export declare function checkBuiltinPlugin(opts: {
|
|
38
|
+
plugin: Plugin;
|
|
39
|
+
dir: string;
|
|
40
|
+
}): Promise<PluginCheckResult>;
|
|
41
|
+
/** What is missing or wrong in one FamilyDef, as written by the Plugin. */
|
|
42
|
+
export declare function defProblems(raw: unknown): PluginProblem[];
|
|
43
|
+
/**
|
|
44
|
+
* The plugin's sources against the rules that need no loading: value imports from sideby, TODOs left from the
|
|
45
|
+
* skeleton, and the leak scan. Messages give the file and line only, never the matched value.
|
|
46
|
+
*/
|
|
47
|
+
export declare function scanSources(dir: string): Promise<PluginProblem[]>;
|