sideby 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +453 -2
- package/README.zh-CN.md +455 -0
- package/dist/build.json +1 -0
- package/dist/src/cli/args.d.ts +13 -0
- package/dist/src/cli/args.js +38 -0
- package/dist/src/cli/commands.d.ts +37 -0
- package/dist/src/cli/commands.js +487 -0
- package/dist/src/cli/format.d.ts +16 -0
- package/dist/src/cli/format.js +80 -0
- package/dist/src/cli/json-schemas.d.ts +2 -0
- package/dist/src/cli/json-schemas.js +224 -0
- package/dist/src/cli/main.d.ts +2 -0
- package/dist/src/cli/main.js +186 -0
- package/dist/src/cli/schemas.d.ts +1 -0
- package/dist/src/cli/schemas.js +16 -0
- package/dist/src/core/account-name.d.ts +2 -0
- package/dist/src/core/account-name.js +2 -0
- package/dist/src/core/accounts.d.ts +18 -0
- package/dist/src/core/accounts.js +116 -0
- package/dist/src/core/config.d.ts +60 -0
- package/dist/src/core/config.js +181 -0
- package/dist/src/core/create.d.ts +30 -0
- package/dist/src/core/create.js +126 -0
- package/dist/src/core/doctor.d.ts +41 -0
- package/dist/src/core/doctor.js +171 -0
- package/dist/src/core/errors.d.ts +12 -0
- package/dist/src/core/errors.js +14 -0
- package/dist/src/core/file-memo.d.ts +24 -0
- package/dist/src/core/file-memo.js +41 -0
- package/dist/src/core/fs-safe.d.ts +38 -0
- package/dist/src/core/fs-safe.js +205 -0
- package/dist/src/core/handoff.d.ts +41 -0
- package/dist/src/core/handoff.js +54 -0
- package/dist/src/core/identity.d.ts +23 -0
- package/dist/src/core/identity.js +79 -0
- package/dist/src/core/last-doctor.d.ts +36 -0
- package/dist/src/core/last-doctor.js +69 -0
- package/dist/src/core/launch.d.ts +35 -0
- package/dist/src/core/launch.js +108 -0
- package/dist/src/core/login-path.d.ts +23 -0
- package/dist/src/core/login-path.js +96 -0
- package/dist/src/core/paths.d.ts +13 -0
- package/dist/src/core/paths.js +32 -0
- package/dist/src/core/quota-levels.d.ts +19 -0
- package/dist/src/core/quota-levels.js +34 -0
- package/dist/src/core/secret-file.d.ts +30 -0
- package/dist/src/core/secret-file.js +116 -0
- package/dist/src/core/share-mode-names.d.ts +1 -0
- package/dist/src/core/share-mode-names.js +10 -0
- package/dist/src/core/share-modes.d.ts +24 -0
- package/dist/src/core/share-modes.js +405 -0
- package/dist/src/core/shell-init.d.ts +25 -0
- package/dist/src/core/shell-init.js +61 -0
- package/dist/src/core/skill-check.d.ts +11 -0
- package/dist/src/core/skill-check.js +57 -0
- package/dist/src/core/usage-days.d.ts +15 -0
- package/dist/src/core/usage-days.js +33 -0
- package/dist/src/core/version.d.ts +9 -0
- package/dist/src/core/version.js +46 -0
- package/dist/src/desktop/app.d.ts +60 -0
- package/dist/src/desktop/app.js +316 -0
- package/dist/src/desktop/icon.d.ts +16 -0
- package/dist/src/desktop/icon.js +174 -0
- package/dist/src/families/claude/index.d.ts +10 -0
- package/dist/src/families/claude/index.js +124 -0
- package/dist/src/families/claude/layout.d.ts +6 -0
- package/dist/src/families/claude/layout.js +5 -0
- package/dist/src/families/claude/quota-cache.d.ts +31 -0
- package/dist/src/families/claude/quota-cache.js +111 -0
- package/dist/src/families/claude/quota.d.ts +3 -0
- package/dist/src/families/claude/quota.js +53 -0
- package/dist/src/families/claude/usage.d.ts +22 -0
- package/dist/src/families/claude/usage.js +146 -0
- package/dist/src/families/codex/index.d.ts +5 -0
- package/dist/src/families/codex/index.js +67 -0
- package/dist/src/families/codex/rollout.d.ts +67 -0
- package/dist/src/families/codex/rollout.js +298 -0
- package/dist/src/families/grok/index.d.ts +8 -0
- package/dist/src/families/grok/index.js +78 -0
- package/dist/src/families/index.d.ts +3 -0
- package/dist/src/families/index.js +40 -0
- package/dist/src/families/logos.d.ts +21 -0
- package/dist/src/families/logos.js +26 -0
- package/dist/src/families/pi/index.d.ts +3 -0
- package/dist/src/families/pi/index.js +54 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +16 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +4 -0
- package/dist/src/panel/background.d.ts +62 -0
- package/dist/src/panel/background.js +274 -0
- package/dist/src/panel/handler.d.ts +93 -0
- package/dist/src/panel/handler.js +411 -0
- package/dist/src/panel/i18n.d.ts +287 -0
- package/dist/src/panel/i18n.js +570 -0
- package/dist/src/panel/page-logic.d.ts +154 -0
- package/dist/src/panel/page-logic.js +326 -0
- package/dist/src/panel/page-script.d.ts +2 -0
- package/dist/src/panel/page-script.js +1633 -0
- package/dist/src/panel/page-styles.d.ts +7 -0
- package/dist/src/panel/page-styles.js +514 -0
- package/dist/src/panel/page.d.ts +20 -0
- package/dist/src/panel/page.js +96 -0
- package/dist/src/panel/server.d.ts +32 -0
- package/dist/src/panel/server.js +133 -0
- package/dist/src/panel/theme.d.ts +116 -0
- package/dist/src/panel/theme.js +132 -0
- package/dist/src/plugins/account-script.d.ts +4 -0
- package/dist/src/plugins/account-script.js +66 -0
- package/dist/src/plugins/bus.d.ts +33 -0
- package/dist/src/plugins/bus.js +84 -0
- package/dist/src/plugins/loader.d.ts +58 -0
- package/dist/src/plugins/loader.js +250 -0
- package/dist/src/quota/claude-statusline.d.ts +28 -0
- package/dist/src/quota/claude-statusline.js +301 -0
- package/dist/src/quota/statusline-tap.d.ts +11 -0
- package/dist/src/quota/statusline-tap.js +104 -0
- package/dist/src/runtime.d.ts +149 -0
- package/dist/src/runtime.js +371 -0
- package/dist/src/types.d.ts +278 -0
- package/dist/src/types.js +3 -0
- package/dist/testing/demo.d.ts +8 -0
- package/dist/testing/demo.js +50 -0
- package/dist/testing/index.d.ts +36 -0
- package/dist/testing/index.js +117 -0
- package/package.json +66 -4
- package/schemas/alias.json +102 -0
- package/schemas/app-install.json +74 -0
- package/schemas/app-uninstall.json +54 -0
- package/schemas/config.json +166 -0
- package/schemas/doctor.json +229 -0
- package/schemas/error.json +25 -0
- package/schemas/list.json +177 -0
- package/schemas/new.json +181 -0
- package/schemas/next.json +127 -0
- package/schemas/plugins.json +74 -0
- package/schemas/quota-setup.json +60 -0
- package/schemas/quota-teardown.json +30 -0
- package/schemas/quota.json +203 -0
- package/skills/sideby/SKILL.md +52 -0
- package/skills/sideby/references/accounts.md +31 -0
- package/skills/sideby/references/diagnose.md +47 -0
- package/skills/sideby/references/handoff.md +41 -0
- package/skills/sideby/references/quota.md +14 -0
|
@@ -0,0 +1,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>;
|
|
@@ -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,35 @@
|
|
|
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
|
+
/** Finds an executable on PATH; returns null when the Host is not installed. */
|
|
13
|
+
export declare function which(bin: string, env: Env): Promise<string | null>;
|
|
14
|
+
/**
|
|
15
|
+
* Builds the Host's environment and arguments (spec §3.5):
|
|
16
|
+
* copy the current env, clear Hijack Variables, merge the Secret File, set the select variable,
|
|
17
|
+
* then let `launch.before` hooks adjust or abort. Never touches the caller's environment.
|
|
18
|
+
*/
|
|
19
|
+
export declare function prepareLaunch(opts: {
|
|
20
|
+
account: Account;
|
|
21
|
+
family: FamilyDef;
|
|
22
|
+
baseEnv: Env;
|
|
23
|
+
accountArgs: readonly string[];
|
|
24
|
+
userArgs: readonly string[];
|
|
25
|
+
command: 'run' | 'login';
|
|
26
|
+
bus: HookBus;
|
|
27
|
+
}): Promise<PreparedLaunch>;
|
|
28
|
+
/**
|
|
29
|
+
* Runs the Host with the terminal attached and returns sideby's exit code (spec §3.5):
|
|
30
|
+
* the Host's own code, 128 + signal number when a signal ended it, 1 when it could not start.
|
|
31
|
+
* While the Host runs, sideby ignores SIGINT/SIGQUIT (the terminal delivers them to the whole foreground
|
|
32
|
+
* process group, so the Host receives them directly) and forwards SIGTERM/SIGHUP once. SIGTSTP keeps its
|
|
33
|
+
* default action so Ctrl-Z suspends sideby together with the Host and `fg` resumes both.
|
|
34
|
+
*/
|
|
35
|
+
export declare function runHost(p: PreparedLaunch): Promise<number>;
|