sideby 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +111 -2
- package/README.zh-CN.md +112 -0
- package/dist/build.json +1 -0
- package/dist/src/cli/args.d.ts +13 -0
- package/dist/src/cli/args.js +38 -0
- package/dist/src/cli/commands.d.ts +50 -0
- package/dist/src/cli/commands.js +566 -0
- package/dist/src/cli/format.d.ts +16 -0
- package/dist/src/cli/format.js +80 -0
- package/dist/src/cli/json-schemas.d.ts +2 -0
- package/dist/src/cli/json-schemas.js +259 -0
- package/dist/src/cli/main.d.ts +2 -0
- package/dist/src/cli/main.js +276 -0
- package/dist/src/cli/schemas.d.ts +1 -0
- package/dist/src/cli/schemas.js +16 -0
- package/dist/src/core/account-name.d.ts +2 -0
- package/dist/src/core/account-name.js +2 -0
- package/dist/src/core/accounts.d.ts +20 -0
- package/dist/src/core/accounts.js +131 -0
- package/dist/src/core/config.d.ts +61 -0
- package/dist/src/core/config.js +184 -0
- package/dist/src/core/create.d.ts +30 -0
- package/dist/src/core/create.js +126 -0
- package/dist/src/core/doctor.d.ts +41 -0
- package/dist/src/core/doctor.js +171 -0
- package/dist/src/core/errors.d.ts +12 -0
- package/dist/src/core/errors.js +14 -0
- package/dist/src/core/file-memo.d.ts +24 -0
- package/dist/src/core/file-memo.js +41 -0
- package/dist/src/core/fs-safe.d.ts +38 -0
- package/dist/src/core/fs-safe.js +205 -0
- package/dist/src/core/handoff.d.ts +41 -0
- package/dist/src/core/handoff.js +54 -0
- package/dist/src/core/identity.d.ts +23 -0
- package/dist/src/core/identity.js +79 -0
- package/dist/src/core/last-doctor.d.ts +36 -0
- package/dist/src/core/last-doctor.js +69 -0
- package/dist/src/core/launch.d.ts +37 -0
- package/dist/src/core/launch.js +108 -0
- package/dist/src/core/login-path.d.ts +23 -0
- package/dist/src/core/login-path.js +96 -0
- package/dist/src/core/paths.d.ts +13 -0
- package/dist/src/core/paths.js +32 -0
- package/dist/src/core/quota-levels.d.ts +19 -0
- package/dist/src/core/quota-levels.js +34 -0
- package/dist/src/core/resume.d.ts +9 -0
- package/dist/src/core/resume.js +15 -0
- package/dist/src/core/secret-file.d.ts +30 -0
- package/dist/src/core/secret-file.js +116 -0
- package/dist/src/core/share-meaning.d.ts +6 -0
- package/dist/src/core/share-meaning.js +32 -0
- package/dist/src/core/share-mode-names.d.ts +1 -0
- package/dist/src/core/share-mode-names.js +10 -0
- package/dist/src/core/share-modes.d.ts +24 -0
- package/dist/src/core/share-modes.js +405 -0
- package/dist/src/core/shell-init.d.ts +31 -0
- package/dist/src/core/shell-init.js +84 -0
- package/dist/src/core/skill-check.d.ts +11 -0
- package/dist/src/core/skill-check.js +57 -0
- package/dist/src/core/usage-days.d.ts +15 -0
- package/dist/src/core/usage-days.js +33 -0
- package/dist/src/core/version.d.ts +9 -0
- package/dist/src/core/version.js +46 -0
- package/dist/src/desktop/app.d.ts +60 -0
- package/dist/src/desktop/app.js +316 -0
- package/dist/src/desktop/icon.d.ts +16 -0
- package/dist/src/desktop/icon.js +174 -0
- package/dist/src/families/claude/index.d.ts +10 -0
- package/dist/src/families/claude/index.js +127 -0
- package/dist/src/families/claude/layout.d.ts +6 -0
- package/dist/src/families/claude/layout.js +5 -0
- package/dist/src/families/claude/quota-cache.d.ts +31 -0
- package/dist/src/families/claude/quota-cache.js +111 -0
- package/dist/src/families/claude/quota.d.ts +3 -0
- package/dist/src/families/claude/quota.js +53 -0
- package/dist/src/families/claude/usage.d.ts +22 -0
- package/dist/src/families/claude/usage.js +146 -0
- package/dist/src/families/codex/index.d.ts +5 -0
- package/dist/src/families/codex/index.js +70 -0
- package/dist/src/families/codex/rollout.d.ts +67 -0
- package/dist/src/families/codex/rollout.js +298 -0
- package/dist/src/families/grok/index.d.ts +8 -0
- package/dist/src/families/grok/index.js +82 -0
- package/dist/src/families/index.d.ts +3 -0
- package/dist/src/families/index.js +40 -0
- package/dist/src/families/logos.d.ts +21 -0
- package/dist/src/families/logos.js +26 -0
- package/dist/src/families/pi/index.d.ts +3 -0
- package/dist/src/families/pi/index.js +54 -0
- package/dist/src/families/shared/sessions.d.ts +9 -0
- package/dist/src/families/shared/sessions.js +70 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +16 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +4 -0
- package/dist/src/panel/background.d.ts +62 -0
- package/dist/src/panel/background.js +274 -0
- package/dist/src/panel/handler.d.ts +91 -0
- package/dist/src/panel/handler.js +396 -0
- package/dist/src/panel/i18n.d.ts +287 -0
- package/dist/src/panel/i18n.js +570 -0
- package/dist/src/panel/page-logic.d.ts +154 -0
- package/dist/src/panel/page-logic.js +326 -0
- package/dist/src/panel/page-script.d.ts +2 -0
- package/dist/src/panel/page-script.js +1633 -0
- package/dist/src/panel/page-styles.d.ts +7 -0
- package/dist/src/panel/page-styles.js +514 -0
- package/dist/src/panel/page.d.ts +20 -0
- package/dist/src/panel/page.js +96 -0
- package/dist/src/panel/server.d.ts +32 -0
- package/dist/src/panel/server.js +133 -0
- package/dist/src/panel/theme.d.ts +116 -0
- package/dist/src/panel/theme.js +132 -0
- package/dist/src/plugins/account-script.d.ts +4 -0
- package/dist/src/plugins/account-script.js +66 -0
- package/dist/src/plugins/bus.d.ts +33 -0
- package/dist/src/plugins/bus.js +84 -0
- package/dist/src/plugins/loader.d.ts +58 -0
- package/dist/src/plugins/loader.js +250 -0
- package/dist/src/quota/claude-statusline.d.ts +28 -0
- package/dist/src/quota/claude-statusline.js +301 -0
- package/dist/src/quota/statusline-tap.d.ts +11 -0
- package/dist/src/quota/statusline-tap.js +104 -0
- package/dist/src/runtime.d.ts +195 -0
- package/dist/src/runtime.js +424 -0
- package/dist/src/types.d.ts +285 -0
- package/dist/src/types.js +3 -0
- package/dist/testing/demo.d.ts +8 -0
- package/dist/testing/demo.js +50 -0
- package/dist/testing/index.d.ts +36 -0
- package/dist/testing/index.js +118 -0
- package/package.json +66 -4
- package/schemas/alias.json +102 -0
- package/schemas/app-install.json +74 -0
- package/schemas/app-uninstall.json +54 -0
- package/schemas/config.json +170 -0
- package/schemas/doctor.json +229 -0
- package/schemas/error.json +25 -0
- package/schemas/families.json +205 -0
- package/schemas/list.json +177 -0
- package/schemas/new.json +199 -0
- package/schemas/next.json +127 -0
- package/schemas/plugins.json +74 -0
- package/schemas/quota-setup.json +60 -0
- package/schemas/quota-teardown.json +30 -0
- package/schemas/quota.json +203 -0
- package/skills/sideby/SKILL.md +61 -0
- package/skills/sideby/references/accounts.md +31 -0
- package/skills/sideby/references/diagnose.md +47 -0
- package/skills/sideby/references/handoff.md +41 -0
- package/skills/sideby/references/quota.md +14 -0
- package/skills/sideby/references/setup.md +31 -0
- package/skills/sideby/references/sharing.md +17 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The Panel page: one self-contained HTML document with inline CSS and JS, no external resources. The parts live in
|
|
2
|
+
// sibling modules: page-styles.ts (tokens and CSS), page-script.ts (behaviour, with page-logic.ts helpers) and
|
|
3
|
+
// i18n.ts (English and Chinese text); this module assembles them under the CSP nonce.
|
|
4
|
+
import { ALIAS_NAME, RESERVED_ALIASES } from '../core/config.js';
|
|
5
|
+
import { QUOTA_FAIL_PERCENT, QUOTA_WARN_PERCENT, USAGE_DAYS } from '../core/quota-levels.js';
|
|
6
|
+
import { MESSAGES } from './i18n.js';
|
|
7
|
+
import { PAGE_SCRIPT } from './page-script.js';
|
|
8
|
+
import { pageStyles } from './page-styles.js';
|
|
9
|
+
import { resolveTheme } from './theme.js';
|
|
10
|
+
export { pageStyles };
|
|
11
|
+
/** JSON that is safe inside a `<script>` element: no `</script>`, `<!--` or line separators survive. */
|
|
12
|
+
export function jsonForScript(value) {
|
|
13
|
+
return JSON.stringify(value)
|
|
14
|
+
.replace(/</g, '\\u003c')
|
|
15
|
+
.replace(/>/g, '\\u003e')
|
|
16
|
+
.replace(/&/g, '\\u0026')
|
|
17
|
+
.replace(/\u2028/g, '\\u2028')
|
|
18
|
+
.replace(/\u2029/g, '\\u2029');
|
|
19
|
+
}
|
|
20
|
+
function attr(value) {
|
|
21
|
+
return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<');
|
|
22
|
+
}
|
|
23
|
+
const SKELETON_ROW = `<div class="sk-row"><span class="sk sk-badge"></span><span class="sk sk-title"></span><span class="sk sk-bar"></span><span class="sk sk-bar"></span></div>`;
|
|
24
|
+
const SKELETON_STAT = `<div class="stat"><span class="sk sk-stat w70"></span></div>`;
|
|
25
|
+
export function renderPage(opts) {
|
|
26
|
+
const nonce = opts.nonce ? ` nonce="${attr(opts.nonce)}"` : '';
|
|
27
|
+
const theme = opts.theme ?? resolveTheme(undefined);
|
|
28
|
+
const scheme = theme.colorScheme === 'auto' ? 'light dark' : theme.colorScheme;
|
|
29
|
+
const lang = opts.lang ?? 'auto';
|
|
30
|
+
const boot = jsonForScript({
|
|
31
|
+
basePath: opts.basePath,
|
|
32
|
+
token: opts.token,
|
|
33
|
+
version: opts.version ?? '',
|
|
34
|
+
build: opts.build ?? '',
|
|
35
|
+
readOnly: Boolean(opts.readOnly),
|
|
36
|
+
colorScheme: theme.colorScheme,
|
|
37
|
+
lang,
|
|
38
|
+
warn: QUOTA_WARN_PERCENT,
|
|
39
|
+
fail: QUOTA_FAIL_PERCENT,
|
|
40
|
+
usageDays: USAGE_DAYS,
|
|
41
|
+
// The alias rules of the config, so the new-account dialog checks a short command as sideby will.
|
|
42
|
+
aliasPattern: ALIAS_NAME.source,
|
|
43
|
+
reservedAliases: [...RESERVED_ALIASES],
|
|
44
|
+
});
|
|
45
|
+
return `<!doctype html>
|
|
46
|
+
<html lang="${lang === 'zh' ? 'zh-CN' : 'en'}">
|
|
47
|
+
<head>
|
|
48
|
+
<meta charset="utf-8">
|
|
49
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
50
|
+
<meta name="color-scheme" content="${scheme}">
|
|
51
|
+
<meta name="referrer" content="no-referrer">
|
|
52
|
+
<title>sideby</title>
|
|
53
|
+
<link rel="icon" href="data:,">
|
|
54
|
+
<style${nonce}>${pageStyles(theme)}</style>
|
|
55
|
+
</head>
|
|
56
|
+
<body${theme.header === 'bar' ? ' class="header-bar"' : ''}>
|
|
57
|
+
<header class="top">
|
|
58
|
+
<div class="top-in">
|
|
59
|
+
<div class="brand">
|
|
60
|
+
<span class="logo" aria-hidden="true"><svg viewBox="0 0 40 40"><path d="M18.5 8A10.5 10.5 0 0 0 18.5 29Z"/><path d="M21.5 11A10.5 10.5 0 0 1 21.5 32Z" fill-opacity=".55"/></svg></span>
|
|
61
|
+
<div>
|
|
62
|
+
<h1>sideby</h1>
|
|
63
|
+
<p class="tagline" data-i18n="top.tagline">Every AI coding account, side by side.</p>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="top-actions">
|
|
67
|
+
<span id="updated" class="muted small">Loading…</span>
|
|
68
|
+
<button id="lang" class="icon-btn lang-btn" type="button"${lang === 'auto' ? '' : ' hidden'}>${lang === 'zh' ? 'EN' : '中文'}</button>
|
|
69
|
+
<button id="theme" class="icon-btn" type="button" hidden></button>
|
|
70
|
+
<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>
|
|
71
|
+
</div>
|
|
72
|
+
</div>
|
|
73
|
+
</header>
|
|
74
|
+
<main class="wrap">
|
|
75
|
+
<div id="banners"></div>
|
|
76
|
+
<section id="summary" class="summary" aria-busy="true">${SKELETON_STAT.repeat(4)}</section>
|
|
77
|
+
<section class="block" aria-labelledby="h-accounts">
|
|
78
|
+
<div class="section-head acct-head"><h2 id="h-accounts"><span data-i18n="acct.title">Accounts</span><span id="acct-count" class="muted small"></span></h2></div>
|
|
79
|
+
<div id="toolbar" class="toolbar" hidden></div>
|
|
80
|
+
<div id="accounts" aria-busy="true"><div class="group group-list">${SKELETON_ROW.repeat(4)}</div></div>
|
|
81
|
+
</section>
|
|
82
|
+
<section id="checkup" class="panel checkup" aria-labelledby="h-checkup">
|
|
83
|
+
<div class="section-head"><h2 id="h-checkup" data-i18n="health.title">Health check</h2></div>
|
|
84
|
+
<span class="sk sk-line w70"></span><span class="sk sk-line w50"></span>
|
|
85
|
+
</section>
|
|
86
|
+
<footer class="foot muted small">sideby <span id="ver"></span> · <span data-i18n="foot.text">runs on this machine; nothing leaves it.</span></footer>
|
|
87
|
+
</main>
|
|
88
|
+
<div id="conn" class="conn" role="status" hidden></div>
|
|
89
|
+
<div id="toasts" class="toasts" aria-live="polite" popover="manual"></div>
|
|
90
|
+
<script type="application/json" id="sideby-boot">${boot}</script>
|
|
91
|
+
<script type="application/json" id="sideby-i18n">${jsonForScript(MESSAGES)}</script>
|
|
92
|
+
<script${nonce}>${PAGE_SCRIPT}</script>
|
|
93
|
+
</body>
|
|
94
|
+
</html>
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Runtime } from '../runtime.ts';
|
|
2
|
+
export declare const DEFAULT_PANEL_PORT = 17420;
|
|
3
|
+
export interface PanelServerOptions {
|
|
4
|
+
runtimeFactory: () => Promise<Runtime>;
|
|
5
|
+
port?: number;
|
|
6
|
+
open?: boolean;
|
|
7
|
+
log?: (s: string) => void;
|
|
8
|
+
version?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Random id of a detached Panel. `/api/health` then also returns it with the server's pid, so `sideby ui
|
|
11
|
+
* --stop` can tell this Panel from a reused pid or from another Panel before it sends a signal.
|
|
12
|
+
*/
|
|
13
|
+
instance?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface PanelServer {
|
|
16
|
+
url: string;
|
|
17
|
+
reused: boolean;
|
|
18
|
+
close(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
export interface PanelIdentity {
|
|
21
|
+
instance?: string;
|
|
22
|
+
pid?: number;
|
|
23
|
+
/** buildId() of the code serving it; absent from Panels older than this field. */
|
|
24
|
+
build?: string;
|
|
25
|
+
}
|
|
26
|
+
/** What the sideby Panel on this port says about itself, or undefined when none answers. */
|
|
27
|
+
export declare function panelIdentity(port: number): Promise<PanelIdentity | undefined>;
|
|
28
|
+
/** True when a sideby Panel answers on this port. */
|
|
29
|
+
export declare function isSidebyPanel(port: number): Promise<boolean>;
|
|
30
|
+
/** Opens `url` in the default browser; returns false (and never throws) when that is not possible. */
|
|
31
|
+
export declare function openBrowser(url: string): Promise<boolean>;
|
|
32
|
+
export declare function startPanelServer(opts: PanelServerOptions): Promise<PanelServer>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Standalone Panel server for `sideby ui`: loopback only, reuses a running Panel, shifts past busy ports.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { buildId } from '../core/version.js';
|
|
5
|
+
import { createPanelHandler } from './handler.js';
|
|
6
|
+
export const DEFAULT_PANEL_PORT = 17420;
|
|
7
|
+
const PORT_ATTEMPTS = 20;
|
|
8
|
+
const LOOPBACK = '127.0.0.1';
|
|
9
|
+
function listen(server, port) {
|
|
10
|
+
return new Promise((resolve, reject) => {
|
|
11
|
+
const onError = (err) => {
|
|
12
|
+
server.off('listening', onListening);
|
|
13
|
+
if (err.code === 'EADDRINUSE')
|
|
14
|
+
resolve('busy');
|
|
15
|
+
else
|
|
16
|
+
reject(err);
|
|
17
|
+
};
|
|
18
|
+
const onListening = () => {
|
|
19
|
+
server.off('error', onError);
|
|
20
|
+
resolve('ok');
|
|
21
|
+
};
|
|
22
|
+
server.once('error', onError);
|
|
23
|
+
server.once('listening', onListening);
|
|
24
|
+
server.listen(port, LOOPBACK);
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** What the sideby Panel on this port says about itself, or undefined when none answers. */
|
|
28
|
+
export async function panelIdentity(port) {
|
|
29
|
+
try {
|
|
30
|
+
const res = await fetch(`http://${LOOPBACK}:${port}/api/health`, { signal: AbortSignal.timeout(1500) });
|
|
31
|
+
if (!res.ok)
|
|
32
|
+
return undefined;
|
|
33
|
+
const body = (await res.json());
|
|
34
|
+
if (body?.app !== 'sideby')
|
|
35
|
+
return undefined;
|
|
36
|
+
return {
|
|
37
|
+
...(typeof body.instance === 'string' ? { instance: body.instance } : {}),
|
|
38
|
+
...(Number.isInteger(body.pid) ? { pid: body.pid } : {}),
|
|
39
|
+
...(typeof body.build === 'string' ? { build: body.build } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** True when a sideby Panel answers on this port. */
|
|
47
|
+
export async function isSidebyPanel(port) {
|
|
48
|
+
return (await panelIdentity(port)) !== undefined;
|
|
49
|
+
}
|
|
50
|
+
/** Opens `url` in the default browser; returns false (and never throws) when that is not possible. */
|
|
51
|
+
export function openBrowser(url) {
|
|
52
|
+
const [cmd, args] = process.platform === 'darwin'
|
|
53
|
+
? ['open', [url]]
|
|
54
|
+
: process.platform === 'win32'
|
|
55
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
56
|
+
: ['xdg-open', [url]];
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
try {
|
|
59
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
60
|
+
child.once('error', () => resolve(false));
|
|
61
|
+
child.once('exit', (code) => resolve(code === 0));
|
|
62
|
+
child.unref();
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
resolve(false);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export async function startPanelServer(opts) {
|
|
70
|
+
const log = opts.log ?? ((s) => console.log(s));
|
|
71
|
+
const first = opts.port ?? DEFAULT_PANEL_PORT;
|
|
72
|
+
const announce = async (url, reused) => {
|
|
73
|
+
log(reused ? `sideby panel is already running at ${url}` : `sideby panel: ${url}`);
|
|
74
|
+
if (opts.open && !(await openBrowser(url)))
|
|
75
|
+
log(`open ${url} in your browser`);
|
|
76
|
+
};
|
|
77
|
+
for (let port = first; port < first + PORT_ATTEMPTS && port <= 65535; port++) {
|
|
78
|
+
const hosts = [`${LOOPBACK}:${port}`, `localhost:${port}`];
|
|
79
|
+
const handler = createPanelHandler({
|
|
80
|
+
runtime: opts.runtimeFactory,
|
|
81
|
+
allowedHosts: hosts,
|
|
82
|
+
...(opts.version ? { version: opts.version } : {}),
|
|
83
|
+
});
|
|
84
|
+
const server = createServer((req, res) => {
|
|
85
|
+
if (opts.instance &&
|
|
86
|
+
req.method === 'GET' &&
|
|
87
|
+
req.url === '/api/health' &&
|
|
88
|
+
hosts.includes(req.headers.host ?? '')) {
|
|
89
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
90
|
+
res.end(JSON.stringify({
|
|
91
|
+
app: 'sideby',
|
|
92
|
+
...(opts.version ? { version: opts.version } : {}),
|
|
93
|
+
build: buildId(),
|
|
94
|
+
instance: opts.instance,
|
|
95
|
+
pid: process.pid,
|
|
96
|
+
}));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
handler(req, res)
|
|
100
|
+
.then((handled) => {
|
|
101
|
+
if (!handled) {
|
|
102
|
+
res.writeHead(404, { 'content-type': 'text/plain; charset=utf-8' });
|
|
103
|
+
res.end('not found');
|
|
104
|
+
}
|
|
105
|
+
})
|
|
106
|
+
.catch(() => {
|
|
107
|
+
if (!res.headersSent)
|
|
108
|
+
res.writeHead(500);
|
|
109
|
+
res.end();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
const result = await listen(server, port);
|
|
113
|
+
const url = `http://${LOOPBACK}:${port}/`;
|
|
114
|
+
if (result === 'ok') {
|
|
115
|
+
if (port !== first)
|
|
116
|
+
log(`port ${first} is busy; using ${port}`);
|
|
117
|
+
await announce(url, false);
|
|
118
|
+
return {
|
|
119
|
+
url,
|
|
120
|
+
reused: false,
|
|
121
|
+
close: () => new Promise((resolve, reject) => {
|
|
122
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
123
|
+
server.closeAllConnections();
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
if (await isSidebyPanel(port)) {
|
|
128
|
+
await announce(url, true);
|
|
129
|
+
return { url, reused: true, close: async () => { } };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
throw new Error(`ports ${first}-${first + PORT_ATTEMPTS - 1} are all busy; pick another with \`sideby ui --port <n>\``);
|
|
133
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** Values for the Panel's design tokens. Every value is one CSS value, such as `#3f8fc4`, `14px` or a font stack. */
|
|
2
|
+
export interface PanelThemeTokens {
|
|
3
|
+
/** Font stack for all text but code. */
|
|
4
|
+
font?: string;
|
|
5
|
+
monoFont?: string;
|
|
6
|
+
/** Body text, 14px by default. */
|
|
7
|
+
fontSize?: string;
|
|
8
|
+
/** Meta text, hints and small labels, 12.5px. */
|
|
9
|
+
smallSize?: string;
|
|
10
|
+
/** Button text, 13.5px. */
|
|
11
|
+
controlSize?: string;
|
|
12
|
+
/** Section headings, 15px. */
|
|
13
|
+
headingSize?: string;
|
|
14
|
+
/** Account names on cards, 16px. */
|
|
15
|
+
largeSize?: string;
|
|
16
|
+
/** Page title, 22px. */
|
|
17
|
+
titleSize?: string;
|
|
18
|
+
titleWeight?: string;
|
|
19
|
+
/** Section headings and account names. */
|
|
20
|
+
headingWeight?: string;
|
|
21
|
+
/** Labels, emphasis and numbers. */
|
|
22
|
+
strongWeight?: string;
|
|
23
|
+
buttonWeight?: string;
|
|
24
|
+
/** Main action button; defaults to `buttonWeight`. */
|
|
25
|
+
primaryWeight?: string;
|
|
26
|
+
/** Page background. */
|
|
27
|
+
bg?: string;
|
|
28
|
+
/** Cards, panels, fields and buttons. */
|
|
29
|
+
surface?: string;
|
|
30
|
+
/** Quiet areas inside a card: notes, logo tiles, chips. */
|
|
31
|
+
surfaceMuted?: string;
|
|
32
|
+
border?: string;
|
|
33
|
+
/** Borders of fields and buttons. */
|
|
34
|
+
borderStrong?: string;
|
|
35
|
+
text?: string;
|
|
36
|
+
muted?: string;
|
|
37
|
+
faint?: string;
|
|
38
|
+
/** Background of a hovered or pressed secondary button. */
|
|
39
|
+
hover?: string;
|
|
40
|
+
active?: string;
|
|
41
|
+
/** Links and the Subscription chip. */
|
|
42
|
+
accent?: string;
|
|
43
|
+
accentSoft?: string;
|
|
44
|
+
/** Focus outline and focused field border. */
|
|
45
|
+
focus?: string;
|
|
46
|
+
/** Main action button. */
|
|
47
|
+
primary?: string;
|
|
48
|
+
primaryHover?: string;
|
|
49
|
+
primaryActive?: string;
|
|
50
|
+
onPrimary?: string;
|
|
51
|
+
primaryBorder?: string;
|
|
52
|
+
primaryHoverBorder?: string;
|
|
53
|
+
/** Secondary button text, and its border and text on hover. */
|
|
54
|
+
buttonText?: string;
|
|
55
|
+
buttonHoverBorder?: string;
|
|
56
|
+
buttonHoverText?: string;
|
|
57
|
+
/** Checkbox color. */
|
|
58
|
+
checkColor?: string;
|
|
59
|
+
ok?: string;
|
|
60
|
+
okSoft?: string;
|
|
61
|
+
warn?: string;
|
|
62
|
+
warnSoft?: string;
|
|
63
|
+
fail?: string;
|
|
64
|
+
failSoft?: string;
|
|
65
|
+
/** The sideby mark in the page header. */
|
|
66
|
+
logoBg?: string;
|
|
67
|
+
logoColor?: string;
|
|
68
|
+
logoBorder?: string;
|
|
69
|
+
shadow?: string;
|
|
70
|
+
/** `outline` of focused controls (`none` to rely on `focusRing`). */
|
|
71
|
+
focusOutline?: string;
|
|
72
|
+
/** `box-shadow` of focused controls, such as a soft halo. */
|
|
73
|
+
focusRing?: string;
|
|
74
|
+
/** Cards and panels; banners and notes derive smaller radii from it. */
|
|
75
|
+
radius?: string;
|
|
76
|
+
/** Buttons and fields. */
|
|
77
|
+
controlRadius?: string;
|
|
78
|
+
chipRadius?: string;
|
|
79
|
+
/** Button height; `controlHeightSmall` for buttons inside cards and banners. */
|
|
80
|
+
controlHeight?: string;
|
|
81
|
+
controlHeightSmall?: string;
|
|
82
|
+
/** Text field and select height. */
|
|
83
|
+
fieldHeight?: string;
|
|
84
|
+
/** Maximum width of the page content (`none` for full width). */
|
|
85
|
+
contentWidth?: string;
|
|
86
|
+
/** Left and right page padding. */
|
|
87
|
+
gutter?: string;
|
|
88
|
+
}
|
|
89
|
+
export interface PanelTheme {
|
|
90
|
+
/** `auto` (default) follows the system; `light` or `dark` fixes one scheme. */
|
|
91
|
+
colorScheme?: 'auto' | 'light' | 'dark';
|
|
92
|
+
/** `plain` (default): the title sits on the page background. `bar`: a full-width header strip on the surface color. */
|
|
93
|
+
header?: 'plain' | 'bar';
|
|
94
|
+
/**
|
|
95
|
+
* Light-mode values. Color tokens set here apply in light mode only; the others (type, shape, layout) also apply
|
|
96
|
+
* in dark mode unless `dark` sets them.
|
|
97
|
+
*/
|
|
98
|
+
light?: PanelThemeTokens;
|
|
99
|
+
/** Dark-mode values, layered over the built-in dark palette. */
|
|
100
|
+
dark?: PanelThemeTokens;
|
|
101
|
+
}
|
|
102
|
+
/** Token name to the CSS custom property the page reads. */
|
|
103
|
+
export declare const THEME_TOKENS: Record<keyof PanelThemeTokens, string>;
|
|
104
|
+
/** A theme checked and turned into CSS declarations, ready for the page. */
|
|
105
|
+
export interface ResolvedTheme {
|
|
106
|
+
colorScheme: 'auto' | 'light' | 'dark';
|
|
107
|
+
header: 'plain' | 'bar';
|
|
108
|
+
/** `--name: value` declarations. */
|
|
109
|
+
light: string[];
|
|
110
|
+
dark: string[];
|
|
111
|
+
}
|
|
112
|
+
export declare const MAX_THEME_VALUE = 200;
|
|
113
|
+
/** Why a theme value is unsafe or malformed, or null when it can go into the style block as is. */
|
|
114
|
+
export declare function themeValueProblem(value: unknown): string | null;
|
|
115
|
+
/** Checks a host theme and turns it into CSS declarations. Throws a TypeError that names the bad token. */
|
|
116
|
+
export declare function resolveTheme(theme: PanelTheme | undefined): ResolvedTheme;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// Host theming for the Panel page: a typed set of tokens that become CSS custom properties inside the page's
|
|
2
|
+
// nonce'd <style>, so an embedding host can match its own look without loosening the CSP.
|
|
3
|
+
/** Token name to the CSS custom property the page reads. */
|
|
4
|
+
export const THEME_TOKENS = {
|
|
5
|
+
font: '--font',
|
|
6
|
+
monoFont: '--mono',
|
|
7
|
+
fontSize: '--font-size',
|
|
8
|
+
smallSize: '--small',
|
|
9
|
+
controlSize: '--control-size',
|
|
10
|
+
headingSize: '--heading-size',
|
|
11
|
+
largeSize: '--large',
|
|
12
|
+
titleSize: '--title-size',
|
|
13
|
+
titleWeight: '--title-weight',
|
|
14
|
+
headingWeight: '--heading-weight',
|
|
15
|
+
strongWeight: '--strong-weight',
|
|
16
|
+
buttonWeight: '--button-weight',
|
|
17
|
+
primaryWeight: '--primary-weight',
|
|
18
|
+
bg: '--bg',
|
|
19
|
+
surface: '--surface',
|
|
20
|
+
surfaceMuted: '--surface-2',
|
|
21
|
+
border: '--border',
|
|
22
|
+
borderStrong: '--border-strong',
|
|
23
|
+
text: '--text',
|
|
24
|
+
muted: '--muted',
|
|
25
|
+
faint: '--faint',
|
|
26
|
+
hover: '--hover',
|
|
27
|
+
active: '--active',
|
|
28
|
+
accent: '--accent',
|
|
29
|
+
accentSoft: '--accent-soft',
|
|
30
|
+
focus: '--focus',
|
|
31
|
+
primary: '--primary',
|
|
32
|
+
primaryHover: '--primary-hover',
|
|
33
|
+
primaryActive: '--primary-active',
|
|
34
|
+
onPrimary: '--on-primary',
|
|
35
|
+
primaryBorder: '--primary-border',
|
|
36
|
+
primaryHoverBorder: '--primary-hover-border',
|
|
37
|
+
buttonText: '--btn-text',
|
|
38
|
+
buttonHoverBorder: '--btn-hover-border',
|
|
39
|
+
buttonHoverText: '--btn-hover-text',
|
|
40
|
+
checkColor: '--check',
|
|
41
|
+
ok: '--ok',
|
|
42
|
+
okSoft: '--ok-soft',
|
|
43
|
+
warn: '--warn',
|
|
44
|
+
warnSoft: '--warn-soft',
|
|
45
|
+
fail: '--fail',
|
|
46
|
+
failSoft: '--fail-soft',
|
|
47
|
+
logoBg: '--logo-bg',
|
|
48
|
+
logoColor: '--logo-fg',
|
|
49
|
+
logoBorder: '--logo-line',
|
|
50
|
+
shadow: '--shadow',
|
|
51
|
+
focusOutline: '--focus-outline',
|
|
52
|
+
focusRing: '--focus-ring',
|
|
53
|
+
radius: '--radius',
|
|
54
|
+
controlRadius: '--ctl-radius',
|
|
55
|
+
chipRadius: '--chip-radius',
|
|
56
|
+
controlHeight: '--ctl-h',
|
|
57
|
+
controlHeightSmall: '--ctl-h-sm',
|
|
58
|
+
fieldHeight: '--field-h',
|
|
59
|
+
contentWidth: '--content-width',
|
|
60
|
+
gutter: '--gutter',
|
|
61
|
+
};
|
|
62
|
+
export const MAX_THEME_VALUE = 200;
|
|
63
|
+
// Letters, digits, spaces and the punctuation CSS values need (`#`, `%`, `(`, `)`, `,`, `.`, `+`, `-`, `/`, quotes, `_`).
|
|
64
|
+
// Nothing that ends a declaration, a block or the <style> element (`;`, `{`, `}`, `<`), no escapes (`\`), no comments (`*`)
|
|
65
|
+
// and no `:`, so no URL scheme either.
|
|
66
|
+
const VALUE_RE = /^[A-Za-z0-9 #%(),.+\-/'"_]+$/;
|
|
67
|
+
/** Why a theme value is unsafe or malformed, or null when it can go into the style block as is. */
|
|
68
|
+
export function themeValueProblem(value) {
|
|
69
|
+
if (typeof value !== 'string')
|
|
70
|
+
return 'must be a string';
|
|
71
|
+
if (!value.trim())
|
|
72
|
+
return 'must not be empty';
|
|
73
|
+
if (value.length > MAX_THEME_VALUE)
|
|
74
|
+
return `is over ${MAX_THEME_VALUE} characters`;
|
|
75
|
+
if (!VALUE_RE.test(value))
|
|
76
|
+
return 'may hold only letters, digits, spaces and # % ( ) , . + - / \' " _ (no ; { } < \\ * or :)';
|
|
77
|
+
if (/url\s*\(/i.test(value))
|
|
78
|
+
return 'must not load a URL';
|
|
79
|
+
let depth = 0;
|
|
80
|
+
for (const c of value) {
|
|
81
|
+
if (c === '(')
|
|
82
|
+
depth++;
|
|
83
|
+
else if (c === ')' && --depth < 0)
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
if (depth !== 0)
|
|
87
|
+
return 'has unbalanced parentheses';
|
|
88
|
+
if (value.split('"').length % 2 === 0 || value.split("'").length % 2 === 0)
|
|
89
|
+
return 'has an unclosed quote';
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function declarations(tokens, where) {
|
|
93
|
+
if (tokens === undefined)
|
|
94
|
+
return [];
|
|
95
|
+
if (typeof tokens !== 'object' || tokens === null || Array.isArray(tokens))
|
|
96
|
+
throw new TypeError(`panel theme: ${where} must be an object of tokens`);
|
|
97
|
+
const out = [];
|
|
98
|
+
for (const [name, value] of Object.entries(tokens)) {
|
|
99
|
+
if (value === undefined)
|
|
100
|
+
continue;
|
|
101
|
+
const prop = Object.hasOwn(THEME_TOKENS, name) ? THEME_TOKENS[name] : undefined;
|
|
102
|
+
if (!prop)
|
|
103
|
+
throw new TypeError(`panel theme: unknown token ${where}.${name}; known tokens: ${Object.keys(THEME_TOKENS).join(', ')}`);
|
|
104
|
+
const problem = themeValueProblem(value);
|
|
105
|
+
if (problem)
|
|
106
|
+
throw new TypeError(`panel theme: ${where}.${name} ${problem}`);
|
|
107
|
+
out.push(`${prop}: ${value.trim()}`);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
/** Checks a host theme and turns it into CSS declarations. Throws a TypeError that names the bad token. */
|
|
112
|
+
export function resolveTheme(theme) {
|
|
113
|
+
if (theme === undefined)
|
|
114
|
+
return { colorScheme: 'auto', header: 'plain', light: [], dark: [] };
|
|
115
|
+
if (typeof theme !== 'object' || theme === null || Array.isArray(theme))
|
|
116
|
+
throw new TypeError('panel theme must be an object');
|
|
117
|
+
for (const key of Object.keys(theme))
|
|
118
|
+
if (!['colorScheme', 'header', 'light', 'dark'].includes(key))
|
|
119
|
+
throw new TypeError(`panel theme: unknown option ${key}; use colorScheme, header, light or dark`);
|
|
120
|
+
const colorScheme = theme.colorScheme ?? 'auto';
|
|
121
|
+
if (!['auto', 'light', 'dark'].includes(colorScheme))
|
|
122
|
+
throw new TypeError('panel theme: colorScheme must be "auto", "light" or "dark"');
|
|
123
|
+
const header = theme.header ?? 'plain';
|
|
124
|
+
if (!['plain', 'bar'].includes(header))
|
|
125
|
+
throw new TypeError('panel theme: header must be "plain" or "bar"');
|
|
126
|
+
return {
|
|
127
|
+
colorScheme,
|
|
128
|
+
header,
|
|
129
|
+
light: declarations(theme.light, 'light'),
|
|
130
|
+
dark: declarations(theme.dark, 'dark'),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Built-in, off by default. Runs `<account>/sideby-before-launch` before each Launch: the general form of
|
|
2
|
+
// a common hand-rolled convention (for example, start a local gateway an API Account needs).
|
|
3
|
+
// It is local code with the user's permissions, so it gets the same ownership checks as Plugins.
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import { access, constants } from 'node:fs/promises';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { lstatOrNull } from '../core/fs-safe.js';
|
|
8
|
+
import { checkTrusted } from './loader.js';
|
|
9
|
+
export const SCRIPT_NAME = 'sideby-before-launch';
|
|
10
|
+
export const SCRIPT_TIMEOUT_MS = 25_000;
|
|
11
|
+
const TAIL_LINES = 20;
|
|
12
|
+
function tail(text) {
|
|
13
|
+
return text.trimEnd().split('\n').slice(-TAIL_LINES).join('\n');
|
|
14
|
+
}
|
|
15
|
+
export const accountScript = {
|
|
16
|
+
name: 'account-script',
|
|
17
|
+
register(api) {
|
|
18
|
+
api.on('launch.before', async (ctx) => {
|
|
19
|
+
const script = join(ctx.account.dir, SCRIPT_NAME);
|
|
20
|
+
const st = await lstatOrNull(script);
|
|
21
|
+
if (!st)
|
|
22
|
+
return;
|
|
23
|
+
if (!st.isFile())
|
|
24
|
+
throw api.abort(`${script} must be a regular file`);
|
|
25
|
+
const problem = await checkTrusted(script);
|
|
26
|
+
if (problem)
|
|
27
|
+
throw api.abort(`refusing to run ${SCRIPT_NAME}: ${problem}`);
|
|
28
|
+
try {
|
|
29
|
+
await access(script, constants.X_OK);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
throw api.abort(`${script} is not executable; run \`chmod u+x "${script}"\``);
|
|
33
|
+
}
|
|
34
|
+
const { code, stderr, timedOut } = await new Promise((resolve) => {
|
|
35
|
+
const child = spawn(script, [], {
|
|
36
|
+
cwd: ctx.account.dir,
|
|
37
|
+
env: ctx.env,
|
|
38
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
39
|
+
});
|
|
40
|
+
let err = '';
|
|
41
|
+
child.stderr.on('data', (d) => {
|
|
42
|
+
err = (err + d.toString()).slice(-16_384);
|
|
43
|
+
});
|
|
44
|
+
const timer = setTimeout(() => {
|
|
45
|
+
child.kill('SIGKILL');
|
|
46
|
+
child.stderr.destroy();
|
|
47
|
+
resolve({ code: null, stderr: err, timedOut: true });
|
|
48
|
+
}, SCRIPT_TIMEOUT_MS);
|
|
49
|
+
child.on('error', (e) => {
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
resolve({ code: 1, stderr: e.message, timedOut: false });
|
|
52
|
+
});
|
|
53
|
+
child.on('exit', (c) => {
|
|
54
|
+
clearTimeout(timer);
|
|
55
|
+
// A background process the script started (a gateway, say) inherits stderr; stop reading it.
|
|
56
|
+
child.stderr.destroy();
|
|
57
|
+
resolve({ code: c, stderr: err, timedOut: false });
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
if (timedOut)
|
|
61
|
+
throw api.abort(`${SCRIPT_NAME} did not finish within ${SCRIPT_TIMEOUT_MS / 1000}s`);
|
|
62
|
+
if (code !== 0)
|
|
63
|
+
throw api.abort(`${SCRIPT_NAME} exited with ${code}; the host was not started\n${tail(stderr)}`);
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { HookEvent, HookEvents, HookFilter, HookHandler } from '../types.ts';
|
|
2
|
+
export declare const HOOK_TIMEOUT_MS: Record<HookEvent, number>;
|
|
3
|
+
/** Error thrown by a hook, labelled with the Plugin that raised it. */
|
|
4
|
+
export declare class HookError extends Error {
|
|
5
|
+
readonly plugin: string;
|
|
6
|
+
readonly aborted: boolean;
|
|
7
|
+
constructor(plugin: string, message: string, aborted: boolean);
|
|
8
|
+
}
|
|
9
|
+
/** Thrown by `api.abort()` to stop a Launch or a new Account on purpose. */
|
|
10
|
+
export declare class HookAbort extends Error {
|
|
11
|
+
}
|
|
12
|
+
export declare class HookBus {
|
|
13
|
+
private readonly entries;
|
|
14
|
+
private readonly timeouts;
|
|
15
|
+
constructor(timeouts?: Partial<Record<HookEvent, number>>);
|
|
16
|
+
add<E extends HookEvent>(plugin: string, event: E, filter: HookFilter, handler: HookHandler<E>): void;
|
|
17
|
+
count(event: HookEvent): number;
|
|
18
|
+
/**
|
|
19
|
+
* Runs matching handlers in registration order. Each gets the event's timeout.
|
|
20
|
+
* Returns per-plugin results; the first failure stops the chain and is thrown as HookError.
|
|
21
|
+
*/
|
|
22
|
+
run<E extends HookEvent>(event: E, family: string, ctx: HookEvents[E]['ctx']): Promise<{
|
|
23
|
+
plugin: string;
|
|
24
|
+
result: HookEvents[E]['result'];
|
|
25
|
+
}[]>;
|
|
26
|
+
/** Like `run`, but a failing handler is reported and the others still run (used by Doctor). */
|
|
27
|
+
runIsolated<E extends HookEvent>(event: E, family: string, ctx: HookEvents[E]['ctx']): Promise<{
|
|
28
|
+
plugin: string;
|
|
29
|
+
result?: HookEvents[E]['result'];
|
|
30
|
+
error?: HookError;
|
|
31
|
+
}[]>;
|
|
32
|
+
private invoke;
|
|
33
|
+
}
|