sideby 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +453 -2
- package/README.zh-CN.md +455 -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 +37 -0
- package/dist/src/cli/commands.js +487 -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 +224 -0
- package/dist/src/cli/main.d.ts +2 -0
- package/dist/src/cli/main.js +186 -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 +18 -0
- package/dist/src/core/accounts.js +116 -0
- package/dist/src/core/config.d.ts +60 -0
- package/dist/src/core/config.js +181 -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 +35 -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/secret-file.d.ts +30 -0
- package/dist/src/core/secret-file.js +116 -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 +25 -0
- package/dist/src/core/shell-init.js +61 -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 +124 -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 +67 -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 +78 -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/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 +93 -0
- package/dist/src/panel/handler.js +411 -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 +149 -0
- package/dist/src/runtime.js +371 -0
- package/dist/src/types.d.ts +278 -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 +117 -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 +166 -0
- package/schemas/doctor.json +229 -0
- package/schemas/error.json +25 -0
- package/schemas/list.json +177 -0
- package/schemas/new.json +181 -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 +52 -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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Env } from '../types.ts';
|
|
2
|
+
export declare const BUNDLE_ID = "dev.sideby.panel";
|
|
3
|
+
export type AppPlatform = 'darwin' | 'linux';
|
|
4
|
+
/** Runs a helper program; resolves false (never rejects) when it is missing or fails. */
|
|
5
|
+
export type Runner = (cmd: string, args: string[]) => Promise<boolean>;
|
|
6
|
+
export declare const defaultRunner: Runner;
|
|
7
|
+
export interface AppContext {
|
|
8
|
+
home: string;
|
|
9
|
+
env: Env;
|
|
10
|
+
platform: NodeJS.Platform;
|
|
11
|
+
run?: Runner;
|
|
12
|
+
}
|
|
13
|
+
export interface InstallOptions {
|
|
14
|
+
/** Absolute path of the Node binary the launcher runs (normally `process.execPath`). */
|
|
15
|
+
node: string;
|
|
16
|
+
/** Absolute path of the sideby CLI entry. */
|
|
17
|
+
entry: string;
|
|
18
|
+
/** Open this address instead of starting the Panel, for a portal that embeds it. */
|
|
19
|
+
url?: string;
|
|
20
|
+
version: string;
|
|
21
|
+
}
|
|
22
|
+
export interface InstallResult {
|
|
23
|
+
platform: AppPlatform;
|
|
24
|
+
/** The app bundle (macOS) or the .desktop file (Linux). */
|
|
25
|
+
path: string;
|
|
26
|
+
files: string[];
|
|
27
|
+
mode: 'panel' | 'url';
|
|
28
|
+
url?: string;
|
|
29
|
+
node: string;
|
|
30
|
+
entry: string;
|
|
31
|
+
updated: boolean;
|
|
32
|
+
hints: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface UninstallResult {
|
|
35
|
+
platform: AppPlatform;
|
|
36
|
+
removed: string[];
|
|
37
|
+
skipped: {
|
|
38
|
+
path: string;
|
|
39
|
+
reason: string;
|
|
40
|
+
}[];
|
|
41
|
+
}
|
|
42
|
+
/** Checks a `--url` value: an http or https address. */
|
|
43
|
+
export declare function checkAppUrl(url: string): string;
|
|
44
|
+
export interface LauncherSpec {
|
|
45
|
+
node: string;
|
|
46
|
+
entry: string;
|
|
47
|
+
url?: string;
|
|
48
|
+
platform: AppPlatform;
|
|
49
|
+
/** XDG directories set when installing, so the app reads the same config and state as the terminal. */
|
|
50
|
+
xdg: {
|
|
51
|
+
XDG_CONFIG_HOME?: string;
|
|
52
|
+
XDG_STATE_HOME?: string;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** The POSIX shell launcher. It records absolute paths and falls back to `sideby` from the login shell. */
|
|
56
|
+
export declare function launcherScript(s: LauncherSpec): string;
|
|
57
|
+
export declare function infoPlist(version: string): string;
|
|
58
|
+
export declare function desktopEntry(launcher: string): string;
|
|
59
|
+
export declare function installApp(ctx: AppContext, opts: InstallOptions): Promise<InstallResult>;
|
|
60
|
+
export declare function uninstallApp(ctx: AppContext): Promise<UninstallResult>;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// `sideby app install|uninstall`: a double-clickable launcher for the Panel. macOS gets ~/Applications/sideby.app,
|
|
2
|
+
// Linux a .desktop entry. The launcher runs the recorded Node and sideby entry with `ui --background`, or with
|
|
3
|
+
// `--url` just opens that address. Every file carries APP_MARKER, and uninstall removes only marked files.
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { constants } from 'node:fs';
|
|
6
|
+
import { chmod, mkdir, mkdtemp, open, rename, rm, rmdir, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
8
|
+
import { UserError } from '../core/errors.js';
|
|
9
|
+
import { lstatOrNull, writeFileAtomic } from '../core/fs-safe.js';
|
|
10
|
+
import { APP_MARKER, encodeIcns, ICONSET, iconPng, iconSvg } from './icon.js';
|
|
11
|
+
export const BUNDLE_ID = 'dev.sideby.panel';
|
|
12
|
+
const APP_NAME = 'sideby';
|
|
13
|
+
export const defaultRunner = (cmd, args) => new Promise((resolve) => execFile(cmd, args, { timeout: 60_000 }, (err) => resolve(!err)));
|
|
14
|
+
function platformOf(ctx) {
|
|
15
|
+
if (ctx.platform === 'darwin' || ctx.platform === 'linux')
|
|
16
|
+
return ctx.platform;
|
|
17
|
+
throw new UserError(`sideby app supports macOS and Linux; on ${ctx.platform} run \`sideby ui --background\` from a shortcut instead`);
|
|
18
|
+
}
|
|
19
|
+
/** Checks a `--url` value: an http or https address. */
|
|
20
|
+
export function checkAppUrl(url) {
|
|
21
|
+
let u;
|
|
22
|
+
try {
|
|
23
|
+
u = new URL(url);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new UserError(`--url must be a full address such as http://localhost:17333/ (got "${url}")`);
|
|
27
|
+
}
|
|
28
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:')
|
|
29
|
+
throw new UserError(`--url must start with http:// or https:// (got "${url}")`);
|
|
30
|
+
return u.href;
|
|
31
|
+
}
|
|
32
|
+
const shq = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
33
|
+
const xml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
34
|
+
/** The POSIX shell launcher. It records absolute paths and falls back to `sideby` from the login shell. */
|
|
35
|
+
export function launcherScript(s) {
|
|
36
|
+
const opener = s.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
37
|
+
const alert = s.platform === 'darwin'
|
|
38
|
+
? `/usr/bin/osascript -e 'display alert "sideby could not start" message "Run sideby app install again in a terminal. Details: '"$LOG"'"' >/dev/null 2>&1`
|
|
39
|
+
: `command -v notify-send >/dev/null 2>&1 && notify-send "sideby could not start" "Run sideby app install again in a terminal. Details: $LOG"`;
|
|
40
|
+
const lines = [
|
|
41
|
+
'#!/bin/sh',
|
|
42
|
+
`# ${APP_MARKER}: written by \`sideby app install\`. Run that again after upgrading sideby or Node.`,
|
|
43
|
+
`NODE=${shq(s.node)}`,
|
|
44
|
+
`ENTRY=${shq(s.entry)}`,
|
|
45
|
+
`URL=${shq(s.url ?? '')}`,
|
|
46
|
+
];
|
|
47
|
+
for (const [k, v] of Object.entries(s.xdg))
|
|
48
|
+
if (v)
|
|
49
|
+
lines.push(`export ${k}=${shq(v)}`);
|
|
50
|
+
lines.push(`LOG="\${XDG_STATE_HOME:-$HOME/.local/state}/sideby/launcher.log"`, 'mkdir -p "$(dirname "$LOG")" 2>/dev/null', 'if [ -n "$URL" ]; then', ` exec ${opener} "$URL"`, 'fi', 'if [ -x "$NODE" ] && [ -f "$ENTRY" ]; then', ' "$NODE" "$ENTRY" ui --background >>"$LOG" 2>&1 && exit 0', 'else', ' # The recorded Node or sideby moved (an upgrade, a version manager): ask the login shell for `sideby`.', ` "\${SHELL:-/bin/sh}" -ilc 'sideby ui --background' >>"$LOG" 2>&1 </dev/null && exit 0`, 'fi', alert, 'exit 1', '');
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
export function infoPlist(version) {
|
|
54
|
+
const entries = [
|
|
55
|
+
['CFBundleName', APP_NAME],
|
|
56
|
+
['CFBundleDisplayName', APP_NAME],
|
|
57
|
+
['CFBundleIdentifier', BUNDLE_ID],
|
|
58
|
+
['CFBundleExecutable', APP_NAME],
|
|
59
|
+
['CFBundleIconFile', 'AppIcon'],
|
|
60
|
+
['CFBundlePackageType', 'APPL'],
|
|
61
|
+
['CFBundleShortVersionString', version],
|
|
62
|
+
['CFBundleVersion', version],
|
|
63
|
+
['LSMinimumSystemVersion', '10.13'],
|
|
64
|
+
['SidebyInstalledBy', APP_MARKER],
|
|
65
|
+
];
|
|
66
|
+
return [
|
|
67
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
68
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
69
|
+
'<plist version="1.0">',
|
|
70
|
+
'<dict>',
|
|
71
|
+
...entries.flatMap(([k, v]) => [` <key>${k}</key>`, ` <string>${xml(v)}</string>`]),
|
|
72
|
+
'</dict>',
|
|
73
|
+
'</plist>',
|
|
74
|
+
'',
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
/** Desktop Entry `Exec` value; refuses characters that would need the spec's double escaping. */
|
|
78
|
+
function execArg(p) {
|
|
79
|
+
if (/["`$\\%\n\r]/.test(p))
|
|
80
|
+
throw new UserError(`cannot write a desktop entry for a path with ", \`, $, \\ or %: ${p}`);
|
|
81
|
+
return `"${p}"`;
|
|
82
|
+
}
|
|
83
|
+
export function desktopEntry(launcher) {
|
|
84
|
+
return [
|
|
85
|
+
'[Desktop Entry]',
|
|
86
|
+
'Type=Application',
|
|
87
|
+
`Name=${APP_NAME}`,
|
|
88
|
+
'Comment=Every AI coding account side by side',
|
|
89
|
+
`Exec=${execArg(launcher)}`,
|
|
90
|
+
`Icon=${APP_NAME}`,
|
|
91
|
+
'Terminal=false',
|
|
92
|
+
'Categories=Development;Utility;',
|
|
93
|
+
`X-Sideby-Installed-By=${APP_MARKER}`,
|
|
94
|
+
'',
|
|
95
|
+
].join('\n');
|
|
96
|
+
}
|
|
97
|
+
function layout(ctx, platform) {
|
|
98
|
+
if (platform === 'darwin') {
|
|
99
|
+
const app = join(ctx.home, 'Applications', `${APP_NAME}.app`);
|
|
100
|
+
return { entries: [app], primary: app };
|
|
101
|
+
}
|
|
102
|
+
const dataHome = ctx.env.XDG_DATA_HOME && isAbsolute(ctx.env.XDG_DATA_HOME)
|
|
103
|
+
? ctx.env.XDG_DATA_HOME
|
|
104
|
+
: join(ctx.home, '.local', 'share');
|
|
105
|
+
const linux = {
|
|
106
|
+
desktop: join(dataHome, 'applications', `${APP_NAME}.desktop`),
|
|
107
|
+
launcher: join(dataHome, APP_NAME, 'sideby-launcher'),
|
|
108
|
+
svg: join(dataHome, 'icons', 'hicolor', 'scalable', 'apps', `${APP_NAME}.svg`),
|
|
109
|
+
png: join(dataHome, 'icons', 'hicolor', '256x256', 'apps', `${APP_NAME}.png`),
|
|
110
|
+
};
|
|
111
|
+
return { entries: Object.values(linux), primary: linux.desktop, linux };
|
|
112
|
+
}
|
|
113
|
+
/** How each kind of install target carries APP_MARKER; anything that merely mentions it elsewhere is foreign. */
|
|
114
|
+
function markerTest(entry) {
|
|
115
|
+
if (entry.endsWith('.app'))
|
|
116
|
+
return (d) => new RegExp(`<key>SidebyInstalledBy</key>\\s*<string>${APP_MARKER}</string>`).test(d.toString('utf8'));
|
|
117
|
+
if (entry.endsWith('.desktop'))
|
|
118
|
+
return (d) => new RegExp(`^X-Sideby-Installed-By=${APP_MARKER}$`, 'm').test(d.toString('utf8'));
|
|
119
|
+
if (entry.endsWith('.svg'))
|
|
120
|
+
return (d) => d.includes(`<!-- ${APP_MARKER} -->`);
|
|
121
|
+
if (entry.endsWith('.png'))
|
|
122
|
+
return (d) => d.includes(`tEXtSoftware\0${APP_MARKER}`, 0, 'latin1');
|
|
123
|
+
// The launcher script: the marker comment is its second line.
|
|
124
|
+
return (d) => d.toString('utf8').split('\n', 2)[1]?.startsWith(`# ${APP_MARKER}: `) === true;
|
|
125
|
+
}
|
|
126
|
+
/** Reads a regular file without following a symlink, however the path changed since it was checked. */
|
|
127
|
+
async function readNoFollow(path) {
|
|
128
|
+
const fh = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
129
|
+
try {
|
|
130
|
+
return await fh.readFile();
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
await fh.close();
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const LINK = 'is a symbolic link';
|
|
137
|
+
const NOT_OURS = 'exists and was not created by `sideby app install`';
|
|
138
|
+
/**
|
|
139
|
+
* Whether `entry` is something `sideby app install` wrote. Never follows a symlink: a link at the target, or
|
|
140
|
+
* (for the macOS bundle) anywhere among the bundle's own paths, is foreign even when it leads to a marked file,
|
|
141
|
+
* so install never writes through it and uninstall never removes it.
|
|
142
|
+
*/
|
|
143
|
+
async function ownership(entry) {
|
|
144
|
+
const top = await lstatOrNull(entry);
|
|
145
|
+
if (!top)
|
|
146
|
+
return { state: 'absent' };
|
|
147
|
+
const foreign = (path, reason) => ({ state: 'foreign', path, reason });
|
|
148
|
+
if (top.isSymbolicLink())
|
|
149
|
+
return foreign(entry, LINK);
|
|
150
|
+
let markerPath = entry;
|
|
151
|
+
if (entry.endsWith('.app')) {
|
|
152
|
+
if (!top.isDirectory())
|
|
153
|
+
return foreign(entry, 'is not a directory');
|
|
154
|
+
const parts = [
|
|
155
|
+
['Contents', 'dir', true],
|
|
156
|
+
['Contents/Info.plist', 'file', true],
|
|
157
|
+
['Contents/MacOS', 'dir', false],
|
|
158
|
+
[`Contents/MacOS/${APP_NAME}`, 'file', false],
|
|
159
|
+
['Contents/Resources', 'dir', false],
|
|
160
|
+
['Contents/Resources/AppIcon.icns', 'file', false],
|
|
161
|
+
];
|
|
162
|
+
for (const [rel, kind, required] of parts) {
|
|
163
|
+
const p = join(entry, rel);
|
|
164
|
+
const st = await lstatOrNull(p);
|
|
165
|
+
if (!st) {
|
|
166
|
+
if (required)
|
|
167
|
+
return foreign(entry, NOT_OURS);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (st.isSymbolicLink())
|
|
171
|
+
return foreign(p, LINK);
|
|
172
|
+
if (kind === 'dir' ? !st.isDirectory() : !st.isFile())
|
|
173
|
+
return foreign(p, kind === 'dir' ? 'is not a directory' : 'is not a regular file');
|
|
174
|
+
}
|
|
175
|
+
markerPath = join(entry, 'Contents', 'Info.plist');
|
|
176
|
+
}
|
|
177
|
+
else if (!top.isFile())
|
|
178
|
+
return foreign(entry, 'is not a regular file');
|
|
179
|
+
let data;
|
|
180
|
+
try {
|
|
181
|
+
data = await readNoFollow(markerPath);
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
return foreign(entry, NOT_OURS);
|
|
185
|
+
}
|
|
186
|
+
return markerTest(entry)(data) ? { state: 'ours' } : foreign(entry, NOT_OURS);
|
|
187
|
+
}
|
|
188
|
+
/** Refuses to replace anything at `entry` that `sideby app install` did not write; true when it is ours. */
|
|
189
|
+
async function assertOwned(entry, verb) {
|
|
190
|
+
const o = await ownership(entry);
|
|
191
|
+
if (o.state === 'absent')
|
|
192
|
+
return false;
|
|
193
|
+
if (o.state === 'ours')
|
|
194
|
+
return true;
|
|
195
|
+
throw new UserError(`${o.path} ${o.reason}; move it away first, then ${verb} again`);
|
|
196
|
+
}
|
|
197
|
+
export async function installApp(ctx, opts) {
|
|
198
|
+
const platform = platformOf(ctx);
|
|
199
|
+
const run = ctx.run ?? defaultRunner;
|
|
200
|
+
if (!isAbsolute(opts.node) || !isAbsolute(opts.entry))
|
|
201
|
+
throw new UserError('node and the sideby entry must be absolute paths');
|
|
202
|
+
const url = opts.url === undefined ? undefined : checkAppUrl(opts.url);
|
|
203
|
+
const l = layout(ctx, platform);
|
|
204
|
+
let updated = false;
|
|
205
|
+
for (const e of l.entries)
|
|
206
|
+
if (await assertOwned(e, 'install'))
|
|
207
|
+
updated = true;
|
|
208
|
+
const xdg = {};
|
|
209
|
+
for (const k of ['XDG_CONFIG_HOME', 'XDG_STATE_HOME']) {
|
|
210
|
+
const v = ctx.env[k];
|
|
211
|
+
if (v && isAbsolute(v))
|
|
212
|
+
xdg[k] = v;
|
|
213
|
+
}
|
|
214
|
+
const script = launcherScript({
|
|
215
|
+
node: opts.node,
|
|
216
|
+
entry: opts.entry,
|
|
217
|
+
platform,
|
|
218
|
+
xdg,
|
|
219
|
+
...(url ? { url } : {}),
|
|
220
|
+
});
|
|
221
|
+
const files = [];
|
|
222
|
+
const hints = [];
|
|
223
|
+
if (platform === 'darwin') {
|
|
224
|
+
const app = l.primary;
|
|
225
|
+
await mkdir(dirname(app), { recursive: true });
|
|
226
|
+
// Build next to the target, then swap it in, so a failed install never leaves half a bundle.
|
|
227
|
+
const tmp = await mkdtemp(join(dirname(app), '.sideby.app.tmp-'));
|
|
228
|
+
try {
|
|
229
|
+
await chmod(tmp, 0o755);
|
|
230
|
+
const contents = join(tmp, 'Contents');
|
|
231
|
+
await mkdir(join(contents, 'MacOS'), { recursive: true });
|
|
232
|
+
await mkdir(join(contents, 'Resources'), { recursive: true });
|
|
233
|
+
await writeFile(join(contents, 'Info.plist'), infoPlist(opts.version));
|
|
234
|
+
await writeFile(join(contents, 'MacOS', APP_NAME), script, { mode: 0o755 });
|
|
235
|
+
await chmod(join(contents, 'MacOS', APP_NAME), 0o755);
|
|
236
|
+
const iconset = join(tmp, 'AppIcon.iconset');
|
|
237
|
+
await mkdir(iconset);
|
|
238
|
+
const pngs = new Map();
|
|
239
|
+
for (const { name, size } of ICONSET) {
|
|
240
|
+
const png = iconPng(size);
|
|
241
|
+
pngs.set(name, png);
|
|
242
|
+
await writeFile(join(iconset, name), png);
|
|
243
|
+
}
|
|
244
|
+
const icns = join(contents, 'Resources', 'AppIcon.icns');
|
|
245
|
+
if (!(await run('iconutil', ['-c', 'icns', iconset, '-o', icns])))
|
|
246
|
+
await writeFile(icns, encodeIcns(pngs));
|
|
247
|
+
await rm(iconset, { recursive: true, force: true });
|
|
248
|
+
await rm(app, { recursive: true, force: true });
|
|
249
|
+
await rename(tmp, app);
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
await rm(tmp, { recursive: true, force: true });
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
// Ask Launch Services to pick up the new icon and bundle; harmless when it is not there.
|
|
256
|
+
await run('/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister', ['-f', app]);
|
|
257
|
+
files.push(app, join(app, 'Contents', 'Info.plist'), join(app, 'Contents', 'MacOS', APP_NAME), join(app, 'Contents', 'Resources', 'AppIcon.icns'));
|
|
258
|
+
hints.push('Open it from ~/Applications or Spotlight ("sideby"), or drag it to the Dock.');
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const p = l.linux;
|
|
262
|
+
// Temp file in the same directory, then rename: never writes through a link, never leaves half a file.
|
|
263
|
+
const write = async (path, data, mode = 0o644) => {
|
|
264
|
+
await mkdir(dirname(path), { recursive: true });
|
|
265
|
+
await writeFileAtomic(path, data, mode);
|
|
266
|
+
files.push(path);
|
|
267
|
+
};
|
|
268
|
+
await write(p.launcher, script, 0o755);
|
|
269
|
+
await write(p.svg, iconSvg());
|
|
270
|
+
await write(p.png, iconPng(256));
|
|
271
|
+
await write(p.desktop, desktopEntry(p.launcher));
|
|
272
|
+
await run('update-desktop-database', [dirname(p.desktop)]);
|
|
273
|
+
hints.push('Find "sideby" in your application menu.');
|
|
274
|
+
}
|
|
275
|
+
if (url)
|
|
276
|
+
hints.push(`The app opens ${url}.`);
|
|
277
|
+
else
|
|
278
|
+
hints.push('The app starts the panel in the background and opens it; stop the panel with `sideby ui --stop`.', 'Run `sideby app install` again after upgrading sideby or Node, so the app starts the new version.');
|
|
279
|
+
if (/[/\\]_npx[/\\]/.test(opts.entry))
|
|
280
|
+
hints.push('sideby runs from the npx cache, which npm may clear; install it with `npm i -g sideby` and run this again.');
|
|
281
|
+
return {
|
|
282
|
+
platform,
|
|
283
|
+
path: l.primary,
|
|
284
|
+
files,
|
|
285
|
+
mode: url ? 'url' : 'panel',
|
|
286
|
+
...(url ? { url } : {}),
|
|
287
|
+
node: opts.node,
|
|
288
|
+
entry: opts.entry,
|
|
289
|
+
updated,
|
|
290
|
+
hints,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
export async function uninstallApp(ctx) {
|
|
294
|
+
const platform = platformOf(ctx);
|
|
295
|
+
const l = layout(ctx, platform);
|
|
296
|
+
const removed = [];
|
|
297
|
+
const skipped = [];
|
|
298
|
+
for (const e of l.entries) {
|
|
299
|
+
const o = await ownership(e);
|
|
300
|
+
if (o.state === 'absent')
|
|
301
|
+
continue;
|
|
302
|
+
if (o.state === 'foreign') {
|
|
303
|
+
const reason = o.reason === NOT_OURS ? 'not created by `sideby app install`' : `${o.path} ${o.reason}`;
|
|
304
|
+
skipped.push({ path: e, reason: `${reason}; left in place` });
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
await rm(e, { recursive: e.endsWith('.app'), force: true });
|
|
308
|
+
removed.push(e);
|
|
309
|
+
}
|
|
310
|
+
if (platform === 'linux' && removed.length) {
|
|
311
|
+
// The launcher's own directory, only when nothing else is left in it.
|
|
312
|
+
await rmdir(dirname(l.linux.launcher)).catch(() => { });
|
|
313
|
+
await (ctx.run ?? defaultRunner)('update-desktop-database', [dirname(l.primary)]);
|
|
314
|
+
}
|
|
315
|
+
return { platform, removed, skipped };
|
|
316
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Text stored in every generated file, so `sideby app uninstall` deletes only what it wrote. */
|
|
2
|
+
export declare const APP_MARKER = "sideby-app-install";
|
|
3
|
+
/** RGBA pixels (straight alpha) of the icon at `size`×`size`. */
|
|
4
|
+
export declare function renderIcon(size: number): Uint8Array;
|
|
5
|
+
/** Encodes straight-alpha RGBA pixels as a PNG with a `Software` text chunk holding APP_MARKER. */
|
|
6
|
+
export declare function encodePng(width: number, height: number, rgba: Uint8Array): Buffer;
|
|
7
|
+
export declare const iconPng: (size: number) => Buffer;
|
|
8
|
+
/** The macOS `.iconset` file names and their pixel sizes, 16 to 1024. */
|
|
9
|
+
export declare const ICONSET: readonly {
|
|
10
|
+
name: string;
|
|
11
|
+
size: number;
|
|
12
|
+
}[];
|
|
13
|
+
/** Builds an `.icns` file directly from PNGs; used when `iconutil` is not available. */
|
|
14
|
+
export declare function encodeIcns(pngs: ReadonlyMap<string, Buffer>): Buffer;
|
|
15
|
+
/** The same mark as an SVG (1024-unit canvas), for Linux icon themes. */
|
|
16
|
+
export declare function iconSvg(): string;
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
// The sideby mark drawn without dependencies: the Panel header's logo (a dark rounded tile with a light disc
|
|
2
|
+
// split into two halves, offset along the diagonal, the second at 55% opacity) rasterised with analytic
|
|
3
|
+
// antialiasing and encoded as PNG, SVG or ICNS.
|
|
4
|
+
import { deflateSync } from 'node:zlib';
|
|
5
|
+
/** Text stored in every generated file, so `sideby app uninstall` deletes only what it wrote. */
|
|
6
|
+
export const APP_MARKER = 'sideby-app-install';
|
|
7
|
+
// Geometry in the Panel's 40-unit logo box (page.ts: .logo 40×40, radius 10; halves of radius 10.5 centred at
|
|
8
|
+
// 18.5,18.5 facing left and 21.5,21.5 facing right, so they sit 3 apart and mirror through the centre).
|
|
9
|
+
const TILE = { size: 40, radius: 10, color: [0x15, 0x17, 0x1c] };
|
|
10
|
+
const MARK = { color: [0xf5, 0xf6, 0xf8], opacities: [1, 0.55] };
|
|
11
|
+
const HALVES = [
|
|
12
|
+
{ cx: 18.5, cy: 18.5, r: 10.5, side: -1 },
|
|
13
|
+
{ cx: 21.5, cy: 21.5, r: 10.5, side: 1 },
|
|
14
|
+
];
|
|
15
|
+
// The tile sits on the macOS icon grid: 824 of 1024 units, centred.
|
|
16
|
+
const GRID = { canvas: 1024, tile: 824 };
|
|
17
|
+
function shapes(size) {
|
|
18
|
+
const tilePx = (size * GRID.tile) / GRID.canvas;
|
|
19
|
+
const off = (size - tilePx) / 2;
|
|
20
|
+
const u = tilePx / TILE.size;
|
|
21
|
+
return {
|
|
22
|
+
tile: { x: off, y: off, w: tilePx, h: tilePx, r: TILE.radius * u },
|
|
23
|
+
halves: HALVES.map((h) => ({ cx: off + h.cx * u, cy: off + h.cy * u, r: h.r * u, side: h.side })),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Signed distance from (px, py) to a rounded rectangle; negative inside. */
|
|
27
|
+
function roundedRectDistance(px, py, s) {
|
|
28
|
+
const hx = s.w / 2;
|
|
29
|
+
const hy = s.h / 2;
|
|
30
|
+
const qx = Math.abs(px - (s.x + hx)) - (hx - s.r);
|
|
31
|
+
const qy = Math.abs(py - (s.y + hy)) - (hy - s.r);
|
|
32
|
+
const outside = Math.hypot(Math.max(qx, 0), Math.max(qy, 0));
|
|
33
|
+
return outside + Math.min(Math.max(qx, qy), 0) - s.r;
|
|
34
|
+
}
|
|
35
|
+
/** Signed distance from (px, py) to a half disc: the disc intersected with the half-plane on its side. */
|
|
36
|
+
function halfDistance(px, py, h) {
|
|
37
|
+
return Math.max(Math.hypot(px - h.cx, py - h.cy) - h.r, (px - h.cx) * -h.side);
|
|
38
|
+
}
|
|
39
|
+
/** RGBA pixels (straight alpha) of the icon at `size`×`size`. */
|
|
40
|
+
export function renderIcon(size) {
|
|
41
|
+
const { tile, halves } = shapes(size);
|
|
42
|
+
const layers = [
|
|
43
|
+
{ dist: (x, y) => roundedRectDistance(x, y, tile), color: TILE.color, opacity: 1 },
|
|
44
|
+
...halves.map((h, i) => ({
|
|
45
|
+
dist: (x, y) => halfDistance(x, y, h),
|
|
46
|
+
color: MARK.color,
|
|
47
|
+
opacity: MARK.opacities[i],
|
|
48
|
+
})),
|
|
49
|
+
];
|
|
50
|
+
const px = new Uint8Array(size * size * 4);
|
|
51
|
+
for (let y = 0; y < size; y++)
|
|
52
|
+
for (let x = 0; x < size; x++) {
|
|
53
|
+
// Premultiplied "over" compositing; coverage from the distance at the pixel centre.
|
|
54
|
+
let r = 0;
|
|
55
|
+
let g = 0;
|
|
56
|
+
let b = 0;
|
|
57
|
+
let a = 0;
|
|
58
|
+
for (const l of layers) {
|
|
59
|
+
const cover = Math.min(1, Math.max(0, 0.5 - l.dist(x + 0.5, y + 0.5)));
|
|
60
|
+
const la = cover * l.opacity;
|
|
61
|
+
if (la === 0)
|
|
62
|
+
continue;
|
|
63
|
+
r = (l.color[0] / 255) * la + r * (1 - la);
|
|
64
|
+
g = (l.color[1] / 255) * la + g * (1 - la);
|
|
65
|
+
b = (l.color[2] / 255) * la + b * (1 - la);
|
|
66
|
+
a = la + a * (1 - la);
|
|
67
|
+
}
|
|
68
|
+
const i = (y * size + x) * 4;
|
|
69
|
+
if (a > 0) {
|
|
70
|
+
px[i] = Math.round((r / a) * 255);
|
|
71
|
+
px[i + 1] = Math.round((g / a) * 255);
|
|
72
|
+
px[i + 2] = Math.round((b / a) * 255);
|
|
73
|
+
px[i + 3] = Math.round(a * 255);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return px;
|
|
77
|
+
}
|
|
78
|
+
const CRC_TABLE = (() => {
|
|
79
|
+
const t = new Uint32Array(256);
|
|
80
|
+
for (let n = 0; n < 256; n++) {
|
|
81
|
+
let c = n;
|
|
82
|
+
for (let k = 0; k < 8; k++)
|
|
83
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
84
|
+
t[n] = c >>> 0;
|
|
85
|
+
}
|
|
86
|
+
return t;
|
|
87
|
+
})();
|
|
88
|
+
function crc32(buf) {
|
|
89
|
+
let c = 0xffffffff;
|
|
90
|
+
for (const byte of buf)
|
|
91
|
+
c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
92
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
93
|
+
}
|
|
94
|
+
function chunk(type, data) {
|
|
95
|
+
const head = Buffer.alloc(8);
|
|
96
|
+
head.writeUInt32BE(data.length, 0);
|
|
97
|
+
head.write(type, 4, 'latin1');
|
|
98
|
+
const crc = Buffer.alloc(4);
|
|
99
|
+
crc.writeUInt32BE(crc32(Buffer.concat([head.subarray(4), data])), 0);
|
|
100
|
+
return Buffer.concat([head, data, crc]);
|
|
101
|
+
}
|
|
102
|
+
/** Encodes straight-alpha RGBA pixels as a PNG with a `Software` text chunk holding APP_MARKER. */
|
|
103
|
+
export function encodePng(width, height, rgba) {
|
|
104
|
+
const ihdr = Buffer.alloc(13);
|
|
105
|
+
ihdr.writeUInt32BE(width, 0);
|
|
106
|
+
ihdr.writeUInt32BE(height, 4);
|
|
107
|
+
ihdr.set([8, 6, 0, 0, 0], 8); // 8-bit RGBA, no interlace
|
|
108
|
+
const raw = Buffer.alloc((width * 4 + 1) * height);
|
|
109
|
+
for (let y = 0; y < height; y++) {
|
|
110
|
+
raw[y * (width * 4 + 1)] = 0; // filter: none
|
|
111
|
+
raw.set(rgba.subarray(y * width * 4, (y + 1) * width * 4), y * (width * 4 + 1) + 1);
|
|
112
|
+
}
|
|
113
|
+
return Buffer.concat([
|
|
114
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
115
|
+
chunk('IHDR', ihdr),
|
|
116
|
+
chunk('tEXt', Buffer.from(`Software\0${APP_MARKER}`, 'latin1')),
|
|
117
|
+
chunk('IDAT', deflateSync(raw, { level: 9 })),
|
|
118
|
+
chunk('IEND', Buffer.alloc(0)),
|
|
119
|
+
]);
|
|
120
|
+
}
|
|
121
|
+
export const iconPng = (size) => encodePng(size, size, renderIcon(size));
|
|
122
|
+
/** The macOS `.iconset` file names and their pixel sizes, 16 to 1024. */
|
|
123
|
+
export const ICONSET = [16, 32, 128, 256, 512].flatMap((s) => [
|
|
124
|
+
{ name: `icon_${s}x${s}.png`, size: s },
|
|
125
|
+
{ name: `icon_${s}x${s}@2x.png`, size: s * 2 },
|
|
126
|
+
]);
|
|
127
|
+
// ICNS element types for PNG data, by pixel size (`@2x` variants have their own types).
|
|
128
|
+
const ICNS_TYPES = {
|
|
129
|
+
'icon_16x16.png': 'icp4',
|
|
130
|
+
'icon_16x16@2x.png': 'ic11',
|
|
131
|
+
'icon_32x32.png': 'icp5',
|
|
132
|
+
'icon_32x32@2x.png': 'ic12',
|
|
133
|
+
'icon_128x128.png': 'ic07',
|
|
134
|
+
'icon_128x128@2x.png': 'ic13',
|
|
135
|
+
'icon_256x256.png': 'ic08',
|
|
136
|
+
'icon_256x256@2x.png': 'ic14',
|
|
137
|
+
'icon_512x512.png': 'ic09',
|
|
138
|
+
'icon_512x512@2x.png': 'ic10',
|
|
139
|
+
};
|
|
140
|
+
/** Builds an `.icns` file directly from PNGs; used when `iconutil` is not available. */
|
|
141
|
+
export function encodeIcns(pngs) {
|
|
142
|
+
const parts = [];
|
|
143
|
+
for (const { name } of ICONSET) {
|
|
144
|
+
const data = pngs.get(name);
|
|
145
|
+
if (!data)
|
|
146
|
+
continue;
|
|
147
|
+
const head = Buffer.alloc(8);
|
|
148
|
+
head.write(ICNS_TYPES[name], 0, 'latin1');
|
|
149
|
+
head.writeUInt32BE(data.length + 8, 4);
|
|
150
|
+
parts.push(head, data);
|
|
151
|
+
}
|
|
152
|
+
const body = Buffer.concat(parts);
|
|
153
|
+
const head = Buffer.alloc(8);
|
|
154
|
+
head.write('icns', 0, 'latin1');
|
|
155
|
+
head.writeUInt32BE(body.length + 8, 4);
|
|
156
|
+
return Buffer.concat([head, body]);
|
|
157
|
+
}
|
|
158
|
+
/** The same mark as an SVG (1024-unit canvas), for Linux icon themes. */
|
|
159
|
+
export function iconSvg() {
|
|
160
|
+
const { tile, halves } = shapes(GRID.canvas);
|
|
161
|
+
const hex = (c) => `#${c.map((v) => v.toString(16).padStart(2, '0')).join('')}`;
|
|
162
|
+
const opacityAttr = (opacity) => (opacity === 1 ? '' : ` fill-opacity="${opacity}"`);
|
|
163
|
+
const rect = (s, fill) => ` <rect x="${s.x}" y="${s.y}" width="${s.w}" height="${s.h}" rx="${s.r}" fill="${fill}"/>`;
|
|
164
|
+
// From the top of the cut to its bottom, bulging to the half's side.
|
|
165
|
+
const half = (h, fill, opacity) => ` <path d="M${h.cx} ${h.cy - h.r}A${h.r} ${h.r} 0 0 ${h.side > 0 ? 1 : 0} ${h.cx} ${h.cy + h.r}Z" fill="${fill}"${opacityAttr(opacity)}/>`;
|
|
166
|
+
return [
|
|
167
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${GRID.canvas} ${GRID.canvas}" width="${GRID.canvas}" height="${GRID.canvas}">`,
|
|
168
|
+
` <!-- ${APP_MARKER} -->`,
|
|
169
|
+
rect(tile, hex(TILE.color)),
|
|
170
|
+
...halves.map((h, i) => half(h, hex(MARK.color), MARK.opacities[i])),
|
|
171
|
+
'</svg>',
|
|
172
|
+
'',
|
|
173
|
+
].join('\n');
|
|
174
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Account, FamilyDef, Plugin } from '../../types.ts';
|
|
2
|
+
/** The Main Account keeps `.claude.json` in HOME; other Accounts keep it inside their directory. */
|
|
3
|
+
export declare function claudeJsonOf(account: Account): string;
|
|
4
|
+
/**
|
|
5
|
+
* `oauthAccount.emailAddress` and `organizationName` from `.claude.json`; nothing else is kept. The organization a
|
|
6
|
+
* personal sign-in gets by default ("<name>'s Organization") says nothing new, so it is left out.
|
|
7
|
+
*/
|
|
8
|
+
export declare function claudeIdentity(account: Account): Promise<import("../../types.ts").AccountIdentity | undefined>;
|
|
9
|
+
export declare const claudeFamily: FamilyDef;
|
|
10
|
+
export declare const claudePlugin: Plugin;
|