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,79 @@
1
+ // Who an Account is signed in as: the email (and for Claude the organization) from the Host's own login file
2
+ // (ADR-0003). Only these identity fields are read out of the file; tokens and every other field are dropped as soon
3
+ // as the file is parsed, and are never stored, logged or returned. Each file's pick is remembered until the file
4
+ // changes (core/file-memo.ts).
5
+ import { readFile, stat } from 'node:fs/promises';
6
+ import { FileMemo } from './file-memo.js';
7
+ const MAX_EMAIL = 254;
8
+ const MAX_ORG = 120;
9
+ // One address, no spaces or control characters; the shape only, not a deliverability check.
10
+ const EMAIL = /^[^\s@\p{Cc}]+@[^\s@\p{Cc}]+\.[^\s@\p{Cc}]+$/u;
11
+ /** The email when `v` looks like one address, else undefined. */
12
+ export function cleanEmail(v) {
13
+ return typeof v === 'string' && v.length <= MAX_EMAIL && EMAIL.test(v) ? v : undefined;
14
+ }
15
+ /** A short single-line organization name, else undefined. */
16
+ export function cleanOrg(v) {
17
+ if (typeof v !== 'string')
18
+ return undefined;
19
+ const s = v.trim();
20
+ return s && s.length <= MAX_ORG && !/\p{Cc}/u.test(s) ? s : undefined;
21
+ }
22
+ /** `{ email?, org? }` without empty fields, or undefined when both are missing. */
23
+ export function identityOf(email, org) {
24
+ const e = cleanEmail(email);
25
+ const o = cleanOrg(org);
26
+ if (!e && !o)
27
+ return undefined;
28
+ return { ...(e ? { email: e } : {}), ...(o ? { org: o } : {}) };
29
+ }
30
+ /**
31
+ * The email claim of a JWT such as Codex's `id_token`: the payload segment is base64url-decoded and parsed, and only
32
+ * `email` is kept. The signature is not checked (the token is the Host's own file, read for display only); the
33
+ * token itself and every other claim are dropped.
34
+ */
35
+ export function jwtEmail(token) {
36
+ if (typeof token !== 'string')
37
+ return undefined;
38
+ const parts = token.split('.');
39
+ if (parts.length !== 3 || !parts[1])
40
+ return undefined;
41
+ try {
42
+ const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
43
+ return typeof claims === 'object' && claims !== null
44
+ ? cleanEmail(claims.email)
45
+ : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ const memo = new FileMemo();
52
+ /** Counters of the identity cache, for tests. */
53
+ export const identityMemoStats = memo.stats;
54
+ /**
55
+ * Reads one JSON login file and keeps only what `pick` returns from it. Undefined when the file is missing, is not
56
+ * JSON or holds no identity. `pick` must return identity fields only (identityOf builds one).
57
+ */
58
+ export async function identityFromJsonFile(path, pick) {
59
+ let st;
60
+ try {
61
+ st = await stat(path);
62
+ }
63
+ catch {
64
+ return undefined;
65
+ }
66
+ if (!st.isFile())
67
+ return undefined;
68
+ const pass = memo.pass(path);
69
+ const value = await pass.get(path, st, async () => {
70
+ try {
71
+ return pick(JSON.parse(await readFile(path, 'utf8'))) ?? null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ });
77
+ pass.done();
78
+ return value ?? undefined;
79
+ }
@@ -0,0 +1,36 @@
1
+ import type { Finding } from '../types.ts';
2
+ import type { DoctorReport } from './doctor.ts';
3
+ export type StoredFinding = Omit<Finding, 'fix'>;
4
+ export interface HistoryEntry {
5
+ at: string;
6
+ findings: StoredFinding[];
7
+ }
8
+ export interface DoctorHistory {
9
+ version: 2;
10
+ /** Latest result per Account ref; an Account absent here was never checked. */
11
+ accounts: Record<string, HistoryEntry>;
12
+ /** Latest Family-level findings (for example a missing Main Account), per Family id. */
13
+ general: Record<string, HistoryEntry>;
14
+ }
15
+ /** What one Doctor run covered: every Family, one Family, or one Account. */
16
+ export type DoctorScope = {
17
+ kind: 'all';
18
+ } | {
19
+ kind: 'family';
20
+ family: string;
21
+ } | {
22
+ kind: 'account';
23
+ ref: string;
24
+ };
25
+ /**
26
+ * Pure merge: Accounts inside the scope are replaced by this run (and dropped when the run no longer
27
+ * reports them: deleted, ignored, no longer an Account); Accounts outside the scope are kept. Family-level
28
+ * findings are replaced for every Family the run touched, even with an empty list.
29
+ */
30
+ export declare function mergeDoctorHistory(prev: DoctorHistory | undefined, report: DoctorReport, scope: DoctorScope, at: Date): DoctorHistory;
31
+ /** Every finding in the history, Family-level first. */
32
+ export declare function historyFindings(h: DoctorHistory): StoredFinding[];
33
+ /** Older formats (without per-Account entries) count as no history. */
34
+ export declare function readDoctorHistory(stateDir: string): Promise<DoctorHistory | undefined>;
35
+ /** Best effort: recording a check never fails the check itself. */
36
+ export declare function writeDoctorHistory(stateDir: string, history: DoctorHistory): Promise<void>;
@@ -0,0 +1,69 @@
1
+ // Doctor history: the latest result per Account (and per Family for Family-level findings), kept in
2
+ // sideby's state directory so the Panel can tell "healthy" from "not checked". A run only replaces what
3
+ // it covered, so checking one Account never makes the others look healthy.
4
+ import { mkdir, readFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { writeFileAtomic } from './fs-safe.js';
7
+ const FILE = 'last-doctor.json';
8
+ const strip = ({ fix: _fix, ...f }) => f;
9
+ const familyOfRef = (ref) => ref.slice(0, ref.indexOf(':'));
10
+ function inScope(scope, ref) {
11
+ if (scope.kind === 'all')
12
+ return true;
13
+ if (scope.kind === 'family')
14
+ return familyOfRef(ref) === scope.family;
15
+ return ref === scope.ref;
16
+ }
17
+ /**
18
+ * Pure merge: Accounts inside the scope are replaced by this run (and dropped when the run no longer
19
+ * reports them: deleted, ignored, no longer an Account); Accounts outside the scope are kept. Family-level
20
+ * findings are replaced for every Family the run touched, even with an empty list.
21
+ */
22
+ export function mergeDoctorHistory(prev, report, scope, at) {
23
+ const when = at.toISOString();
24
+ const accounts = {};
25
+ for (const [ref, entry] of Object.entries(prev?.accounts ?? {}))
26
+ if (!inScope(scope, ref))
27
+ accounts[ref] = entry;
28
+ for (const a of report.accounts)
29
+ accounts[a.ref] = { at: when, findings: a.findings.map(strip) };
30
+ const touched = new Set(scope.kind === 'all'
31
+ ? [...report.accounts.map((a) => a.family), ...report.general.map((f) => familyOfRef(f.account))]
32
+ : [scope.kind === 'family' ? scope.family : familyOfRef(scope.ref)]);
33
+ const general = {};
34
+ for (const [family, entry] of Object.entries(prev?.general ?? {}))
35
+ if (scope.kind !== 'all' && !touched.has(family))
36
+ general[family] = entry;
37
+ for (const family of touched)
38
+ general[family] = {
39
+ at: when,
40
+ findings: report.general.filter((f) => familyOfRef(f.account) === family).map(strip),
41
+ };
42
+ return { version: 2, accounts, general };
43
+ }
44
+ /** Every finding in the history, Family-level first. */
45
+ export function historyFindings(h) {
46
+ return [...Object.values(h.general), ...Object.values(h.accounts)].flatMap((e) => e.findings);
47
+ }
48
+ /** Older formats (without per-Account entries) count as no history. */
49
+ export async function readDoctorHistory(stateDir) {
50
+ try {
51
+ const data = JSON.parse(await readFile(join(stateDir, FILE), 'utf8'));
52
+ if (data?.version !== 2 || typeof data.accounts !== 'object' || typeof data.general !== 'object')
53
+ return undefined;
54
+ return data;
55
+ }
56
+ catch {
57
+ return undefined;
58
+ }
59
+ }
60
+ /** Best effort: recording a check never fails the check itself. */
61
+ export async function writeDoctorHistory(stateDir, history) {
62
+ try {
63
+ await mkdir(stateDir, { recursive: true, mode: 0o700 });
64
+ await writeFileAtomic(join(stateDir, FILE), `${JSON.stringify(history, null, 2)}\n`);
65
+ }
66
+ catch {
67
+ // ignore
68
+ }
69
+ }
@@ -0,0 +1,37 @@
1
+ import type { HookBus } from '../plugins/bus.ts';
2
+ import type { Account, Env, FamilyDef } from '../types.ts';
3
+ export interface PreparedLaunch {
4
+ account: Account;
5
+ family: FamilyDef;
6
+ bin: string;
7
+ args: string[];
8
+ env: Env;
9
+ /** Printed before launching when sign-in happens inside the Host. */
10
+ notice?: string;
11
+ }
12
+ /** What `runHost` needs: a prepared Account launch, or the Host run unchanged (`sideby resume`). */
13
+ export type HostLaunch = Pick<PreparedLaunch, 'family' | 'bin' | 'args' | 'env'>;
14
+ /** Finds an executable on PATH; returns null when the Host is not installed. */
15
+ export declare function which(bin: string, env: Env): Promise<string | null>;
16
+ /**
17
+ * Builds the Host's environment and arguments (spec §3.5):
18
+ * copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
19
+ * then let `launch.before` hooks adjust or abort. Never touches the caller's environment.
20
+ */
21
+ export declare function prepareLaunch(opts: {
22
+ account: Account;
23
+ family: FamilyDef;
24
+ baseEnv: Env;
25
+ accountArgs: readonly string[];
26
+ userArgs: readonly string[];
27
+ command: 'run' | 'login';
28
+ bus: HookBus;
29
+ }): Promise<PreparedLaunch>;
30
+ /**
31
+ * Runs the Host with the terminal attached and returns sideby's exit code (spec §3.5):
32
+ * the Host's own code, 128 + signal number when a signal ended it, 1 when it could not start.
33
+ * While the Host runs, sideby ignores SIGINT/SIGQUIT (the terminal delivers them to the whole foreground
34
+ * process group, so the Host receives them directly) and forwards SIGTERM/SIGHUP once. SIGTSTP keeps its
35
+ * default action so Ctrl-Z suspends sideby together with the Host and `fg` resumes both.
36
+ */
37
+ export declare function runHost(p: HostLaunch): Promise<number>;
@@ -0,0 +1,108 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { access, constants } from 'node:fs/promises';
3
+ import { constants as osConstants } from 'node:os';
4
+ import { delimiter, join } from 'node:path';
5
+ import { readSecretFile } from './secret-file.js';
6
+ /** Finds an executable on PATH; returns null when the Host is not installed. */
7
+ export async function which(bin, env) {
8
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
9
+ if (!dir)
10
+ continue;
11
+ const p = join(dir, bin);
12
+ try {
13
+ await access(p, constants.X_OK);
14
+ return p;
15
+ }
16
+ catch {
17
+ // keep looking
18
+ }
19
+ }
20
+ return null;
21
+ }
22
+ /**
23
+ * Builds the Host's environment and arguments (spec §3.5):
24
+ * copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
25
+ * then let `launch.before` hooks adjust or abort. Never touches the caller's environment.
26
+ */
27
+ export async function prepareLaunch(opts) {
28
+ const { account, family } = opts;
29
+ const env = { ...opts.baseEnv };
30
+ for (const v of family.hijackVars)
31
+ delete env[v];
32
+ delete env[family.selectVar];
33
+ if (account.secretFile)
34
+ Object.assign(env, await readSecretFile(account.secretFile, opts.baseEnv));
35
+ if (!account.isMain)
36
+ env[family.selectVar] = account.dir;
37
+ let args;
38
+ let notice;
39
+ if (opts.command === 'login') {
40
+ // Family arguments still apply: Codex must store a non-main login in the Account's own file.
41
+ const base = family.defaultArgs?.(account, []) ?? [];
42
+ if (family.login.args)
43
+ args = [...base, ...family.login.args];
44
+ else {
45
+ args = base;
46
+ notice = family.login.hint;
47
+ }
48
+ }
49
+ else {
50
+ args = [...(family.defaultArgs?.(account, opts.userArgs) ?? []), ...opts.accountArgs, ...opts.userArgs];
51
+ }
52
+ await opts.bus.run('launch.before', family.id, {
53
+ account,
54
+ family,
55
+ env,
56
+ args,
57
+ config: {},
58
+ command: opts.command,
59
+ });
60
+ return { account, family, bin: family.bin, args, env, ...(notice ? { notice } : {}) };
61
+ }
62
+ /**
63
+ * Runs the Host with the terminal attached and returns sideby's exit code (spec §3.5):
64
+ * the Host's own code, 128 + signal number when a signal ended it, 1 when it could not start.
65
+ * While the Host runs, sideby ignores SIGINT/SIGQUIT (the terminal delivers them to the whole foreground
66
+ * process group, so the Host receives them directly) and forwards SIGTERM/SIGHUP once. SIGTSTP keeps its
67
+ * default action so Ctrl-Z suspends sideby together with the Host and `fg` resumes both.
68
+ */
69
+ export function runHost(p) {
70
+ return new Promise((resolve) => {
71
+ const child = spawn(p.bin, p.args, { stdio: 'inherit', env: p.env });
72
+ const ignore = () => { };
73
+ const forwarded = new Set();
74
+ const forward = (sig) => () => {
75
+ if (forwarded.has(sig) || child.exitCode !== null || child.signalCode !== null)
76
+ return;
77
+ forwarded.add(sig);
78
+ child.kill(sig);
79
+ };
80
+ const handlers = [
81
+ ['SIGINT', ignore],
82
+ ['SIGQUIT', ignore],
83
+ ['SIGTERM', forward('SIGTERM')],
84
+ ['SIGHUP', forward('SIGHUP')],
85
+ ];
86
+ for (const [s, h] of handlers)
87
+ process.on(s, h);
88
+ const cleanup = () => {
89
+ for (const [s, h] of handlers)
90
+ process.off(s, h);
91
+ };
92
+ child.once('error', (err) => {
93
+ cleanup();
94
+ const msg = err.code === 'ENOENT'
95
+ ? `${p.family.title} is not installed (\`${p.bin}\` not found on PATH); install it: ${p.family.installUrl}`
96
+ : `could not start ${p.bin}: ${err.message}`;
97
+ process.stderr.write(`sideby: ${msg}\n`);
98
+ resolve(1);
99
+ });
100
+ child.once('exit', (code, signal) => {
101
+ cleanup();
102
+ if (signal)
103
+ resolve(128 + (osConstants.signals[signal] ?? 0));
104
+ else
105
+ resolve(code ?? 1);
106
+ });
107
+ });
108
+ }
@@ -0,0 +1,23 @@
1
+ import type { Env } from '../types.ts';
2
+ export declare const LOGIN_PATH_TIMEOUT_MS = 5000;
3
+ export interface LoginPathOptions {
4
+ /** Shell to ask; defaults to `$SHELL`, then the account's shell, then `/bin/sh`. */
5
+ shell?: string;
6
+ timeoutMs?: number;
7
+ }
8
+ export interface LoginPathResult {
9
+ path: string;
10
+ /** `login-shell` when the shell answered, `fallback` when its answer was unusable and `path` is the current one. */
11
+ source: 'login-shell' | 'fallback';
12
+ /** Why the fallback was used. */
13
+ reason?: string;
14
+ }
15
+ /** Joins PATH lists, first occurrence wins, dropping empty and relative entries. */
16
+ export declare function mergePaths(...lists: (string | undefined)[]): string;
17
+ /** Reads the PATH between the markers; rc files may print anything before or after them. */
18
+ export declare function parseMarkedPath(stdout: string): string | undefined;
19
+ /**
20
+ * Asks the login shell (`$SHELL -ilc`) for its PATH and merges it in front of `env.PATH`.
21
+ * Never throws: on a timeout, a missing shell or unreadable output it returns the current PATH.
22
+ */
23
+ export declare function loginShellPath(env: Env, opts?: LoginPathOptions): Promise<LoginPathResult>;
@@ -0,0 +1,96 @@
1
+ // PATH as the user's login shell sets it. An app started from Finder, a dock or launchd gets a minimal PATH, so
2
+ // Hosts installed under ~/.local/bin, ~/.grok/bin or a version manager would look "not installed".
3
+ import { spawn } from 'node:child_process';
4
+ import { userInfo } from 'node:os';
5
+ import { delimiter, isAbsolute } from 'node:path';
6
+ const BEGIN = '__SIDEBY_PATH_BEGIN__';
7
+ const END = '__SIDEBY_PATH_END__';
8
+ export const LOGIN_PATH_TIMEOUT_MS = 5000;
9
+ /** Joins PATH lists, first occurrence wins, dropping empty and relative entries. */
10
+ export function mergePaths(...lists) {
11
+ const seen = new Set();
12
+ for (const list of lists)
13
+ for (const dir of (list ?? '').split(delimiter))
14
+ if (dir && isAbsolute(dir) && !seen.has(dir))
15
+ seen.add(dir);
16
+ return [...seen].join(delimiter);
17
+ }
18
+ /** Reads the PATH between the markers; rc files may print anything before or after them. */
19
+ export function parseMarkedPath(stdout) {
20
+ const end = stdout.lastIndexOf(END);
21
+ if (end === -1)
22
+ return undefined;
23
+ const begin = stdout.lastIndexOf(BEGIN, end);
24
+ if (begin === -1)
25
+ return undefined;
26
+ const value = stdout.slice(begin + BEGIN.length, end).trim();
27
+ if (!value || /[\r\n]/.test(value))
28
+ return undefined;
29
+ const dirs = value.split(delimiter).filter((d) => d && isAbsolute(d));
30
+ return dirs.length ? dirs.join(delimiter) : undefined;
31
+ }
32
+ function defaultShell(env) {
33
+ if (env.SHELL && isAbsolute(env.SHELL))
34
+ return env.SHELL;
35
+ try {
36
+ const s = userInfo().shell;
37
+ if (s && isAbsolute(s))
38
+ return s;
39
+ }
40
+ catch { }
41
+ return '/bin/sh';
42
+ }
43
+ /**
44
+ * Asks the login shell (`$SHELL -ilc`) for its PATH and merges it in front of `env.PATH`.
45
+ * Never throws: on a timeout, a missing shell or unreadable output it returns the current PATH.
46
+ */
47
+ export function loginShellPath(env, opts = {}) {
48
+ const current = env.PATH ?? '';
49
+ const shell = opts.shell ?? defaultShell(env);
50
+ const timeoutMs = opts.timeoutMs ?? LOGIN_PATH_TIMEOUT_MS;
51
+ // `printenv` prints the exported PATH the same way in sh, bash, zsh and fish (where $PATH is a list).
52
+ const script = `echo ${BEGIN}; printenv PATH; echo ${END}`;
53
+ return new Promise((resolve) => {
54
+ let done = false;
55
+ let out = '';
56
+ const finish = (reason) => {
57
+ if (done)
58
+ return;
59
+ done = true;
60
+ clearTimeout(timer);
61
+ // A shell that printed its PATH but hangs on exit (an rc file started something) still counts.
62
+ const found = parseMarkedPath(out);
63
+ if (found)
64
+ resolve({ path: mergePaths(found, current), source: 'login-shell' });
65
+ else
66
+ resolve({ path: current, source: 'fallback', reason: reason ?? 'no PATH in the shell output' });
67
+ };
68
+ let child;
69
+ try {
70
+ child = spawn(shell, ['-ilc', script], {
71
+ env: env,
72
+ stdio: ['ignore', 'pipe', 'ignore'],
73
+ // Own process group, so a timeout also stops whatever the rc files started.
74
+ detached: true,
75
+ });
76
+ }
77
+ catch (err) {
78
+ resolve({ path: current, source: 'fallback', reason: err instanceof Error ? err.message : String(err) });
79
+ return;
80
+ }
81
+ const timer = setTimeout(() => {
82
+ try {
83
+ if (child.pid)
84
+ process.kill(-child.pid, 'SIGKILL');
85
+ }
86
+ catch { }
87
+ finish(`${shell} did not answer within ${timeoutMs} ms`);
88
+ }, timeoutMs);
89
+ child.stdout?.on('data', (d) => {
90
+ if (out.length < 1_000_000)
91
+ out += d;
92
+ });
93
+ child.once('error', (err) => finish(err.message));
94
+ child.once('close', () => finish());
95
+ });
96
+ }
@@ -0,0 +1,13 @@
1
+ import type { Env } from '../types.ts';
2
+ export interface Paths {
3
+ home: string;
4
+ configDir: string;
5
+ stateDir: string;
6
+ configFile: string;
7
+ userPluginsDir: string;
8
+ }
9
+ export declare function resolvePaths(env?: Env): Paths;
10
+ /** Expands a leading `~` or `~/`; returns undefined for relative paths so callers can reject them. */
11
+ export declare function expandUserPath(p: string, home: string): string | undefined;
12
+ /** Replaces the HOME prefix with `~` for user-facing output. */
13
+ export declare function tildify(p: string, home: string): string;
@@ -0,0 +1,32 @@
1
+ import { homedir } from 'node:os';
2
+ import { isAbsolute, join } from 'node:path';
3
+ export function resolvePaths(env = process.env) {
4
+ const home = env.HOME || homedir();
5
+ const configBase = absOr(env.XDG_CONFIG_HOME, join(home, '.config'));
6
+ const stateBase = absOr(env.XDG_STATE_HOME, join(home, '.local', 'state'));
7
+ const configDir = join(configBase, 'sideby');
8
+ return {
9
+ home,
10
+ configDir,
11
+ stateDir: join(stateBase, 'sideby'),
12
+ configFile: join(configDir, 'config.json'),
13
+ userPluginsDir: join(configDir, 'plugins'),
14
+ };
15
+ }
16
+ /** Expands a leading `~` or `~/`; returns undefined for relative paths so callers can reject them. */
17
+ export function expandUserPath(p, home) {
18
+ if (p === '~')
19
+ return home;
20
+ if (p.startsWith('~/'))
21
+ return join(home, p.slice(2));
22
+ return isAbsolute(p) ? p : undefined;
23
+ }
24
+ /** Replaces the HOME prefix with `~` for user-facing output. */
25
+ export function tildify(p, home) {
26
+ if (p === home)
27
+ return '~';
28
+ return p.startsWith(`${home}/`) ? `~${p.slice(home.length)}` : p;
29
+ }
30
+ function absOr(value, fallback) {
31
+ return value && isAbsolute(value) ? value : fallback;
32
+ }
@@ -0,0 +1,19 @@
1
+ import type { QuotaResult } from '../types.ts';
2
+ /**
3
+ * How full a Quota window must be before the CLI and the Panel warn about it, and the Usage window both
4
+ * report. One rule for both surfaces: the Panel interpolates these numbers into its inline script.
5
+ */
6
+ export declare const QUOTA_FAIL_PERCENT = 85;
7
+ export declare const QUOTA_WARN_PERCENT = 60;
8
+ /** Days of Usage every Family reports. */
9
+ export declare const USAGE_DAYS = 7;
10
+ export type QuotaLevel = 'ok' | 'warn' | 'fail';
11
+ export declare function quotaLevel(usedPercent: number): QuotaLevel;
12
+ /** True once a window's reset time has passed; an unreadable time never counts as reset. */
13
+ export declare function windowReset(resetsAt: string, now: Date): boolean;
14
+ /**
15
+ * Quota Pressure: the used percent of the fullest window that has not reset yet (0 when all have reset),
16
+ * or null when the Account has no Quota. The Panel keeps its own copy (`quotaPressure` in page-logic.ts,
17
+ * -1 for null) because page-logic helpers must be self-contained; a test keeps the two equal.
18
+ */
19
+ export declare function quotaPressure(quota: QuotaResult | undefined, now: Date): number | null;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * How full a Quota window must be before the CLI and the Panel warn about it, and the Usage window both
3
+ * report. One rule for both surfaces: the Panel interpolates these numbers into its inline script.
4
+ */
5
+ export const QUOTA_FAIL_PERCENT = 85;
6
+ export const QUOTA_WARN_PERCENT = 60;
7
+ /** Days of Usage every Family reports. */
8
+ export const USAGE_DAYS = 7;
9
+ export function quotaLevel(usedPercent) {
10
+ if (usedPercent >= QUOTA_FAIL_PERCENT)
11
+ return 'fail';
12
+ if (usedPercent >= QUOTA_WARN_PERCENT)
13
+ return 'warn';
14
+ return 'ok';
15
+ }
16
+ /** True once a window's reset time has passed; an unreadable time never counts as reset. */
17
+ export function windowReset(resetsAt, now) {
18
+ const t = Date.parse(resetsAt);
19
+ return Number.isFinite(t) && t <= now.getTime();
20
+ }
21
+ /**
22
+ * Quota Pressure: the used percent of the fullest window that has not reset yet (0 when all have reset),
23
+ * or null when the Account has no Quota. The Panel keeps its own copy (`quotaPressure` in page-logic.ts,
24
+ * -1 for null) because page-logic helpers must be self-contained; a test keeps the two equal.
25
+ */
26
+ export function quotaPressure(quota, now) {
27
+ if (quota?.status !== 'ok' || quota.windows.length === 0)
28
+ return null;
29
+ let max = 0;
30
+ for (const w of quota.windows)
31
+ if (!windowReset(w.resetsAt, now))
32
+ max = Math.max(max, Math.min(100, Number(w.usedPercent) || 0));
33
+ return max;
34
+ }
@@ -0,0 +1,9 @@
1
+ import type { Account, FamilyDef } from '../types.ts';
2
+ export interface ResumeRoute {
3
+ /** The session the Host arguments resume by id; absent when they resume none. */
4
+ sessionId?: string;
5
+ /** The Account that last wrote that session; absent when no Account holds it. */
6
+ account?: Account;
7
+ }
8
+ /** Asks every Account of the Family for the session and picks the one that wrote it last. */
9
+ export declare function findResumeRoute(family: FamilyDef, accounts: readonly Account[], args: readonly string[]): Promise<ResumeRoute>;
@@ -0,0 +1,15 @@
1
+ /** Asks every Account of the Family for the session and picks the one that wrote it last. */
2
+ export async function findResumeRoute(family, accounts, args) {
3
+ const sessionId = family.resumedSession?.(args);
4
+ if (!sessionId || !family.sessionWrittenAt)
5
+ return {};
6
+ const own = accounts.filter((a) => a.family === family.id);
7
+ const written = await Promise.all(own.map((a) => family.sessionWrittenAt(a, sessionId).catch(() => undefined)));
8
+ let best;
9
+ own.forEach((account, i) => {
10
+ const at = written[i];
11
+ if (at !== undefined && (!best || at > best.at))
12
+ best = { account, at };
13
+ });
14
+ return best ? { sessionId, account: best.account } : { sessionId };
15
+ }
@@ -0,0 +1,30 @@
1
+ import { UserError } from './errors.ts';
2
+ export declare class SecretFileError extends UserError {
3
+ }
4
+ /** Non-secret locations a Secret File may reference from the environment, such as `$HOME/bin/notify`. */
5
+ export declare const SECRET_FILE_ENV_REFS: readonly ['HOME', 'USER', 'LOGNAME', 'TMPDIR', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME', 'XDG_CACHE_HOME'];
6
+ /**
7
+ * Parses the dotenv subset sideby supports: KEY=VALUE, optional `export `, quotes and comments.
8
+ * `$NAME` and `${NAME}` in unquoted or double-quoted values expand to a key defined earlier in the
9
+ * same file, else to one of SECRET_FILE_ENV_REFS from `env`; no other variable is read and nothing is
10
+ * executed. Errors name the line, never its content.
11
+ */
12
+ export declare function parseSecretFile(text: string, file: string, env?: Readonly<Record<string, string | undefined>>): Record<string, string>;
13
+ export type SecretFileState = {
14
+ kind: 'ok';
15
+ } | {
16
+ kind: 'missing';
17
+ } | {
18
+ kind: 'not-file';
19
+ } | {
20
+ kind: 'mode';
21
+ mode: number;
22
+ };
23
+ /**
24
+ * The one Secret File rule, used by Launch and by Doctor: the Account's own regular file (never a link)
25
+ * with mode 600. Only the file's type and mode are inspected, never its contents.
26
+ */
27
+ export declare function inspectSecretFile(file: string): Promise<SecretFileState>;
28
+ /** Reads a Secret File after inspectSecretFile accepts it. */
29
+ export declare function readSecretFile(file: string, env?: Readonly<Record<string, string | undefined>>): Promise<Record<string, string>>;
30
+ export declare function secretFileTemplate(vars: readonly string[], familyTitle: string): string;