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.
Files changed (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +453 -2
  3. package/README.zh-CN.md +455 -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 +37 -0
  8. package/dist/src/cli/commands.js +487 -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 +224 -0
  13. package/dist/src/cli/main.d.ts +2 -0
  14. package/dist/src/cli/main.js +186 -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 +18 -0
  20. package/dist/src/core/accounts.js +116 -0
  21. package/dist/src/core/config.d.ts +60 -0
  22. package/dist/src/core/config.js +181 -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 +35 -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/secret-file.d.ts +30 -0
  48. package/dist/src/core/secret-file.js +116 -0
  49. package/dist/src/core/share-mode-names.d.ts +1 -0
  50. package/dist/src/core/share-mode-names.js +10 -0
  51. package/dist/src/core/share-modes.d.ts +24 -0
  52. package/dist/src/core/share-modes.js +405 -0
  53. package/dist/src/core/shell-init.d.ts +25 -0
  54. package/dist/src/core/shell-init.js +61 -0
  55. package/dist/src/core/skill-check.d.ts +11 -0
  56. package/dist/src/core/skill-check.js +57 -0
  57. package/dist/src/core/usage-days.d.ts +15 -0
  58. package/dist/src/core/usage-days.js +33 -0
  59. package/dist/src/core/version.d.ts +9 -0
  60. package/dist/src/core/version.js +46 -0
  61. package/dist/src/desktop/app.d.ts +60 -0
  62. package/dist/src/desktop/app.js +316 -0
  63. package/dist/src/desktop/icon.d.ts +16 -0
  64. package/dist/src/desktop/icon.js +174 -0
  65. package/dist/src/families/claude/index.d.ts +10 -0
  66. package/dist/src/families/claude/index.js +124 -0
  67. package/dist/src/families/claude/layout.d.ts +6 -0
  68. package/dist/src/families/claude/layout.js +5 -0
  69. package/dist/src/families/claude/quota-cache.d.ts +31 -0
  70. package/dist/src/families/claude/quota-cache.js +111 -0
  71. package/dist/src/families/claude/quota.d.ts +3 -0
  72. package/dist/src/families/claude/quota.js +53 -0
  73. package/dist/src/families/claude/usage.d.ts +22 -0
  74. package/dist/src/families/claude/usage.js +146 -0
  75. package/dist/src/families/codex/index.d.ts +5 -0
  76. package/dist/src/families/codex/index.js +67 -0
  77. package/dist/src/families/codex/rollout.d.ts +67 -0
  78. package/dist/src/families/codex/rollout.js +298 -0
  79. package/dist/src/families/grok/index.d.ts +8 -0
  80. package/dist/src/families/grok/index.js +78 -0
  81. package/dist/src/families/index.d.ts +3 -0
  82. package/dist/src/families/index.js +40 -0
  83. package/dist/src/families/logos.d.ts +21 -0
  84. package/dist/src/families/logos.js +26 -0
  85. package/dist/src/families/pi/index.d.ts +3 -0
  86. package/dist/src/families/pi/index.js +54 -0
  87. package/dist/src/families/shared/toml.d.ts +5 -0
  88. package/dist/src/families/shared/toml.js +16 -0
  89. package/dist/src/index.d.ts +5 -0
  90. package/dist/src/index.js +4 -0
  91. package/dist/src/panel/background.d.ts +62 -0
  92. package/dist/src/panel/background.js +274 -0
  93. package/dist/src/panel/handler.d.ts +93 -0
  94. package/dist/src/panel/handler.js +411 -0
  95. package/dist/src/panel/i18n.d.ts +287 -0
  96. package/dist/src/panel/i18n.js +570 -0
  97. package/dist/src/panel/page-logic.d.ts +154 -0
  98. package/dist/src/panel/page-logic.js +326 -0
  99. package/dist/src/panel/page-script.d.ts +2 -0
  100. package/dist/src/panel/page-script.js +1633 -0
  101. package/dist/src/panel/page-styles.d.ts +7 -0
  102. package/dist/src/panel/page-styles.js +514 -0
  103. package/dist/src/panel/page.d.ts +20 -0
  104. package/dist/src/panel/page.js +96 -0
  105. package/dist/src/panel/server.d.ts +32 -0
  106. package/dist/src/panel/server.js +133 -0
  107. package/dist/src/panel/theme.d.ts +116 -0
  108. package/dist/src/panel/theme.js +132 -0
  109. package/dist/src/plugins/account-script.d.ts +4 -0
  110. package/dist/src/plugins/account-script.js +66 -0
  111. package/dist/src/plugins/bus.d.ts +33 -0
  112. package/dist/src/plugins/bus.js +84 -0
  113. package/dist/src/plugins/loader.d.ts +58 -0
  114. package/dist/src/plugins/loader.js +250 -0
  115. package/dist/src/quota/claude-statusline.d.ts +28 -0
  116. package/dist/src/quota/claude-statusline.js +301 -0
  117. package/dist/src/quota/statusline-tap.d.ts +11 -0
  118. package/dist/src/quota/statusline-tap.js +104 -0
  119. package/dist/src/runtime.d.ts +149 -0
  120. package/dist/src/runtime.js +371 -0
  121. package/dist/src/types.d.ts +278 -0
  122. package/dist/src/types.js +3 -0
  123. package/dist/testing/demo.d.ts +8 -0
  124. package/dist/testing/demo.js +50 -0
  125. package/dist/testing/index.d.ts +36 -0
  126. package/dist/testing/index.js +117 -0
  127. package/package.json +66 -4
  128. package/schemas/alias.json +102 -0
  129. package/schemas/app-install.json +74 -0
  130. package/schemas/app-uninstall.json +54 -0
  131. package/schemas/config.json +166 -0
  132. package/schemas/doctor.json +229 -0
  133. package/schemas/error.json +25 -0
  134. package/schemas/list.json +177 -0
  135. package/schemas/new.json +181 -0
  136. package/schemas/next.json +127 -0
  137. package/schemas/plugins.json +74 -0
  138. package/schemas/quota-setup.json +60 -0
  139. package/schemas/quota-teardown.json +30 -0
  140. package/schemas/quota.json +203 -0
  141. package/skills/sideby/SKILL.md +52 -0
  142. package/skills/sideby/references/accounts.md +31 -0
  143. package/skills/sideby/references/diagnose.md +47 -0
  144. package/skills/sideby/references/handoff.md +41 -0
  145. package/skills/sideby/references/quota.md +14 -0
@@ -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,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;
@@ -0,0 +1,116 @@
1
+ import { lstat, readFile } from 'node:fs/promises';
2
+ import { UserError } from './errors.js';
3
+ export class SecretFileError extends UserError {
4
+ }
5
+ const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
6
+ /** Non-secret locations a Secret File may reference from the environment, such as `$HOME/bin/notify`. */
7
+ export const SECRET_FILE_ENV_REFS = [
8
+ 'HOME',
9
+ 'USER',
10
+ 'LOGNAME',
11
+ 'TMPDIR',
12
+ 'XDG_CONFIG_HOME',
13
+ 'XDG_DATA_HOME',
14
+ 'XDG_STATE_HOME',
15
+ 'XDG_CACHE_HOME',
16
+ ];
17
+ const REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)|\$\{/g;
18
+ /**
19
+ * Parses the dotenv subset sideby supports: KEY=VALUE, optional `export `, quotes and comments.
20
+ * `$NAME` and `${NAME}` in unquoted or double-quoted values expand to a key defined earlier in the
21
+ * same file, else to one of SECRET_FILE_ENV_REFS from `env`; no other variable is read and nothing is
22
+ * executed. Errors name the line, never its content.
23
+ */
24
+ export function parseSecretFile(text, file, env = {}) {
25
+ const out = {};
26
+ const lines = text.split(/\r?\n/);
27
+ const expand = (s, line) => s.replace(REF, (whole, braced, bare) => {
28
+ const name = braced ?? bare;
29
+ if (!name)
30
+ throw new SecretFileError(`${file}:${line}: unterminated \${`);
31
+ if (Object.hasOwn(out, name))
32
+ return out[name];
33
+ const fromEnv = SECRET_FILE_ENV_REFS.includes(name) ? env[name] : undefined;
34
+ if (fromEnv !== undefined)
35
+ return fromEnv;
36
+ throw new SecretFileError(`${file}:${line}: ${whole} is not defined earlier in this file; from the environment only ${SECRET_FILE_ENV_REFS.join(', ')} are read`);
37
+ });
38
+ lines.forEach((raw, i) => {
39
+ const line = raw.trim();
40
+ if (!line || line.startsWith('#'))
41
+ return;
42
+ const m = LINE.exec(raw);
43
+ if (!m)
44
+ throw new SecretFileError(`${file}:${i + 1}: expected KEY=VALUE`);
45
+ const key = m[1];
46
+ let value = m[2];
47
+ const q = value[0];
48
+ if (q === '"' || q === "'") {
49
+ let end = -1;
50
+ for (let j = 1; j < value.length; j++) {
51
+ if (q === '"' && value[j] === '\\')
52
+ j++;
53
+ else if (value[j] === q) {
54
+ end = j;
55
+ break;
56
+ }
57
+ }
58
+ if (end === -1)
59
+ throw new SecretFileError(`${file}:${i + 1}: unterminated quote`);
60
+ const rest = value.slice(end + 1).trim();
61
+ if (rest && !rest.startsWith('#'))
62
+ throw new SecretFileError(`${file}:${i + 1}: text after closing quote`);
63
+ value = value.slice(1, end);
64
+ if (q === '"')
65
+ // Escapes and references in one pass, so an escaped `\$` stays a literal dollar sign.
66
+ value = value.replace(/\\(n|"|\\|\$)|\$[^$\\]*/g, (part, c) => c === undefined ? expand(part, i + 1) : c === 'n' ? '\n' : c);
67
+ }
68
+ else {
69
+ const hash = value.search(/\s#/);
70
+ if (hash !== -1)
71
+ value = value.slice(0, hash).trimEnd();
72
+ if (/\$\(|`/.test(value))
73
+ throw new SecretFileError(`${file}:${i + 1}: command substitution is not supported; write the value itself`);
74
+ value = expand(value, i + 1);
75
+ }
76
+ out[key] = value;
77
+ });
78
+ return out;
79
+ }
80
+ /**
81
+ * The one Secret File rule, used by Launch and by Doctor: the Account's own regular file (never a link)
82
+ * with mode 600. Only the file's type and mode are inspected, never its contents.
83
+ */
84
+ export async function inspectSecretFile(file) {
85
+ let st;
86
+ try {
87
+ st = await lstat(file);
88
+ }
89
+ catch {
90
+ return { kind: 'missing' };
91
+ }
92
+ if (!st.isFile())
93
+ return { kind: 'not-file' };
94
+ const mode = st.mode & 0o777;
95
+ return mode === 0o600 ? { kind: 'ok' } : { kind: 'mode', mode };
96
+ }
97
+ /** Reads a Secret File after inspectSecretFile accepts it. */
98
+ export async function readSecretFile(file, env = {}) {
99
+ const state = await inspectSecretFile(file);
100
+ if (state.kind === 'missing')
101
+ throw new SecretFileError(`${file} is missing`);
102
+ if (state.kind === 'not-file')
103
+ throw new SecretFileError(`${file} must be a regular file, not a link or directory`);
104
+ if (state.kind === 'mode')
105
+ throw new SecretFileError(`${file} has mode ${state.mode.toString(8)}; run \`chmod 600 ${file}\` so only you can read it`);
106
+ return parseSecretFile(await readFile(file, 'utf8'), file, env);
107
+ }
108
+ export function secretFileTemplate(vars, familyTitle) {
109
+ const lines = [
110
+ `# ${familyTitle} API account. sideby loads these variables only into this account's process.`,
111
+ '# Uncomment and fill in. Keep this file private (mode 600).',
112
+ ...vars.map((v) => `# ${v}=`),
113
+ '',
114
+ ];
115
+ return lines.join('\n');
116
+ }
@@ -0,0 +1 @@
1
+ export declare const SHARE_MODES: readonly ["link", "copy", "link-or-copy", "link-or-local", "local", "local-if-api", "info", "json-key"];
@@ -0,0 +1,10 @@
1
+ export const SHARE_MODES = [
2
+ 'link',
3
+ 'copy',
4
+ 'link-or-copy',
5
+ 'link-or-local',
6
+ 'local',
7
+ 'local-if-api',
8
+ 'info',
9
+ 'json-key',
10
+ ];
@@ -0,0 +1,24 @@
1
+ import type { Account, CheckResult, SharedItemDef } from '../types.ts';
2
+ export interface ItemContext {
3
+ account: Account;
4
+ item: SharedItemDef;
5
+ /** Resolved main-side path. */
6
+ mainPath: string;
7
+ /** Path inside the Account directory. */
8
+ accountPath: string;
9
+ /** The Account directory; every repair must stay inside it. */
10
+ accountDir: string;
11
+ /** Allows `json-key` to remove entries the Account has but the main file does not. */
12
+ force: boolean;
13
+ }
14
+ /** `counted` is false for `info` items, which never count toward the shared total. */
15
+ export interface ItemCheck {
16
+ counted: boolean;
17
+ results: CheckResult[];
18
+ }
19
+ export declare function credentialModeFinding(item: string, path: string, mode: number): CheckResult;
20
+ export declare function checkItem(ctx: ItemContext): Promise<ItemCheck>;
21
+ export declare function itemPaths(home: string, mainDir: string, accountDir: string, item: SharedItemDef): {
22
+ mainPath: string;
23
+ accountPath: string;
24
+ };