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.
Files changed (154) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +111 -2
  3. package/README.zh-CN.md +112 -0
  4. package/dist/build.json +1 -0
  5. package/dist/src/cli/args.d.ts +13 -0
  6. package/dist/src/cli/args.js +38 -0
  7. package/dist/src/cli/commands.d.ts +50 -0
  8. package/dist/src/cli/commands.js +566 -0
  9. package/dist/src/cli/format.d.ts +16 -0
  10. package/dist/src/cli/format.js +80 -0
  11. package/dist/src/cli/json-schemas.d.ts +2 -0
  12. package/dist/src/cli/json-schemas.js +259 -0
  13. package/dist/src/cli/main.d.ts +2 -0
  14. package/dist/src/cli/main.js +276 -0
  15. package/dist/src/cli/schemas.d.ts +1 -0
  16. package/dist/src/cli/schemas.js +16 -0
  17. package/dist/src/core/account-name.d.ts +2 -0
  18. package/dist/src/core/account-name.js +2 -0
  19. package/dist/src/core/accounts.d.ts +20 -0
  20. package/dist/src/core/accounts.js +131 -0
  21. package/dist/src/core/config.d.ts +61 -0
  22. package/dist/src/core/config.js +184 -0
  23. package/dist/src/core/create.d.ts +30 -0
  24. package/dist/src/core/create.js +126 -0
  25. package/dist/src/core/doctor.d.ts +41 -0
  26. package/dist/src/core/doctor.js +171 -0
  27. package/dist/src/core/errors.d.ts +12 -0
  28. package/dist/src/core/errors.js +14 -0
  29. package/dist/src/core/file-memo.d.ts +24 -0
  30. package/dist/src/core/file-memo.js +41 -0
  31. package/dist/src/core/fs-safe.d.ts +38 -0
  32. package/dist/src/core/fs-safe.js +205 -0
  33. package/dist/src/core/handoff.d.ts +41 -0
  34. package/dist/src/core/handoff.js +54 -0
  35. package/dist/src/core/identity.d.ts +23 -0
  36. package/dist/src/core/identity.js +79 -0
  37. package/dist/src/core/last-doctor.d.ts +36 -0
  38. package/dist/src/core/last-doctor.js +69 -0
  39. package/dist/src/core/launch.d.ts +37 -0
  40. package/dist/src/core/launch.js +108 -0
  41. package/dist/src/core/login-path.d.ts +23 -0
  42. package/dist/src/core/login-path.js +96 -0
  43. package/dist/src/core/paths.d.ts +13 -0
  44. package/dist/src/core/paths.js +32 -0
  45. package/dist/src/core/quota-levels.d.ts +19 -0
  46. package/dist/src/core/quota-levels.js +34 -0
  47. package/dist/src/core/resume.d.ts +9 -0
  48. package/dist/src/core/resume.js +15 -0
  49. package/dist/src/core/secret-file.d.ts +30 -0
  50. package/dist/src/core/secret-file.js +116 -0
  51. package/dist/src/core/share-meaning.d.ts +6 -0
  52. package/dist/src/core/share-meaning.js +32 -0
  53. package/dist/src/core/share-mode-names.d.ts +1 -0
  54. package/dist/src/core/share-mode-names.js +10 -0
  55. package/dist/src/core/share-modes.d.ts +24 -0
  56. package/dist/src/core/share-modes.js +405 -0
  57. package/dist/src/core/shell-init.d.ts +31 -0
  58. package/dist/src/core/shell-init.js +84 -0
  59. package/dist/src/core/skill-check.d.ts +11 -0
  60. package/dist/src/core/skill-check.js +57 -0
  61. package/dist/src/core/usage-days.d.ts +15 -0
  62. package/dist/src/core/usage-days.js +33 -0
  63. package/dist/src/core/version.d.ts +9 -0
  64. package/dist/src/core/version.js +46 -0
  65. package/dist/src/desktop/app.d.ts +60 -0
  66. package/dist/src/desktop/app.js +316 -0
  67. package/dist/src/desktop/icon.d.ts +16 -0
  68. package/dist/src/desktop/icon.js +174 -0
  69. package/dist/src/families/claude/index.d.ts +10 -0
  70. package/dist/src/families/claude/index.js +127 -0
  71. package/dist/src/families/claude/layout.d.ts +6 -0
  72. package/dist/src/families/claude/layout.js +5 -0
  73. package/dist/src/families/claude/quota-cache.d.ts +31 -0
  74. package/dist/src/families/claude/quota-cache.js +111 -0
  75. package/dist/src/families/claude/quota.d.ts +3 -0
  76. package/dist/src/families/claude/quota.js +53 -0
  77. package/dist/src/families/claude/usage.d.ts +22 -0
  78. package/dist/src/families/claude/usage.js +146 -0
  79. package/dist/src/families/codex/index.d.ts +5 -0
  80. package/dist/src/families/codex/index.js +70 -0
  81. package/dist/src/families/codex/rollout.d.ts +67 -0
  82. package/dist/src/families/codex/rollout.js +298 -0
  83. package/dist/src/families/grok/index.d.ts +8 -0
  84. package/dist/src/families/grok/index.js +82 -0
  85. package/dist/src/families/index.d.ts +3 -0
  86. package/dist/src/families/index.js +40 -0
  87. package/dist/src/families/logos.d.ts +21 -0
  88. package/dist/src/families/logos.js +26 -0
  89. package/dist/src/families/pi/index.d.ts +3 -0
  90. package/dist/src/families/pi/index.js +54 -0
  91. package/dist/src/families/shared/sessions.d.ts +9 -0
  92. package/dist/src/families/shared/sessions.js +70 -0
  93. package/dist/src/families/shared/toml.d.ts +5 -0
  94. package/dist/src/families/shared/toml.js +16 -0
  95. package/dist/src/index.d.ts +5 -0
  96. package/dist/src/index.js +4 -0
  97. package/dist/src/panel/background.d.ts +62 -0
  98. package/dist/src/panel/background.js +274 -0
  99. package/dist/src/panel/handler.d.ts +91 -0
  100. package/dist/src/panel/handler.js +396 -0
  101. package/dist/src/panel/i18n.d.ts +287 -0
  102. package/dist/src/panel/i18n.js +570 -0
  103. package/dist/src/panel/page-logic.d.ts +154 -0
  104. package/dist/src/panel/page-logic.js +326 -0
  105. package/dist/src/panel/page-script.d.ts +2 -0
  106. package/dist/src/panel/page-script.js +1633 -0
  107. package/dist/src/panel/page-styles.d.ts +7 -0
  108. package/dist/src/panel/page-styles.js +514 -0
  109. package/dist/src/panel/page.d.ts +20 -0
  110. package/dist/src/panel/page.js +96 -0
  111. package/dist/src/panel/server.d.ts +32 -0
  112. package/dist/src/panel/server.js +133 -0
  113. package/dist/src/panel/theme.d.ts +116 -0
  114. package/dist/src/panel/theme.js +132 -0
  115. package/dist/src/plugins/account-script.d.ts +4 -0
  116. package/dist/src/plugins/account-script.js +66 -0
  117. package/dist/src/plugins/bus.d.ts +33 -0
  118. package/dist/src/plugins/bus.js +84 -0
  119. package/dist/src/plugins/loader.d.ts +58 -0
  120. package/dist/src/plugins/loader.js +250 -0
  121. package/dist/src/quota/claude-statusline.d.ts +28 -0
  122. package/dist/src/quota/claude-statusline.js +301 -0
  123. package/dist/src/quota/statusline-tap.d.ts +11 -0
  124. package/dist/src/quota/statusline-tap.js +104 -0
  125. package/dist/src/runtime.d.ts +195 -0
  126. package/dist/src/runtime.js +424 -0
  127. package/dist/src/types.d.ts +285 -0
  128. package/dist/src/types.js +3 -0
  129. package/dist/testing/demo.d.ts +8 -0
  130. package/dist/testing/demo.js +50 -0
  131. package/dist/testing/index.d.ts +36 -0
  132. package/dist/testing/index.js +118 -0
  133. package/package.json +66 -4
  134. package/schemas/alias.json +102 -0
  135. package/schemas/app-install.json +74 -0
  136. package/schemas/app-uninstall.json +54 -0
  137. package/schemas/config.json +170 -0
  138. package/schemas/doctor.json +229 -0
  139. package/schemas/error.json +25 -0
  140. package/schemas/families.json +205 -0
  141. package/schemas/list.json +177 -0
  142. package/schemas/new.json +199 -0
  143. package/schemas/next.json +127 -0
  144. package/schemas/plugins.json +74 -0
  145. package/schemas/quota-setup.json +60 -0
  146. package/schemas/quota-teardown.json +30 -0
  147. package/schemas/quota.json +203 -0
  148. package/skills/sideby/SKILL.md +61 -0
  149. package/skills/sideby/references/accounts.md +31 -0
  150. package/skills/sideby/references/diagnose.md +47 -0
  151. package/skills/sideby/references/handoff.md +41 -0
  152. package/skills/sideby/references/quota.md +14 -0
  153. package/skills/sideby/references/setup.md +31 -0
  154. package/skills/sideby/references/sharing.md +17 -0
@@ -0,0 +1,84 @@
1
+ // The shell functions `sideby shell-init` prints, and the optional files (config `shellInitFile`) sideby keeps
2
+ // in sync with them whenever it adds an Account or an alias.
3
+ import { mkdir, readFile } from 'node:fs/promises';
4
+ import { dirname } from 'node:path';
5
+ import { ALIAS_NAME, aliasProblem, RESERVED_ALIASES } from './config.js';
6
+ import { realpathOrNull, statOrNull, writeFileAtomic } from './fs-safe.js';
7
+ import { expandUserPath } from './paths.js';
8
+ export const SHELLS = ['zsh', 'bash'];
9
+ /** First line of every generated script; an existing file without it is never overwritten. */
10
+ export const SHELL_INIT_HEADER = '# sideby shell-init: one function per account, plus aliases from the sideby config';
11
+ const quote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
12
+ const SHELL_VAR = /^[A-Za-z_][A-Za-z0-9_]*$/;
13
+ /** A shell `case` pattern for a session id: 36 characters with dashes where a UUID has them. */
14
+ const SESSION_ID_GLOB = '????????-????-????-????-????????????';
15
+ /**
16
+ * The Host-named function for one routed Family, or null when its names are not safe in a shell (spec §3.15). It
17
+ * starts sideby only when an argument looks like a session id, so a plain Host command costs nothing extra.
18
+ * `function name {` because zsh expands an alias of the same name in `name() {`.
19
+ */
20
+ function resumeRouteFunction(r) {
21
+ if (!ALIAS_NAME.test(r.bin) || RESERVED_ALIASES.has(r.bin) || !SHELL_VAR.test(r.selectVar))
22
+ return null;
23
+ const id = SESSION_ID_GLOB;
24
+ return [
25
+ `function ${r.bin} { local a; if [ -z "\${${r.selectVar}-}" ]; then for a in "$@"; do case $a in ${id}|--resume=${id})`,
26
+ ` command sideby resume ${quote(r.family)} -- "$@"; return;; esac; done; fi; command ${r.bin} "$@"; }`,
27
+ ].join('');
28
+ }
29
+ /**
30
+ * The script for one shell, ending in a newline: exactly what `sideby shell-init <shell>` prints. zsh and bash
31
+ * read the same function syntax, so the shell only names the file it goes to.
32
+ */
33
+ export function shellInitScript(accounts, aliases, routes = []) {
34
+ const lines = [SHELL_INIT_HEADER];
35
+ for (const acc of accounts)
36
+ lines.push(`sideby-${acc.family}-${acc.name}() { command sideby run ${quote(acc.ref)} -- "$@"; }`);
37
+ // An alias function runs the alias itself, so `sideby run` reads its Account and arguments from the config.
38
+ for (const alias of Object.keys(aliases ?? {}))
39
+ if (!aliasProblem(alias))
40
+ lines.push(`${alias}() { command sideby run ${quote(alias)} -- "$@"; }`);
41
+ // An Alias with the Host's name wins: it already starts a chosen Account.
42
+ for (const r of routes) {
43
+ const fn = aliases && Object.hasOwn(aliases, r.bin) ? null : resumeRouteFunction(r);
44
+ if (fn)
45
+ lines.push(fn);
46
+ }
47
+ return `${lines.join('\n')}\n`;
48
+ }
49
+ /**
50
+ * Writes `content` to the configured file atomically (temp file in the same directory, then rename), keeping its
51
+ * mode (0644 for a new file). Refuses a relative path and an existing file that sideby did not generate, so a
52
+ * mistyped `shellInitFile` can never replace a shell rc file.
53
+ */
54
+ export async function writeShellInitFile(shell, configured, home, content) {
55
+ const base = { shell, path: configured };
56
+ const expanded = expandUserPath(configured, home);
57
+ if (!expanded)
58
+ return { ...base, ok: false, message: `shellInitFile.${shell} must be an absolute or ~/ path` };
59
+ try {
60
+ // Follow a link (a dotfiles manager may link the file) and replace the real file.
61
+ const target = (await realpathOrNull(expanded)) ?? expanded;
62
+ const st = await statOrNull(target);
63
+ if (st && !st.isFile())
64
+ return { ...base, ok: false, message: `${configured} is not a regular file` };
65
+ if (st) {
66
+ const current = await readFile(target, 'utf8');
67
+ if (current === content)
68
+ return { ...base, ok: true, action: 'unchanged' };
69
+ if (current.trim() && !current.startsWith(SHELL_INIT_HEADER))
70
+ return {
71
+ ...base,
72
+ ok: false,
73
+ message: `${configured} was not written by sideby shell-init; move it aside or point shellInitFile.${shell} at another file`,
74
+ };
75
+ }
76
+ else
77
+ await mkdir(dirname(target), { recursive: true });
78
+ await writeFileAtomic(target, content, st ? st.mode & 0o777 : 0o644);
79
+ return { ...base, ok: true, action: 'written' };
80
+ }
81
+ catch (err) {
82
+ return { ...base, ok: false, message: `cannot write ${configured}: ${err.message}` };
83
+ }
84
+ }
@@ -0,0 +1,11 @@
1
+ import type { FamilyDef, Finding } from '../types.ts';
2
+ export declare const SKILL_INSTALL = "npx skills add Adonis0123/sideby -g";
3
+ /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
4
+ export declare function skillVersionOf(text: string): string | null;
5
+ /**
6
+ * Checks, for each given Family, the sideby skill its Host reads: `<main>/skills/sideby`, and for Codex also
7
+ * `~/.agents/skills/sideby` (where `npx skills add -g` installs and Codex reads user skills). A file is read
8
+ * once per Family, so the result for one Family never depends on which others are checked with it. Findings
9
+ * are general and belong to that Family's Main Account.
10
+ */
11
+ export declare function checkInstalledSkills(home: string, families: readonly FamilyDef[], version: string): Promise<Finding[]>;
@@ -0,0 +1,57 @@
1
+ // Doctor's look at an installed sideby Agent Skill (spec §3.11). Read-only: a skill written for another
2
+ // sideby version teaches commands this CLI may not have, so a version mismatch is a warning with the
3
+ // reinstall command. Not installed is not a problem.
4
+ import { readFile, realpath } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { mainDirOf } from './accounts.js';
7
+ import { tildify } from './paths.js';
8
+ export const SKILL_INSTALL = 'npx skills add Adonis0123/sideby -g';
9
+ /** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
10
+ export function skillVersionOf(text) {
11
+ const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
12
+ const meta = front && /^metadata:[ \t]*\r?\n((?:[ \t]+.*(?:\r?\n|$))*)/m.exec(front)?.[1];
13
+ return (meta && /^[ \t]+version:[ \t]*['"]?([^'"\s]+)/m.exec(meta)?.[1]) || null;
14
+ }
15
+ /**
16
+ * Checks, for each given Family, the sideby skill its Host reads: `<main>/skills/sideby`, and for Codex also
17
+ * `~/.agents/skills/sideby` (where `npx skills add -g` installs and Codex reads user skills). A file is read
18
+ * once per Family, so the result for one Family never depends on which others are checked with it. Findings
19
+ * are general and belong to that Family's Main Account.
20
+ */
21
+ export async function checkInstalledSkills(home, families, version) {
22
+ const places = families.flatMap((f) => [
23
+ { family: f, path: join(mainDirOf(f, home), 'skills', 'sideby', 'SKILL.md') },
24
+ ...(f.id === 'codex' ? [{ family: f, path: join(home, '.agents', 'skills', 'sideby', 'SKILL.md') }] : []),
25
+ ]);
26
+ const seen = new Set();
27
+ const out = [];
28
+ for (const { family, path } of places) {
29
+ let real;
30
+ let text;
31
+ try {
32
+ real = await realpath(path);
33
+ if (seen.has(`${family.id}\0${real}`))
34
+ continue;
35
+ text = await readFile(real, 'utf8');
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ seen.add(`${family.id}\0${real}`);
41
+ const found = skillVersionOf(text);
42
+ if (found === version)
43
+ continue;
44
+ const where = tildify(path, home);
45
+ out.push({
46
+ level: 'warn',
47
+ account: `${family.id}:main`,
48
+ item: where,
49
+ code: 'skill.outdated',
50
+ message: `the sideby skill at ${where} is ${found ? `for sideby ${found}` : 'from before skills had a version'}; this sideby is ${version}`,
51
+ hint: `update it with \`${SKILL_INSTALL}\`, so agents learn this version's commands`,
52
+ source: 'core',
53
+ fixable: false,
54
+ });
55
+ }
56
+ return out;
57
+ }
@@ -0,0 +1,15 @@
1
+ export interface DailyTokens {
2
+ /** Local calendar date, `YYYY-MM-DD`. */
3
+ date: string;
4
+ totalTokens: number;
5
+ }
6
+ /** Local calendar date of a time, `YYYY-MM-DD`. */
7
+ export declare function localDate(at: number | Date): string;
8
+ /**
9
+ * `days` buckets ending with the local day of `now`. `add` drops times outside those days, so the buckets can
10
+ * sum to less than a reader's rolling-window total (the oldest partial day falls outside them).
11
+ */
12
+ export declare function dailyBuckets(now: Date, days?: number): {
13
+ daily: DailyTokens[];
14
+ add(at: number, tokens: number): void;
15
+ };
@@ -0,0 +1,33 @@
1
+ // Per-day Usage buckets shared by the built-in readers: local calendar dates, oldest first, zero-filled.
2
+ import { USAGE_DAYS } from './quota-levels.js';
3
+ const pad = (n) => String(n).padStart(2, '0');
4
+ /** Local calendar date of a time, `YYYY-MM-DD`. */
5
+ export function localDate(at) {
6
+ const d = at instanceof Date ? at : new Date(at);
7
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
8
+ }
9
+ /**
10
+ * `days` buckets ending with the local day of `now`. `add` drops times outside those days, so the buckets can
11
+ * sum to less than a reader's rolling-window total (the oldest partial day falls outside them).
12
+ */
13
+ export function dailyBuckets(now, days = USAGE_DAYS) {
14
+ const daily = [];
15
+ const index = new Map();
16
+ for (let i = days - 1; i >= 0; i--) {
17
+ // Built from calendar parts, so a daylight-saving day of 23 or 25 hours still yields one entry per date.
18
+ const date = localDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - i));
19
+ const bucket = { date, totalTokens: 0 };
20
+ daily.push(bucket);
21
+ index.set(date, bucket);
22
+ }
23
+ return {
24
+ daily,
25
+ add(at, tokens) {
26
+ if (!Number.isFinite(at) || tokens <= 0)
27
+ return;
28
+ const bucket = index.get(localDate(at));
29
+ if (bucket)
30
+ bucket.totalTokens += tokens;
31
+ },
32
+ };
33
+ }
@@ -0,0 +1,9 @@
1
+ /** The sideby package version, read once from package.json (two levels up from src/, three from dist/src/). */
2
+ export declare function packageVersion(): string;
3
+ /**
4
+ * Identity of the code that is running: the version plus the id `pnpm build` writes to `dist/build.json`, so a
5
+ * rebuilt or reinstalled package differs even when its version does not. Long-running Panels compare it to
6
+ * notice new code. From source there is no build file (`<version>+source`). `SIDEBY_BUILD_ID` overrides the
7
+ * id for tests.
8
+ */
9
+ export declare function buildId(env?: NodeJS.ProcessEnv): string;
@@ -0,0 +1,46 @@
1
+ import { readFileSync } from 'node:fs';
2
+ let cached;
3
+ /** The sideby package version, read once from package.json (two levels up from src/, three from dist/src/). */
4
+ export function packageVersion() {
5
+ if (cached)
6
+ return cached;
7
+ for (const rel of ['../../package.json', '../../../package.json']) {
8
+ try {
9
+ const pkg = JSON.parse(readFileSync(new URL(rel, import.meta.url), 'utf8'));
10
+ if (pkg.name === 'sideby' && pkg.version) {
11
+ cached = pkg.version;
12
+ return cached;
13
+ }
14
+ }
15
+ catch {
16
+ // try the next layout
17
+ }
18
+ }
19
+ cached = '0.0.0';
20
+ return cached;
21
+ }
22
+ let cachedBuild;
23
+ /**
24
+ * Identity of the code that is running: the version plus the id `pnpm build` writes to `dist/build.json`, so a
25
+ * rebuilt or reinstalled package differs even when its version does not. Long-running Panels compare it to
26
+ * notice new code. From source there is no build file (`<version>+source`). `SIDEBY_BUILD_ID` overrides the
27
+ * id for tests.
28
+ */
29
+ export function buildId(env = process.env) {
30
+ if (env.SIDEBY_BUILD_ID)
31
+ return `${packageVersion()}+${env.SIDEBY_BUILD_ID}`;
32
+ if (cachedBuild)
33
+ return cachedBuild;
34
+ let id = 'source';
35
+ try {
36
+ // dist/src/core/version.js → dist/build.json; from src/ the file does not exist.
37
+ const data = JSON.parse(readFileSync(new URL('../../build.json', import.meta.url), 'utf8'));
38
+ if (typeof data.id === 'string' && data.id)
39
+ id = data.id;
40
+ }
41
+ catch {
42
+ // running from source
43
+ }
44
+ cachedBuild = `${packageVersion()}+${id}`;
45
+ return cachedBuild;
46
+ }
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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;