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,171 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { looksLikeBackup, mainDirOf } from './accounts.js';
3
+ import { lstatOrNull, permBits, statOrNull } from './fs-safe.js';
4
+ import { inspectSecretFile } from './secret-file.js';
5
+ import { checkItem, credentialModeFinding, itemPaths } from './share-modes.js';
6
+ import { checkInstalledSkills } from './skill-check.js';
7
+ const BACKUP_ENTRY = /(\.bak|backup|\.tmp)/i;
8
+ const BACKUP_DIRS = new Set(['backups', 'tmp', '.tmp']);
9
+ async function countBackups(dir) {
10
+ try {
11
+ return (await readdir(dir)).filter((e) => BACKUP_ENTRY.test(e) && !BACKUP_DIRS.has(e)).length;
12
+ }
13
+ catch {
14
+ return 0;
15
+ }
16
+ }
17
+ function toFinding(r, account, source) {
18
+ return { ...r, account, source, fixable: Boolean(r.fixable && r.fix) };
19
+ }
20
+ async function checkAccount(input, family, account) {
21
+ const findings = [];
22
+ let okCount = 0;
23
+ let total = 0;
24
+ const mainDir = mainDirOf(family, input.home);
25
+ const items = [...family.sharedItems];
26
+ if (!account.isMain) {
27
+ for (const item of items) {
28
+ const { mainPath, accountPath } = itemPaths(input.home, mainDir, account.dir, item);
29
+ const res = await checkItem({
30
+ account,
31
+ item,
32
+ mainPath,
33
+ accountPath,
34
+ accountDir: account.dir,
35
+ force: Boolean(input.force),
36
+ });
37
+ if (res.counted) {
38
+ total++;
39
+ if (res.results[0]?.level === 'ok')
40
+ okCount++;
41
+ }
42
+ for (const r of res.results)
43
+ if (r.level !== 'ok')
44
+ findings.push(toFinding(r, account.ref, 'core'));
45
+ }
46
+ if (looksLikeBackup(account.name))
47
+ findings.push({
48
+ level: 'warn',
49
+ account: account.ref,
50
+ item: 'name',
51
+ code: 'name.backup-like',
52
+ message: `${account.ref} looks like a backup, not an account`,
53
+ hint: `if it is a backup, add "${account.ref}" to "ignore" in the sideby config`,
54
+ source: 'core',
55
+ fixable: false,
56
+ });
57
+ }
58
+ else {
59
+ // The Main Account is the source of sharing; only its credential files are checked.
60
+ for (const item of items.filter((i) => i.credential)) {
61
+ const { mainPath } = itemPaths(input.home, mainDir, account.dir, item);
62
+ const st = await lstatOrNull(mainPath);
63
+ if (st?.isFile() && permBits(st) !== 0o600)
64
+ findings.push(toFinding(credentialModeFinding(item.mainPath ?? item.path, mainPath, permBits(st)), account.ref, 'core'));
65
+ }
66
+ }
67
+ if (account.secretFile) {
68
+ const state = await inspectSecretFile(account.secretFile);
69
+ if (state.kind === 'not-file')
70
+ findings.push({
71
+ level: 'fail',
72
+ account: account.ref,
73
+ item: 'proxy.env',
74
+ code: 'secret.not-file',
75
+ message: 'proxy.env must be a regular file, not a link or directory',
76
+ source: 'core',
77
+ fixable: false,
78
+ });
79
+ else if (state.kind === 'mode')
80
+ findings.push(toFinding(credentialModeFinding('proxy.env', account.secretFile, state.mode), account.ref, 'core'));
81
+ }
82
+ for (const r of await input.bus.runIsolated('doctor.check', family.id, {
83
+ account,
84
+ family,
85
+ config: {},
86
+ })) {
87
+ if (r.error) {
88
+ findings.push({
89
+ level: 'fail',
90
+ account: account.ref,
91
+ item: 'plugin',
92
+ code: 'plugin.error',
93
+ message: r.error.message,
94
+ source: r.plugin,
95
+ fixable: false,
96
+ });
97
+ continue;
98
+ }
99
+ const results = r.result ?? [];
100
+ if (!Array.isArray(results)) {
101
+ findings.push({
102
+ level: 'fail',
103
+ account: account.ref,
104
+ item: 'plugin',
105
+ code: 'plugin.error',
106
+ message: `[${r.plugin}] doctor.check must return an array of findings`,
107
+ source: r.plugin,
108
+ fixable: false,
109
+ });
110
+ continue;
111
+ }
112
+ for (const c of results)
113
+ if (c?.level !== 'ok')
114
+ findings.push(toFinding(c, account.ref, r.plugin));
115
+ }
116
+ return {
117
+ ref: account.ref,
118
+ family: account.family,
119
+ name: account.name,
120
+ kind: account.kind,
121
+ dir: account.dir,
122
+ shared: { ok: okCount, total },
123
+ backups: await countBackups(account.dir),
124
+ findings,
125
+ };
126
+ }
127
+ export async function runDoctor(input) {
128
+ const accounts = [];
129
+ const general = [];
130
+ for (const family of input.families.values()) {
131
+ const list = input.accounts.filter((a) => a.family === family.id);
132
+ if (list.length === 0)
133
+ continue;
134
+ if (!list.some((a) => a.isMain) && !(await statOrNull(mainDirOf(family, input.home)))?.isDirectory())
135
+ general.push({
136
+ level: 'fail',
137
+ account: `${family.id}:main`,
138
+ item: 'main',
139
+ code: 'main.missing',
140
+ message: `${family.title} has accounts but no main account directory to share from`,
141
+ hint: `run ${family.bin} once so it creates its default directory`,
142
+ source: 'core',
143
+ fixable: false,
144
+ });
145
+ for (const account of list)
146
+ accounts.push(await checkAccount(input, family, account));
147
+ }
148
+ if (input.skillVersion) {
149
+ const checked = [...input.families.values()].filter((f) => input.accounts.some((a) => a.family === f.id));
150
+ general.push(...(await checkInstalledSkills(input.home, checked, input.skillVersion)));
151
+ }
152
+ const failed = [...general, ...accounts.flatMap((a) => a.findings)].some((f) => f.level === 'fail');
153
+ return { status: failed ? 'issues' : 'ok', accounts, general, fixes: [] };
154
+ }
155
+ /** Applies every fixable Finding once, in order; one failing fix never stops the others. */
156
+ export async function applyFixes(report) {
157
+ const out = [];
158
+ for (const f of report.accounts.flatMap((a) => a.findings)) {
159
+ if (!f.fixable || !f.fix)
160
+ continue;
161
+ let res;
162
+ try {
163
+ res = await f.fix();
164
+ }
165
+ catch (err) {
166
+ res = { ok: false, message: err.message };
167
+ }
168
+ out.push({ account: f.account, item: f.item, code: f.code, ...res });
169
+ }
170
+ return out;
171
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * An error caused by what the user asked for or has on disk (unknown account, broken config, invalid name),
3
+ * as opposed to a bug. Adapters map it without a stack: the CLI exits 1, the Panel answers 400.
4
+ * Command-line shape errors (`UsageError`, exit 2) are deliberately not UserErrors.
5
+ */
6
+ export declare class UserError extends Error {
7
+ /** Machine-readable reason, such as `create-refused`; the Panel passes it to the page. */
8
+ readonly code?: string;
9
+ constructor(message: string, opts?: {
10
+ code?: string;
11
+ });
12
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * An error caused by what the user asked for or has on disk (unknown account, broken config, invalid name),
3
+ * as opposed to a bug. Adapters map it without a stack: the CLI exits 1, the Panel answers 400.
4
+ * Command-line shape errors (`UsageError`, exit 2) are deliberately not UserErrors.
5
+ */
6
+ export class UserError extends Error {
7
+ /** Machine-readable reason, such as `create-refused`; the Panel passes it to the page. */
8
+ code;
9
+ constructor(message, opts = {}) {
10
+ super(message);
11
+ if (opts.code)
12
+ this.code = opts.code;
13
+ }
14
+ }
@@ -0,0 +1,24 @@
1
+ import type { Stats } from 'node:fs';
2
+ /** The parts of `fs.Stats` that tell whether a file changed. */
3
+ export type FileStats = Pick<Stats, 'size' | 'mtimeMs' | 'ctimeMs' | 'ino'>;
4
+ export declare function fileSignature(st: FileStats): string;
5
+ /** One pass over a scope's files. Files the pass does not ask for are forgotten when it ends. */
6
+ export interface MemoPass<T> {
7
+ /** The remembered value for this file version, or `parse()` (remembered unless `keep` says no). */
8
+ get(path: string, st: FileStats, parse: () => Promise<T>, keep?: (value: T) => boolean): Promise<T>;
9
+ /** Ends the pass: the scope now holds only the files this pass asked for. */
10
+ done(): void;
11
+ }
12
+ export declare class FileMemo<T> {
13
+ #private;
14
+ /** Counters for tests and timing. */
15
+ readonly stats: {
16
+ hits: number;
17
+ misses: number;
18
+ };
19
+ /** Starts a pass over `scope`, such as one Account's directory. */
20
+ pass(scope: string): MemoPass<T>;
21
+ /** Files remembered for a scope (for tests). */
22
+ size(scope: string): number;
23
+ clear(): void;
24
+ }
@@ -0,0 +1,41 @@
1
+ export function fileSignature(st) {
2
+ return `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`;
3
+ }
4
+ export class FileMemo {
5
+ #scopes = new Map();
6
+ /** Counters for tests and timing. */
7
+ stats = { hits: 0, misses: 0 };
8
+ /** Starts a pass over `scope`, such as one Account's directory. */
9
+ pass(scope) {
10
+ const before = this.#scopes.get(scope);
11
+ const seen = new Map();
12
+ return {
13
+ get: async (path, st, parse, keep) => {
14
+ const sig = fileSignature(st);
15
+ const hit = seen.get(path) ?? before?.get(path);
16
+ if (hit && hit.sig === sig) {
17
+ this.stats.hits++;
18
+ seen.set(path, hit);
19
+ return hit.value;
20
+ }
21
+ this.stats.misses++;
22
+ const value = await parse();
23
+ if (!keep || keep(value))
24
+ seen.set(path, { sig, value });
25
+ return value;
26
+ },
27
+ done: () => {
28
+ this.#scopes.set(scope, seen);
29
+ },
30
+ };
31
+ }
32
+ /** Files remembered for a scope (for tests). */
33
+ size(scope) {
34
+ return this.#scopes.get(scope)?.size ?? 0;
35
+ }
36
+ clear() {
37
+ this.#scopes.clear();
38
+ this.stats.hits = 0;
39
+ this.stats.misses = 0;
40
+ }
41
+ }
@@ -0,0 +1,38 @@
1
+ import type { Stats } from 'node:fs';
2
+ export declare function lstatOrNull(p: string): Promise<Stats | null>;
3
+ export declare function statOrNull(p: string): Promise<Stats | null>;
4
+ export declare function realpathOrNull(p: string): Promise<string | null>;
5
+ /** True when `link` is a symlink whose resolved target is the same file as `target`. */
6
+ export declare function linksTo(link: string, target: string): Promise<boolean>;
7
+ export declare function sha256(data: string | Uint8Array): string;
8
+ export declare function fileHash(p: string): Promise<string | null>;
9
+ /**
10
+ * Writes via a temp file in the same directory, fsyncs, then renames over `path`.
11
+ * Refuses to write when `path` is a symlink so a write can never pass through a link into another directory.
12
+ */
13
+ export declare function writeFileAtomic(path: string, data: string | Uint8Array, mode?: number,
14
+ /** Checked after the temp file is complete, right before the rename; false aborts and keeps `path`. */
15
+ beforeRename?: () => Promise<boolean>): Promise<boolean>;
16
+ /**
17
+ * Writes only if the file still has `expectedHash` (null = must not exist), checking both before writing and
18
+ * again right before the rename. This narrows, but cannot close, the window in which another program (for
19
+ * example the Host saving the same file) could write in between: plain files offer no compare-and-swap.
20
+ */
21
+ export declare function writeIfUnchanged(path: string, expectedHash: string | null, data: string | Uint8Array, mode?: number): Promise<boolean>;
22
+ /**
23
+ * Throws unless `target` stays inside `root` both by name (no `..`) and on disk (no parent directory that is a
24
+ * symlink leading elsewhere), so a repair can never write into another Account or the Main Account.
25
+ */
26
+ export declare function assertInside(root: string, target: string): Promise<void>;
27
+ /** JSON.parse whose error never quotes the input: parse errors from Node include a slice of the text. */
28
+ export declare function parseJsonSafely(text: string, label: string): unknown;
29
+ /** Recursive comparison of files, directories and symlink targets. Modes and times are ignored. */
30
+ export declare function sameContent(a: string, b: string): Promise<boolean>;
31
+ /**
32
+ * Copies `src` (resolving it if it is a symlink) to `dst`, keeping modes, hidden files and nested symlinks.
33
+ * Builds the copy beside `dst` and swaps it in, so a failure leaves the old `dst` untouched.
34
+ * Refuses when `dst` is a symlink: callers must remove links explicitly.
35
+ */
36
+ export declare function replaceWithCopy(src: string, dst: string): Promise<void>;
37
+ export declare function isEmptyDir(p: string): Promise<boolean>;
38
+ export declare function permBits(st: Stats): number;
@@ -0,0 +1,205 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { cp, lstat, open, readdir, readFile, readlink, realpath, rename, rm, stat } from 'node:fs/promises';
3
+ import { basename, dirname, isAbsolute, join, relative, sep } from 'node:path';
4
+ export async function lstatOrNull(p) {
5
+ try {
6
+ return await lstat(p);
7
+ }
8
+ catch {
9
+ return null;
10
+ }
11
+ }
12
+ export async function statOrNull(p) {
13
+ try {
14
+ return await stat(p);
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export async function realpathOrNull(p) {
21
+ try {
22
+ return await realpath(p);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
28
+ /** True when `link` is a symlink whose resolved target is the same file as `target`. */
29
+ export async function linksTo(link, target) {
30
+ const st = await lstatOrNull(link);
31
+ if (!st?.isSymbolicLink())
32
+ return false;
33
+ const [a, b] = await Promise.all([realpathOrNull(link), realpathOrNull(target)]);
34
+ return a !== null && a === b;
35
+ }
36
+ export function sha256(data) {
37
+ return createHash('sha256').update(data).digest('hex');
38
+ }
39
+ export async function fileHash(p) {
40
+ try {
41
+ return sha256(await readFile(p));
42
+ }
43
+ catch {
44
+ return null;
45
+ }
46
+ }
47
+ /**
48
+ * Writes via a temp file in the same directory, fsyncs, then renames over `path`.
49
+ * Refuses to write when `path` is a symlink so a write can never pass through a link into another directory.
50
+ */
51
+ export async function writeFileAtomic(path, data, mode = 0o600,
52
+ /** Checked after the temp file is complete, right before the rename; false aborts and keeps `path`. */
53
+ beforeRename) {
54
+ const existing = await lstatOrNull(path);
55
+ if (existing?.isSymbolicLink())
56
+ throw new Error(`refusing to write through a symlink: ${path}`);
57
+ const tmp = join(dirname(path), `.${basename(path)}.sideby-${randomBytes(6).toString('hex')}.tmp`);
58
+ const fh = await open(tmp, 'wx', mode);
59
+ try {
60
+ await fh.writeFile(data);
61
+ // Set the final mode before the rename (open's mode is reduced by the umask), so nothing can fail
62
+ // after the target has been replaced.
63
+ await fh.chmod(mode);
64
+ await fh.sync();
65
+ }
66
+ catch (err) {
67
+ await fh.close();
68
+ await rm(tmp, { force: true });
69
+ throw err;
70
+ }
71
+ await fh.close();
72
+ try {
73
+ if (beforeRename && !(await beforeRename())) {
74
+ await rm(tmp, { force: true });
75
+ return false;
76
+ }
77
+ await rename(tmp, path);
78
+ return true;
79
+ }
80
+ catch (err) {
81
+ await rm(tmp, { force: true });
82
+ throw err;
83
+ }
84
+ }
85
+ /**
86
+ * Writes only if the file still has `expectedHash` (null = must not exist), checking both before writing and
87
+ * again right before the rename. This narrows, but cannot close, the window in which another program (for
88
+ * example the Host saving the same file) could write in between: plain files offer no compare-and-swap.
89
+ */
90
+ export async function writeIfUnchanged(path, expectedHash, data, mode = 0o600) {
91
+ if ((await fileHash(path)) !== expectedHash)
92
+ return false;
93
+ return writeFileAtomic(path, data, mode, async () => (await fileHash(path)) === expectedHash);
94
+ }
95
+ /**
96
+ * Throws unless `target` stays inside `root` both by name (no `..`) and on disk (no parent directory that is a
97
+ * symlink leading elsewhere), so a repair can never write into another Account or the Main Account.
98
+ */
99
+ export async function assertInside(root, target) {
100
+ const rel = relative(root, target);
101
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel))
102
+ throw new Error(`refusing to write outside the account directory: ${target}`);
103
+ const rootReal = (await realpathOrNull(root)) ?? root;
104
+ let dir = dirname(target);
105
+ while (!(await lstatOrNull(dir))) {
106
+ const up = dirname(dir);
107
+ if (up === dir)
108
+ break;
109
+ dir = up;
110
+ }
111
+ const real = (await realpathOrNull(dir)) ?? dir;
112
+ if (real !== rootReal && !real.startsWith(`${rootReal}${sep}`))
113
+ throw new Error(`refusing to write outside the account directory: ${target} (a parent directory is a link)`);
114
+ }
115
+ /** JSON.parse whose error never quotes the input: parse errors from Node include a slice of the text. */
116
+ export function parseJsonSafely(text, label) {
117
+ try {
118
+ return JSON.parse(text);
119
+ }
120
+ catch {
121
+ throw new Error(`${label} is not valid JSON`);
122
+ }
123
+ }
124
+ /** Recursive comparison of files, directories and symlink targets. Modes and times are ignored. */
125
+ export async function sameContent(a, b) {
126
+ const [sa, sb] = await Promise.all([lstatOrNull(a), lstatOrNull(b)]);
127
+ if (!sa || !sb)
128
+ return false;
129
+ if (sa.isSymbolicLink() || sb.isSymbolicLink()) {
130
+ if (!(sa.isSymbolicLink() && sb.isSymbolicLink()))
131
+ return false;
132
+ return (await readlink(a)) === (await readlink(b));
133
+ }
134
+ if (sa.isFile() && sb.isFile()) {
135
+ if (sa.size !== sb.size)
136
+ return false;
137
+ const [ba, bb] = await Promise.all([readFile(a), readFile(b)]);
138
+ return ba.equals(bb);
139
+ }
140
+ if (sa.isDirectory() && sb.isDirectory()) {
141
+ const [ea, eb] = await Promise.all([readdir(a), readdir(b)]);
142
+ if (ea.length !== eb.length)
143
+ return false;
144
+ ea.sort();
145
+ eb.sort();
146
+ for (let i = 0; i < ea.length; i++) {
147
+ if (ea[i] !== eb[i])
148
+ return false;
149
+ if (!(await sameContent(join(a, ea[i]), join(b, eb[i]))))
150
+ return false;
151
+ }
152
+ return true;
153
+ }
154
+ return false;
155
+ }
156
+ /**
157
+ * Copies `src` (resolving it if it is a symlink) to `dst`, keeping modes, hidden files and nested symlinks.
158
+ * Builds the copy beside `dst` and swaps it in, so a failure leaves the old `dst` untouched.
159
+ * Refuses when `dst` is a symlink: callers must remove links explicitly.
160
+ */
161
+ export async function replaceWithCopy(src, dst) {
162
+ const existing = await lstatOrNull(dst);
163
+ if (existing?.isSymbolicLink())
164
+ throw new Error(`refusing to copy over a symlink: ${dst}`);
165
+ const source = (await realpathOrNull(src)) ?? src;
166
+ const tmp = join(dirname(dst), `.${basename(dst)}.sideby-${randomBytes(6).toString('hex')}.tmp`);
167
+ try {
168
+ await cp(source, tmp, {
169
+ recursive: true,
170
+ verbatimSymlinks: true,
171
+ preserveTimestamps: true,
172
+ errorOnExist: true,
173
+ });
174
+ }
175
+ catch (err) {
176
+ await rm(tmp, { recursive: true, force: true });
177
+ throw err;
178
+ }
179
+ if (!existing) {
180
+ await rename(tmp, dst);
181
+ return;
182
+ }
183
+ const old = `${tmp}.old`;
184
+ await rename(dst, old);
185
+ try {
186
+ await rename(tmp, dst);
187
+ }
188
+ catch (err) {
189
+ await rename(old, dst);
190
+ await rm(tmp, { recursive: true, force: true });
191
+ throw err;
192
+ }
193
+ await rm(old, { recursive: true, force: true });
194
+ }
195
+ export async function isEmptyDir(p) {
196
+ try {
197
+ return (await readdir(p)).length === 0;
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ }
203
+ export function permBits(st) {
204
+ return st.mode & 0o777;
205
+ }
@@ -0,0 +1,41 @@
1
+ import type { Account, LoginState, QuotaResult } from '../types.ts';
2
+ export type HandoffState = 'ready' | 'unknown' | 'api' | 'full' | 'logged-out';
3
+ export interface HandoffCandidate {
4
+ ref: string;
5
+ name: string;
6
+ isMain: boolean;
7
+ kind: Account['kind'];
8
+ login: LoginState | 'not-needed';
9
+ quota: QuotaResult;
10
+ }
11
+ export interface HandoffEntry {
12
+ ref: string;
13
+ kind: Account['kind'];
14
+ state: HandoffState;
15
+ /** Quota Pressure in percent; null when the Account has no Quota. */
16
+ pressure: number | null;
17
+ /** For `full`: when the last full window resets, so the Account is usable again. */
18
+ resetsAt?: string;
19
+ /** When the Quota was recorded; it is only as fresh as the Account's last session. */
20
+ observedAt?: string;
21
+ }
22
+ export interface HandoffPlan {
23
+ family: string;
24
+ /** The recommended Account, or null when none can be used now. */
25
+ pick: string | null;
26
+ /** Every Account, best first; the ones that cannot be picked come last. */
27
+ accounts: HandoffEntry[];
28
+ /**
29
+ * Whether any Account has Quota data. Without any, the pick is only a guess (often the Account that just ran
30
+ * out), so `sideby next` does not start it and the Panel shows no recommendation.
31
+ */
32
+ hasQuota: boolean;
33
+ /** Set when nothing can be picked and some Accounts are full: the first to come back. */
34
+ earliestReset?: {
35
+ ref: string;
36
+ at: string;
37
+ };
38
+ }
39
+ export declare function planHandoff(family: string, candidates: readonly HandoffCandidate[], now: Date, opts?: {
40
+ includeApi?: boolean;
41
+ }): HandoffPlan;
@@ -0,0 +1,54 @@
1
+ import { QUOTA_FAIL_PERCENT, quotaPressure, windowReset } from './quota-levels.js';
2
+ const ORDER = { ready: 0, unknown: 1, api: 2, full: 3, 'logged-out': 4 };
3
+ /** A full Account without a readable reset time sorts after every one that has one. */
4
+ const resetTime = (e) => (e.resetsAt ? Date.parse(e.resetsAt) : Number.POSITIVE_INFINITY);
5
+ function entry(c, now) {
6
+ const pressure = quotaPressure(c.quota, now);
7
+ const base = {
8
+ ref: c.ref,
9
+ kind: c.kind,
10
+ pressure,
11
+ ...(c.quota.status === 'ok' ? { observedAt: c.quota.observedAt } : {}),
12
+ };
13
+ if (c.kind === 'api')
14
+ return { ...base, state: 'api' };
15
+ if (c.login === 'logged-out')
16
+ return { ...base, state: 'logged-out' };
17
+ if (pressure === null)
18
+ return { ...base, state: 'unknown' };
19
+ if (pressure < QUOTA_FAIL_PERCENT)
20
+ return { ...base, state: 'ready' };
21
+ // Usable again only when every full window has reset: the latest of their reset times.
22
+ let last;
23
+ if (c.quota.status === 'ok')
24
+ for (const w of c.quota.windows) {
25
+ const t = Date.parse(w.resetsAt);
26
+ if (w.usedPercent >= QUOTA_FAIL_PERCENT && !windowReset(w.resetsAt, now) && Number.isFinite(t))
27
+ last = Math.max(last ?? t, t);
28
+ }
29
+ return { ...base, state: 'full', ...(last === undefined ? {} : { resetsAt: new Date(last).toISOString() }) };
30
+ }
31
+ export function planHandoff(family, candidates, now, opts = {}) {
32
+ const byRef = new Map(candidates.map((c) => [c.ref, c]));
33
+ const tie = (a, b) => {
34
+ const x = byRef.get(a.ref);
35
+ const y = byRef.get(b.ref);
36
+ return Number(y.isMain) - Number(x.isMain) || x.name.localeCompare(y.name, undefined, { numeric: true });
37
+ };
38
+ const accounts = candidates
39
+ .map((c) => entry(c, now))
40
+ .sort((a, b) => ORDER[a.state] - ORDER[b.state] ||
41
+ (a.state === 'ready' ? a.pressure - b.pressure : 0) ||
42
+ (a.state === 'full' ? resetTime(a) - resetTime(b) || 0 : 0) ||
43
+ tie(a, b));
44
+ const usable = (e) => e.state === 'ready' || e.state === 'unknown' || (e.state === 'api' && Boolean(opts.includeApi));
45
+ const pick = accounts.find(usable)?.ref ?? null;
46
+ const hasQuota = accounts.some((e) => e.state === 'ready' || e.state === 'full');
47
+ const plan = { family, pick, accounts, hasQuota };
48
+ if (pick === null) {
49
+ const first = accounts.find((e) => e.state === 'full' && e.resetsAt);
50
+ if (first?.resetsAt)
51
+ plan.earliestReset = { ref: first.ref, at: first.resetsAt };
52
+ }
53
+ return plan;
54
+ }
@@ -0,0 +1,23 @@
1
+ import type { AccountIdentity } from '../types.ts';
2
+ /** The email when `v` looks like one address, else undefined. */
3
+ export declare function cleanEmail(v: unknown): string | undefined;
4
+ /** A short single-line organization name, else undefined. */
5
+ export declare function cleanOrg(v: unknown): string | undefined;
6
+ /** `{ email?, org? }` without empty fields, or undefined when both are missing. */
7
+ export declare function identityOf(email: unknown, org?: unknown): AccountIdentity | undefined;
8
+ /**
9
+ * The email claim of a JWT such as Codex's `id_token`: the payload segment is base64url-decoded and parsed, and only
10
+ * `email` is kept. The signature is not checked (the token is the Host's own file, read for display only); the
11
+ * token itself and every other claim are dropped.
12
+ */
13
+ export declare function jwtEmail(token: unknown): string | undefined;
14
+ /** Counters of the identity cache, for tests. */
15
+ export declare const identityMemoStats: {
16
+ hits: number;
17
+ misses: number;
18
+ };
19
+ /**
20
+ * Reads one JSON login file and keeps only what `pick` returns from it. Undefined when the file is missing, is not
21
+ * JSON or holds no identity. `pick` must return identity fields only (identityOf builds one).
22
+ */
23
+ export declare function identityFromJsonFile(path: string, pick: (data: unknown) => AccountIdentity | undefined): Promise<AccountIdentity | undefined>;