sideby 0.12.1 → 0.13.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/README.md +1 -1
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +4 -2
- package/dist/src/cli/commands.js +117 -17
- package/dist/src/cli/json-schemas.js +52 -1
- package/dist/src/cli/main.js +34 -3
- package/dist/src/core/brand-mark.d.ts +5 -7
- package/dist/src/core/brand-mark.js +18 -5
- package/dist/src/core/browser-profiles.d.ts +71 -0
- package/dist/src/core/browser-profiles.js +361 -0
- package/dist/src/core/config.d.ts +15 -0
- package/dist/src/core/config.js +13 -1
- package/dist/src/core/create.js +2 -1
- package/dist/src/core/desktop-app.d.ts +3 -1
- package/dist/src/core/desktop-app.js +29 -8
- package/dist/src/core/launch.d.ts +7 -0
- package/dist/src/core/launch.js +22 -2
- package/dist/src/core/leveldb-keys.d.ts +14 -0
- package/dist/src/core/leveldb-keys.js +291 -0
- package/dist/src/core/remote-control.d.ts +17 -6
- package/dist/src/core/remote-control.js +40 -17
- package/dist/src/core/update.d.ts +10 -3
- package/dist/src/core/update.js +38 -3
- package/dist/src/core/version.d.ts +11 -0
- package/dist/src/core/version.js +26 -0
- package/dist/src/desktop/app.d.ts +2 -0
- package/dist/src/desktop/app.js +39 -3
- package/dist/src/families/browser.d.ts +20 -0
- package/dist/src/families/browser.js +23 -0
- package/dist/src/families/claude/browser.d.ts +3 -0
- package/dist/src/families/claude/browser.js +14 -0
- package/dist/src/families/claude/desktop.js +1 -0
- package/dist/src/families/codex/browser.d.ts +7 -0
- package/dist/src/families/codex/browser.js +26 -0
- package/dist/src/families/desktop.d.ts +5 -1
- package/dist/src/families/pi/index.js +2 -0
- package/dist/src/panel/handler.d.ts +6 -0
- package/dist/src/panel/handler.js +49 -6
- package/dist/src/panel/i18n.d.ts +71 -4
- package/dist/src/panel/i18n.js +152 -18
- package/dist/src/panel/page-logic.d.ts +23 -0
- package/dist/src/panel/page-logic.js +44 -0
- package/dist/src/panel/page-script.js +576 -181
- package/dist/src/panel/page-styles.js +61 -18
- package/dist/src/panel/page.d.ts +5 -0
- package/dist/src/panel/page.js +10 -9
- package/dist/src/runtime.d.ts +6 -1
- package/dist/src/runtime.js +22 -11
- package/dist/src/types.d.ts +2 -0
- package/package.json +5 -3
- package/schemas/browser-open.json +47 -0
- package/schemas/browser-switch.json +42 -0
- package/schemas/browser.json +99 -0
- package/schemas/config.json +11 -0
- package/schemas/families.json +6 -0
- package/schemas/list.json +6 -0
- package/schemas/remote-control.json +4 -0
- package/schemas/update.json +4 -0
- package/skills/sideby/SKILL.md +5 -3
- package/skills/sideby/references/setup.md +1 -1
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// Browser Sign-ins (spec §3.24, ADR-0023): which Browser Profile of Google Chrome signed in to each Account's vendor
|
|
2
|
+
// website, from Chrome's local data on this computer only. Read-only: `Local State` gives each profile's folder,
|
|
3
|
+
// display name and the email of the Google account it is signed in to Chrome with, `Local Storage/leveldb` the key
|
|
4
|
+
// names of claude.ai and chatgpt.com (core/leveldb-keys.ts). Never Cookies, Login Data, IndexedDB or the Keychain,
|
|
5
|
+
// never a value, never the network. The Host-side id each match needs (families/browser.ts) is compared in memory
|
|
6
|
+
// and never leaves this module. Opening a website starts Chrome unchanged with `open`, through `DesktopDeps.exec`,
|
|
7
|
+
// so tests never start a real browser. On WSL the Chrome is the one installed on Windows: its folders come from one
|
|
8
|
+
// `cmd.exe` call, and it is started with `cmd.exe /c start` (spec §3.24, WSL).
|
|
9
|
+
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
10
|
+
import { join, win32 } from 'node:path';
|
|
11
|
+
import { matchedOrigins, websiteDef } from '../families/browser.js';
|
|
12
|
+
import { editConfigBrowser } from './config.js';
|
|
13
|
+
import { depsFor, findApp } from './desktop-app.js';
|
|
14
|
+
import { UserError } from './errors.js';
|
|
15
|
+
import { localStorageKeys } from './leveldb-keys.js';
|
|
16
|
+
const OPEN = '/usr/bin/open';
|
|
17
|
+
export const CHROME = {
|
|
18
|
+
title: 'Google Chrome',
|
|
19
|
+
bundleId: 'com.google.Chrome',
|
|
20
|
+
bundleNames: ['Google Chrome.app'],
|
|
21
|
+
};
|
|
22
|
+
export const CHROME_DOWNLOAD = 'https://www.google.com/chrome/';
|
|
23
|
+
const MAX_LOCAL_STATE = 16 * 1024 * 1024;
|
|
24
|
+
/** Chrome's user data folder on macOS. */
|
|
25
|
+
export function chromeUserData(home) {
|
|
26
|
+
return join(home, 'Library', 'Application Support', 'Google', 'Chrome');
|
|
27
|
+
}
|
|
28
|
+
/** A Windows drive path (`C:\Windows`) as WSL mounts it (`/mnt/c/Windows`); undefined for anything else. */
|
|
29
|
+
function toUnix(win, mount) {
|
|
30
|
+
const m = /^([A-Za-z]):\\(.*)$/.exec(win.trim());
|
|
31
|
+
if (!m)
|
|
32
|
+
return undefined;
|
|
33
|
+
return join(mount, m[1].toLowerCase(), ...m[2].split('\\').filter(Boolean));
|
|
34
|
+
}
|
|
35
|
+
const windowsMemo = new Map();
|
|
36
|
+
/**
|
|
37
|
+
* `%LOCALAPPDATA%` and the Program Files folders, from one `cmd.exe` call, remembered for the process. When cmd.exe
|
|
38
|
+
* fails, the only Windows user with Chrome data under `<mount>/c/Users`; several are not guessed between.
|
|
39
|
+
*/
|
|
40
|
+
async function windowsDirs(deps, mount, problems) {
|
|
41
|
+
const hit = windowsMemo.get(mount);
|
|
42
|
+
if (hit)
|
|
43
|
+
return hit;
|
|
44
|
+
const r = await deps.exec(join(mount, 'c', 'Windows', 'System32', 'cmd.exe'), [
|
|
45
|
+
'/d',
|
|
46
|
+
'/c',
|
|
47
|
+
'echo %LOCALAPPDATA%^|%ProgramFiles%^|%ProgramFiles(x86)%',
|
|
48
|
+
]);
|
|
49
|
+
const parts = r.code === 0 ? r.stdout.trim().split('|') : [];
|
|
50
|
+
const dir = (win) => {
|
|
51
|
+
const unix = win ? toUnix(win, mount) : undefined;
|
|
52
|
+
return win && unix ? { win: win.trim(), unix } : undefined;
|
|
53
|
+
};
|
|
54
|
+
const local = dir(parts[0]);
|
|
55
|
+
if (local) {
|
|
56
|
+
const found = {
|
|
57
|
+
localAppData: local,
|
|
58
|
+
programs: [dir(parts[1]), dir(parts[2])].filter((d) => d !== undefined),
|
|
59
|
+
};
|
|
60
|
+
windowsMemo.set(mount, found);
|
|
61
|
+
return found;
|
|
62
|
+
}
|
|
63
|
+
// cmd.exe did not answer (interop off, or no C: drive): look for the one Windows user that has Chrome.
|
|
64
|
+
const users = join(mount, 'c', 'Users');
|
|
65
|
+
const withChrome = [];
|
|
66
|
+
for (const u of await readdir(users).catch(() => [])) {
|
|
67
|
+
const st = await stat(join(users, u, 'AppData', 'Local', 'Google', 'Chrome', 'User Data', 'Local State')).catch(() => null);
|
|
68
|
+
if (st?.isFile())
|
|
69
|
+
withChrome.push(u);
|
|
70
|
+
}
|
|
71
|
+
if (withChrome.length > 1)
|
|
72
|
+
problems.push(`several Windows users have Chrome data (${withChrome.join(', ')}) and cmd.exe did not say which is yours; check that WSL can run Windows programs (\`cmd.exe /c ver\`)`);
|
|
73
|
+
const only = withChrome.length === 1 ? withChrome[0] : undefined;
|
|
74
|
+
return {
|
|
75
|
+
...(only
|
|
76
|
+
? {
|
|
77
|
+
localAppData: {
|
|
78
|
+
win: `C:\\Users\\${only}\\AppData\\Local`,
|
|
79
|
+
unix: join(users, only, 'AppData', 'Local'),
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
: {}),
|
|
83
|
+
programs: [],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/** Where Chrome keeps its data: under the home folder on macOS, in Windows' own folder on WSL; null elsewhere. */
|
|
87
|
+
async function userDataFor(rt, deps, problems) {
|
|
88
|
+
if (deps.platform === 'darwin')
|
|
89
|
+
return chromeUserData(rt.paths.home);
|
|
90
|
+
if (!deps.wslMount)
|
|
91
|
+
return null;
|
|
92
|
+
const win = await windowsDirs(deps, deps.wslMount, problems);
|
|
93
|
+
return win.localAppData ? join(win.localAppData.unix, 'Google', 'Chrome', 'User Data') : null;
|
|
94
|
+
}
|
|
95
|
+
/** Characters cmd.exe would treat as more than a path, even inside quotes. */
|
|
96
|
+
const CMD_UNSAFE = /["%&|<>^\r\n]/;
|
|
97
|
+
const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,190}$/;
|
|
98
|
+
/**
|
|
99
|
+
* Each profile's folder, display name and Google account email (`user_name`, kept only when it is an email) from
|
|
100
|
+
* `Local State`; nothing else of the file is kept.
|
|
101
|
+
*/
|
|
102
|
+
async function profileEntries(userData, problems) {
|
|
103
|
+
const file = join(userData, 'Local State');
|
|
104
|
+
let text;
|
|
105
|
+
try {
|
|
106
|
+
const st = await stat(file);
|
|
107
|
+
if (!st.isFile() || st.size > MAX_LOCAL_STATE) {
|
|
108
|
+
problems.push(`${file} is not a readable file`);
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
text = await readFile(file, 'utf8');
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
if (e.code === 'ENOENT')
|
|
115
|
+
return null;
|
|
116
|
+
problems.push(`${file} could not be read`);
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
let cache;
|
|
120
|
+
try {
|
|
121
|
+
cache = JSON.parse(text)?.profile?.info_cache;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
problems.push(`${file} is not valid JSON`);
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
if (typeof cache !== 'object' || cache === null)
|
|
128
|
+
return [];
|
|
129
|
+
const out = [];
|
|
130
|
+
for (const [dir, info] of Object.entries(cache)) {
|
|
131
|
+
if (!/^[\w .-]{1,64}$/.test(dir) || dir.startsWith('.'))
|
|
132
|
+
continue;
|
|
133
|
+
const n = info?.name;
|
|
134
|
+
const name = typeof n === 'string' && n.trim() && !/\p{Cc}/u.test(n) ? n.trim().slice(0, 80) : dir;
|
|
135
|
+
const u = info?.user_name;
|
|
136
|
+
const email = typeof u === 'string' && EMAIL.test(u.trim()) ? u.trim() : undefined;
|
|
137
|
+
out.push({ dir, name, ...(email ? { email } : {}) });
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** Key names per profile folder, remembered while none of its LevelDB files changed. */
|
|
142
|
+
const keyMemo = new Map();
|
|
143
|
+
async function profileKeys(dir, origins) {
|
|
144
|
+
let sig = origins.join(',');
|
|
145
|
+
try {
|
|
146
|
+
for (const n of (await readdir(dir)).sort()) {
|
|
147
|
+
const st = await stat(join(dir, n)).catch(() => null);
|
|
148
|
+
if (st)
|
|
149
|
+
sig += `|${n}:${st.size}:${st.mtimeMs}:${st.ino}`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { keys: new Map(), problems: [] };
|
|
154
|
+
}
|
|
155
|
+
const hit = keyMemo.get(dir);
|
|
156
|
+
if (hit?.sig === sig)
|
|
157
|
+
return hit.value;
|
|
158
|
+
const value = await localStorageKeys(dir, origins);
|
|
159
|
+
keyMemo.set(dir, { sig, value });
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
const BOUNDARY = /[A-Za-z0-9_]/;
|
|
163
|
+
/** `id` appears in `key` as a whole segment: not glued to another letter, digit or underscore. */
|
|
164
|
+
export function keyHasId(key, id) {
|
|
165
|
+
const k = key.toLowerCase();
|
|
166
|
+
const i0 = id.toLowerCase();
|
|
167
|
+
for (let i = k.indexOf(i0); i >= 0; i = k.indexOf(i0, i + 1)) {
|
|
168
|
+
const before = k[i - 1];
|
|
169
|
+
const after = k[i + i0.length];
|
|
170
|
+
if ((before === undefined || !BOUNDARY.test(before)) && (after === undefined || !BOUNDARY.test(after)))
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
/** Accounts that a Browser Sign-in can apply to: Subscription Accounts of built-in Families with a website. */
|
|
176
|
+
function eligible(rt, accounts) {
|
|
177
|
+
return accounts.filter((a) => a.kind !== 'api' &&
|
|
178
|
+
websiteDef(a.family) &&
|
|
179
|
+
rt.plugins.some((p) => p.source === 'builtin' && p.families.includes(a.family)));
|
|
180
|
+
}
|
|
181
|
+
/** The Browser Report for `sideby browser` and the Panel. Reads nothing of the browser while it is off. */
|
|
182
|
+
export async function browserReport(rt, accounts) {
|
|
183
|
+
const enabled = rt.config.browser?.signIns === true;
|
|
184
|
+
const list = eligible(rt, accounts);
|
|
185
|
+
const fams = [...new Set(list.map((a) => a.family))];
|
|
186
|
+
const families = {
|
|
187
|
+
matched: fams.filter((f) => websiteDef(f)?.match),
|
|
188
|
+
openOnly: fams.filter((f) => !websiteDef(f)?.match),
|
|
189
|
+
};
|
|
190
|
+
const base = { enabled, browser: CHROME.title, families };
|
|
191
|
+
if (!enabled)
|
|
192
|
+
return { ...base, found: false, profiles: [], problems: [] };
|
|
193
|
+
const problems = [];
|
|
194
|
+
const userData = await userDataFor(rt, depsFor(rt), problems);
|
|
195
|
+
if (userData === null)
|
|
196
|
+
return { ...base, found: false, profiles: [], problems };
|
|
197
|
+
const entries = await profileEntries(userData, problems);
|
|
198
|
+
if (entries === null)
|
|
199
|
+
return { ...base, found: false, profiles: [], problems };
|
|
200
|
+
const ids = [];
|
|
201
|
+
for (const a of list) {
|
|
202
|
+
const m = websiteDef(a.family)?.match;
|
|
203
|
+
const id = m ? await m.signInId(a) : undefined;
|
|
204
|
+
if (m && id)
|
|
205
|
+
ids.push({ ref: a.ref, origin: m.origin, id });
|
|
206
|
+
}
|
|
207
|
+
const origins = matchedOrigins();
|
|
208
|
+
const read = [];
|
|
209
|
+
for (const e of entries) {
|
|
210
|
+
const r = await profileKeys(join(userData, e.dir, 'Local Storage', 'leveldb'), origins);
|
|
211
|
+
for (const p of r.problems)
|
|
212
|
+
problems.push(`${e.name}: ${p}`);
|
|
213
|
+
const counts = ids.map(({ origin, id }) => [...(r.keys.get(origin) ?? [])].filter((k) => keyHasId(k, id)).length);
|
|
214
|
+
const changedAt = 'changedAt' in r && r.changedAt ? new Date(r.changedAt).toISOString() : undefined;
|
|
215
|
+
read.push({ e, counts, ...(changedAt ? { changedAt } : {}) });
|
|
216
|
+
}
|
|
217
|
+
// A site keeps a few keys for whoever is signed in, and may leave one behind after a sign-out or a switch. So a
|
|
218
|
+
// trace counts only when it is at least half as strong as the strongest one of the same id (across profiles) and of
|
|
219
|
+
// the same website in the same profile (across ids).
|
|
220
|
+
const bestOfId = ids.map((_, i) => Math.max(0, ...read.map((x) => x.counts[i])));
|
|
221
|
+
const profiles = read.map(({ e, counts, changedAt }) => {
|
|
222
|
+
const bestOfSite = (origin) => Math.max(0, ...ids.map((x, i) => (x.origin === origin ? counts[i] : 0)));
|
|
223
|
+
const signIns = ids
|
|
224
|
+
.filter((x, i) => {
|
|
225
|
+
const n = counts[i];
|
|
226
|
+
return n > 0 && n * 2 >= bestOfId[i] && n * 2 >= bestOfSite(x.origin);
|
|
227
|
+
})
|
|
228
|
+
.map((x) => x.ref);
|
|
229
|
+
return {
|
|
230
|
+
dir: e.dir,
|
|
231
|
+
name: e.name,
|
|
232
|
+
...(e.email ? { email: e.email } : {}),
|
|
233
|
+
signIns,
|
|
234
|
+
...(changedAt ? { changedAt } : {}),
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
profiles.sort((a, b) => (b.changedAt ?? '').localeCompare(a.changedAt ?? '') || a.dir.localeCompare(b.dir));
|
|
238
|
+
return { ...base, found: true, profiles, problems };
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Opens an Account's vendor website in one Browser Profile: the one given (folder or name), else the only profile
|
|
242
|
+
* that signed in to it. Several or none: refuses with the choices, so the caller can ask. `profile` must be a folder
|
|
243
|
+
* Chrome lists in `Local State`.
|
|
244
|
+
*/
|
|
245
|
+
export async function openInBrowser(rt, account, profile) {
|
|
246
|
+
const deps = depsFor(rt);
|
|
247
|
+
if (deps.platform !== 'darwin' && !deps.wslMount)
|
|
248
|
+
throw new UserError(`opening a browser user works on macOS and WSL only for now (this is ${deps.platform}); open ${websiteDef(account.family)?.url ?? 'the website'} yourself`, { code: 'unsupported' });
|
|
249
|
+
const site = websiteDef(account.family);
|
|
250
|
+
if (!site || account.kind === 'api' || !eligible(rt, [account]).length)
|
|
251
|
+
throw new UserError(`${account.ref} has no website to open; only Claude, Codex and Grok subscription accounts do`, {
|
|
252
|
+
code: 'no-website',
|
|
253
|
+
});
|
|
254
|
+
const problems = [];
|
|
255
|
+
const launch = await chromeLauncher(deps, problems);
|
|
256
|
+
if (!launch)
|
|
257
|
+
throw new UserError(`${CHROME.title} is not installed; get it from ${CHROME_DOWNLOAD}`, {
|
|
258
|
+
code: 'not-installed',
|
|
259
|
+
});
|
|
260
|
+
const entries = (await profileEntries(launch.userData ?? chromeUserData(rt.paths.home), problems)) ?? [];
|
|
261
|
+
if (!entries.length)
|
|
262
|
+
throw new UserError(`${CHROME.title} has no browser users yet; open it once, then try again`, {
|
|
263
|
+
code: 'no-profiles',
|
|
264
|
+
});
|
|
265
|
+
let pick;
|
|
266
|
+
if (profile) {
|
|
267
|
+
pick = entries.find((e) => e.dir === profile);
|
|
268
|
+
if (!pick) {
|
|
269
|
+
const p = profile.toLowerCase();
|
|
270
|
+
const byName = entries.filter((e) => e.name.toLowerCase() === p);
|
|
271
|
+
if (!byName.length)
|
|
272
|
+
byName.push(...entries.filter((e) => e.email?.toLowerCase() === p));
|
|
273
|
+
if (byName.length > 1)
|
|
274
|
+
throw new UserError(`several browser users are named "${profile}"; pass the folder instead: ${byName.map((e) => e.dir).join(', ')}`, { code: 'profile-ambiguous' });
|
|
275
|
+
pick = byName[0];
|
|
276
|
+
}
|
|
277
|
+
if (!pick)
|
|
278
|
+
throw new UserError(`no browser user "${profile}"; choose one of: ${entries.map((e) => `${e.name} (${e.dir})`).join(', ')}`, { code: 'profile-unknown' });
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
const report = rt.config.browser?.signIns === true ? await browserReport(rt, [account]) : undefined;
|
|
282
|
+
const found = report?.profiles.filter((p) => p.signIns.includes(account.ref)) ?? [];
|
|
283
|
+
if (found.length !== 1) {
|
|
284
|
+
const why = !site.match
|
|
285
|
+
? `${site.url.replace('https://', '')} keeps nothing sideby can compare, so name the browser user`
|
|
286
|
+
: !report
|
|
287
|
+
? `finding it is off (turn it on with \`sideby browser --enable\`)`
|
|
288
|
+
: found.length
|
|
289
|
+
? `${found.length} browser users signed in to ${account.ref}`
|
|
290
|
+
: `no browser user signed in to ${account.ref}`;
|
|
291
|
+
const choices = (found.length ? found : entries).map((e) => `${e.name} (${e.dir})`).join(', ');
|
|
292
|
+
throw new UserError(`${why}; pass --profile with one of: ${choices}`, { code: 'profile-needed' });
|
|
293
|
+
}
|
|
294
|
+
pick = entries.find((e) => e.dir === found[0]?.dir);
|
|
295
|
+
if (!pick)
|
|
296
|
+
throw new UserError('that browser user is gone; run `sideby browser` again', {
|
|
297
|
+
code: 'profile-unknown',
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
const [cmd, ...args] = launch.command(`--profile-directory=${pick.dir}`, site.url);
|
|
301
|
+
const r = await deps.exec(cmd, args);
|
|
302
|
+
if (r.code !== 0)
|
|
303
|
+
throw new UserError(`could not open ${CHROME.title}: ${launch.windows ? `cmd.exe exited with ${r.code}` : r.stderr.trim() || `open exited with ${r.code}`}; open it once by hand, then try again`, { code: 'open-failed' });
|
|
304
|
+
return {
|
|
305
|
+
ref: account.ref,
|
|
306
|
+
url: site.url,
|
|
307
|
+
profile: { dir: pick.dir, name: pick.name },
|
|
308
|
+
command: [cmd, ...args],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** How to start Chrome here: `open` on macOS, `cmd.exe /c start` with Windows' chrome.exe on WSL; null when absent. */
|
|
312
|
+
async function chromeLauncher(deps, problems) {
|
|
313
|
+
if (deps.platform === 'darwin') {
|
|
314
|
+
const app = await findApp(CHROME, deps, { spotlight: true });
|
|
315
|
+
return app && { windows: false, command: (...a) => [OPEN, '-na', app.path, '--args', ...a] };
|
|
316
|
+
}
|
|
317
|
+
const mount = deps.wslMount;
|
|
318
|
+
const win = await windowsDirs(deps, mount, problems);
|
|
319
|
+
const local = win.localAppData;
|
|
320
|
+
if (!local)
|
|
321
|
+
return null;
|
|
322
|
+
for (const d of [...win.programs, local]) {
|
|
323
|
+
const exe = join(d.unix, 'Google', 'Chrome', 'Application', 'chrome.exe');
|
|
324
|
+
if (!(await stat(exe).catch(() => null))?.isFile())
|
|
325
|
+
continue;
|
|
326
|
+
const winExe = win32.join(d.win, 'Google', 'Chrome', 'Application', 'chrome.exe');
|
|
327
|
+
if (CMD_UNSAFE.test(winExe))
|
|
328
|
+
continue;
|
|
329
|
+
const cmd = join(mount, 'c', 'Windows', 'System32', 'cmd.exe');
|
|
330
|
+
return {
|
|
331
|
+
userData: join(local.unix, 'Google', 'Chrome', 'User Data'),
|
|
332
|
+
windows: true,
|
|
333
|
+
command: (...a) => [cmd, '/d', '/c', 'start', '', winExe, ...a],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
return null;
|
|
337
|
+
}
|
|
338
|
+
/** What turning Browser Sign-ins on reads, for the confirmation in the CLI and the Panel. */
|
|
339
|
+
export const BROWSER_READS = [
|
|
340
|
+
"the name of each Google Chrome browser user and the Google account email it uses in Chrome (from Chrome's Local State)",
|
|
341
|
+
'the key names claude.ai and chatgpt.com keep in each browser user’s Local Storage, never their values',
|
|
342
|
+
'the account id in each Claude and Codex login file, only to compare; it is never shown or saved',
|
|
343
|
+
];
|
|
344
|
+
/** Turns Browser Sign-ins on or off in config `browser.signIns`; without `apply`, shows the change only. */
|
|
345
|
+
export async function setBrowserSignIns(rt, on, opts) {
|
|
346
|
+
const r = await editConfigBrowser(rt.paths.configFile, (cur) => {
|
|
347
|
+
const next = { ...cur };
|
|
348
|
+
if (on)
|
|
349
|
+
next.signIns = true;
|
|
350
|
+
else
|
|
351
|
+
delete next.signIns;
|
|
352
|
+
return next;
|
|
353
|
+
}, { dryRun: !opts.apply });
|
|
354
|
+
return {
|
|
355
|
+
enabled: on,
|
|
356
|
+
changed: r.changed,
|
|
357
|
+
applied: opts.apply && r.changed,
|
|
358
|
+
file: rt.paths.configFile,
|
|
359
|
+
reads: BROWSER_READS,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
@@ -66,6 +66,9 @@ export declare const ConfigSchema: Type.TObject<{
|
|
|
66
66
|
check: Type.TOptional<Type.TBoolean>;
|
|
67
67
|
auto: Type.TOptional<Type.TBoolean>;
|
|
68
68
|
}>>;
|
|
69
|
+
browser: Type.TOptional<Type.TObject<{
|
|
70
|
+
signIns: Type.TOptional<Type.TBoolean>;
|
|
71
|
+
}>>;
|
|
69
72
|
shellInitFile: Type.TOptional<Type.TObject<{
|
|
70
73
|
zsh: Type.TOptional<Type.TString>;
|
|
71
74
|
bash: Type.TOptional<Type.TString>;
|
|
@@ -101,6 +104,18 @@ export declare function editConfigUpdate(file: string, edit: (current: UpdateCon
|
|
|
101
104
|
before: UpdateConfig | undefined;
|
|
102
105
|
after: UpdateConfig | undefined;
|
|
103
106
|
}>;
|
|
107
|
+
export type BrowserConfig = NonNullable<Config['browser']>;
|
|
108
|
+
/**
|
|
109
|
+
* `sideby browser --enable|--disable` and the Panel's switch (spec §3.24): changes the config `browser` key with
|
|
110
|
+
* `edit` (see `editConfigKey` for how the file is written). With `dryRun`, shows the change without writing.
|
|
111
|
+
*/
|
|
112
|
+
export declare function editConfigBrowser(file: string, edit: (current: BrowserConfig) => BrowserConfig, opts?: {
|
|
113
|
+
dryRun?: boolean;
|
|
114
|
+
}): Promise<{
|
|
115
|
+
changed: boolean;
|
|
116
|
+
before: BrowserConfig | undefined;
|
|
117
|
+
after: BrowserConfig | undefined;
|
|
118
|
+
}>;
|
|
104
119
|
export type AccountsConfig = NonNullable<Config['accounts']>;
|
|
105
120
|
/**
|
|
106
121
|
* `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
|
package/dist/src/core/config.js
CHANGED
|
@@ -101,6 +101,11 @@ export const ConfigSchema = Type.Object({
|
|
|
101
101
|
description: 'Install a newer version by itself after a check, when no `sideby run` is running and the version is not a major one. Off by default.',
|
|
102
102
|
})),
|
|
103
103
|
}, { additionalProperties: false, description: 'Update Check and Auto Update (ADR-0021).' })),
|
|
104
|
+
browser: Type.Optional(Type.Object({
|
|
105
|
+
signIns: Type.Optional(Type.Boolean({
|
|
106
|
+
description: "Look in Google Chrome's local data for which browser user signed in to each Account's website (key names only, never cookies or passwords; nothing leaves this computer). Off by default.",
|
|
107
|
+
})),
|
|
108
|
+
}, { additionalProperties: false, description: 'Browser Sign-ins (spec §3.24, ADR-0023).' })),
|
|
104
109
|
shellInitFile: Type.Optional(Type.Object({
|
|
105
110
|
zsh: Type.Optional(Type.String({ minLength: 1 })),
|
|
106
111
|
bash: Type.Optional(Type.String({ minLength: 1 })),
|
|
@@ -167,7 +172,7 @@ async function configTarget(file) {
|
|
|
167
172
|
}
|
|
168
173
|
/**
|
|
169
174
|
* Re-reads the config file, lets `edit` change one top-level `key` (sideby writes only `aliases`, `handoff`,
|
|
170
|
-
* `accounts` and `
|
|
175
|
+
* `accounts`, `update` and `browser`, spec §3.10), and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
|
|
171
176
|
* with a trailing newline, atomically, keeping the file's mode (0600 for a new file). Refuses, without writing, a
|
|
172
177
|
* file that is not valid JSON or not a valid config, a result that is not a valid config, or a file that changed
|
|
173
178
|
* while sideby wrote it. `edit` returns null to leave the file alone; `dryRun` returns the change without writing.
|
|
@@ -239,6 +244,13 @@ export function editConfigHandoff(file, edit, opts = {}) {
|
|
|
239
244
|
export function editConfigUpdate(file, edit, opts = {}) {
|
|
240
245
|
return editConfigKey(file, 'update', 'change the setting', (current) => edit(current ?? {}), opts);
|
|
241
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* `sideby browser --enable|--disable` and the Panel's switch (spec §3.24): changes the config `browser` key with
|
|
249
|
+
* `edit` (see `editConfigKey` for how the file is written). With `dryRun`, shows the change without writing.
|
|
250
|
+
*/
|
|
251
|
+
export function editConfigBrowser(file, edit, opts = {}) {
|
|
252
|
+
return editConfigKey(file, 'browser', 'change the setting', (current) => edit(current ?? {}), opts);
|
|
253
|
+
}
|
|
242
254
|
/**
|
|
243
255
|
* `sideby model` (spec §3.19): changes the config `accounts` key with `edit` (see `editConfigKey` for how the file is
|
|
244
256
|
* written). With `dryRun`, shows the change without writing.
|
package/dist/src/core/create.js
CHANGED
|
@@ -4,6 +4,7 @@ import { HookError } from '../plugins/bus.js';
|
|
|
4
4
|
import { ACCOUNT_NAME, accountAt, accountDirOf, mainDirOf } from './accounts.js';
|
|
5
5
|
import { UserError } from './errors.js';
|
|
6
6
|
import { assertInside, lstatOrNull, parseJsonSafely, replaceWithCopy, statOrNull, writeFileAtomic, } from './fs-safe.js';
|
|
7
|
+
import { installHint } from './launch.js';
|
|
7
8
|
import { secretFileTemplate } from './secret-file.js';
|
|
8
9
|
import { itemPaths } from './share-modes.js';
|
|
9
10
|
export class CreateError extends UserError {
|
|
@@ -33,7 +34,7 @@ export async function createAccount(opts) {
|
|
|
33
34
|
throw new CreateError(`"${name}" is not a valid account name: use 1–32 lowercase letters, digits or "-", not "main"`);
|
|
34
35
|
const mainDir = mainDirOf(family, home);
|
|
35
36
|
if (!(await statOrNull(mainDir))?.isDirectory())
|
|
36
|
-
throw new CreateError(`${family.title} has no main account at ${mainDir}; install ${family.bin} (${family
|
|
37
|
+
throw new CreateError(`${family.title} has no main account at ${mainDir}; install ${family.bin} (${installHint(family)}) and run it once first`);
|
|
37
38
|
const dir = accountDirOf(family, home, name);
|
|
38
39
|
if (await lstatOrNull(dir))
|
|
39
40
|
throw new CreateError(`${dir} already exists; pick another name or run \`sideby doctor\``);
|
|
@@ -15,6 +15,8 @@ export interface DesktopDeps {
|
|
|
15
15
|
appDirs: readonly string[];
|
|
16
16
|
/** Runs a program and resolves with its exit code and output; never rejects. */
|
|
17
17
|
exec(cmd: string, args: readonly string[], env?: Env): Promise<ExecResult>;
|
|
18
|
+
/** On WSL, where Windows drives are mounted (normally `/mnt`, so C: is `/mnt/c`); undefined elsewhere. */
|
|
19
|
+
wslMount?: string;
|
|
18
20
|
}
|
|
19
21
|
/**
|
|
20
22
|
* Internal: makes every Desktop App call made with this Runtime use `deps` instead of the machine. Only
|
|
@@ -108,6 +110,6 @@ export declare function withDesktopApps<T extends {
|
|
|
108
110
|
* Doctor's extra checks for an Account's App Data (spec §3.20): the Desktop App's shared files under `link` rules,
|
|
109
111
|
* only when that Account's App Data folder exists, never for the Main Account (it uses the app's default) or an API
|
|
110
112
|
* Account. Results are named `app-data/<path>` and are not counted in `shared`. The App Data is the repair boundary:
|
|
111
|
-
* a fix may only add a missing link inside it, never replace a real file.
|
|
113
|
+
* a fix may only add a missing link inside it or sync a file's `key` there, never replace a real file.
|
|
112
114
|
*/
|
|
113
115
|
export declare function checkAppData(plugins: readonly LoadedPlugin[], home: string, account: Account): Promise<CheckResult[]>;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// program sideby runs here (`open`, `ps`, `osascript`, `plutil`, `mdfind`) goes through `DesktopDeps.exec`, so
|
|
4
4
|
// tests never start a real app.
|
|
5
5
|
import { execFile } from 'node:child_process';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
6
7
|
import { chmod, mkdir, readdir, readFile, symlink } from 'node:fs/promises';
|
|
7
8
|
import { basename, join } from 'node:path';
|
|
8
9
|
import { desktopDef, profileAppFor } from '../families/desktop.js';
|
|
@@ -28,12 +29,27 @@ const overrides = new WeakMap();
|
|
|
28
29
|
export function bindDesktopDeps(rt, deps) {
|
|
29
30
|
overrides.set(rt, deps);
|
|
30
31
|
}
|
|
32
|
+
/** This Linux runs under WSL: `WSL_DISTRO_NAME` is set, or the kernel says Microsoft. */
|
|
33
|
+
function isWsl() {
|
|
34
|
+
if (process.platform !== 'linux')
|
|
35
|
+
return false;
|
|
36
|
+
if (process.env.WSL_DISTRO_NAME)
|
|
37
|
+
return true;
|
|
38
|
+
try {
|
|
39
|
+
return /microsoft/i.test(readFileSync('/proc/sys/kernel/osrelease', 'utf8'));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
31
45
|
export function depsFor(rt) {
|
|
32
46
|
const o = overrides.get(rt) ?? {};
|
|
47
|
+
const wslMount = 'platform' in o || 'wslMount' in o ? o.wslMount : isWsl() ? '/mnt' : undefined;
|
|
33
48
|
return {
|
|
34
49
|
platform: o.platform ?? process.platform,
|
|
35
50
|
appDirs: o.appDirs ?? ['/Applications', join(rt.paths.home, 'Applications')],
|
|
36
51
|
exec: o.exec ?? defaultExec,
|
|
52
|
+
...(wslMount ? { wslMount } : {}),
|
|
37
53
|
};
|
|
38
54
|
}
|
|
39
55
|
/** The Desktop App of a Family, only when a built-in Plugin provides that Family (a plugin may not claim one). */
|
|
@@ -351,7 +367,7 @@ export async function withDesktopApps(rt, accounts, statuses) {
|
|
|
351
367
|
* Doctor's extra checks for an Account's App Data (spec §3.20): the Desktop App's shared files under `link` rules,
|
|
352
368
|
* only when that Account's App Data folder exists, never for the Main Account (it uses the app's default) or an API
|
|
353
369
|
* Account. Results are named `app-data/<path>` and are not counted in `shared`. The App Data is the repair boundary:
|
|
354
|
-
* a fix may only add a missing link inside it, never replace a real file.
|
|
370
|
+
* a fix may only add a missing link inside it or sync a file's `key` there, never replace a real file.
|
|
355
371
|
*/
|
|
356
372
|
export async function checkAppData(plugins, home, account) {
|
|
357
373
|
if (account.isMain || account.kind === 'api')
|
|
@@ -367,13 +383,18 @@ export async function checkAppData(plugins, home, account) {
|
|
|
367
383
|
const item = { path: f.path, mode: 'link' };
|
|
368
384
|
const mainPath = join(home, f.mainPath);
|
|
369
385
|
const p = join(dir, item.path);
|
|
370
|
-
const
|
|
386
|
+
const ctx = { account, item, mainPath, accountPath: p, accountDir: dir, force: false };
|
|
387
|
+
let res = await checkItem(ctx);
|
|
388
|
+
// The Desktop App writes its own file when the Main Account had none at first open, and replaces the link when
|
|
389
|
+
// it saves its preferences: not the user's doing, and a new link would be replaced again. With a `key`, only
|
|
390
|
+
// that key has to match the main file; `--fix` syncs it inside the App Data.
|
|
391
|
+
const own = res.results.some((r) => r.code === 'link.real-file');
|
|
392
|
+
if (own && f.key)
|
|
393
|
+
res = await checkItem({ ...ctx, item: { path: f.path, mode: 'json-key', key: f.key } });
|
|
371
394
|
for (const r of res.results) {
|
|
372
395
|
if (r.level === 'ok')
|
|
373
396
|
continue;
|
|
374
|
-
|
|
375
|
-
// later: not the user's doing, so a warning with the way to share, never a failure and never replaced.
|
|
376
|
-
const own = r.code === 'link.real-file'
|
|
397
|
+
const mine = r.code === 'link.real-file'
|
|
377
398
|
? {
|
|
378
399
|
level: 'warn',
|
|
379
400
|
message: `${item.path} is ${def.title}'s own copy for this account, not a link to the main account's`,
|
|
@@ -382,9 +403,9 @@ export async function checkAppData(plugins, home, account) {
|
|
|
382
403
|
: {};
|
|
383
404
|
out.push({
|
|
384
405
|
...r,
|
|
385
|
-
...
|
|
386
|
-
item: `app-data/${item.path}`,
|
|
387
|
-
message: `${def.title} data: ${
|
|
406
|
+
...mine,
|
|
407
|
+
item: `app-data/${r.item.startsWith(item.path) ? r.item : item.path}`,
|
|
408
|
+
message: `${def.title} data: ${mine.message ?? r.message}`,
|
|
388
409
|
});
|
|
389
410
|
}
|
|
390
411
|
}
|
|
@@ -16,6 +16,13 @@ export interface PreparedLaunch {
|
|
|
16
16
|
export type HostLaunch = Pick<PreparedLaunch, 'family' | 'bin' | 'args' | 'env' | 'title'>;
|
|
17
17
|
/** Finds an executable on PATH; returns null when the Host is not installed. */
|
|
18
18
|
export declare function which(bin: string, env: Env): Promise<string | null>;
|
|
19
|
+
/**
|
|
20
|
+
* The first PATH entry named `bin` that exists but cannot run, such as a link left dangling by an interrupted
|
|
21
|
+
* upgrade; null when there is none. Only meaningful when `which` found nothing.
|
|
22
|
+
*/
|
|
23
|
+
export declare function brokenOnPath(bin: string, env: Env): Promise<string | null>;
|
|
24
|
+
/** What to tell a user whose Host cannot run: the install command when the Family has one, else the page. */
|
|
25
|
+
export declare function installHint(family: Pick<FamilyDef, 'installUrl' | 'installCommand'>): string;
|
|
19
26
|
/**
|
|
20
27
|
* Why a Host could not run `sideby` by name, or null when it can: something the Host runs on its own (the status line
|
|
21
28
|
* tap, the handoff hook) needs sideby installed on PATH, not the npx cache, which can disappear. `why` completes
|
package/dist/src/core/launch.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawn } from 'node:child_process';
|
|
|
2
2
|
import { access, constants } from 'node:fs/promises';
|
|
3
3
|
import { constants as osConstants } from 'node:os';
|
|
4
4
|
import { delimiter, join } from 'node:path';
|
|
5
|
-
import { realpathOrNull } from './fs-safe.js';
|
|
5
|
+
import { lstatOrNull, realpathOrNull } from './fs-safe.js';
|
|
6
6
|
import { readSecretFile } from './secret-file.js';
|
|
7
7
|
/** Finds an executable on PATH; returns null when the Host is not installed. */
|
|
8
8
|
export async function which(bin, env) {
|
|
@@ -20,6 +20,26 @@ export async function which(bin, env) {
|
|
|
20
20
|
}
|
|
21
21
|
return null;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* The first PATH entry named `bin` that exists but cannot run, such as a link left dangling by an interrupted
|
|
25
|
+
* upgrade; null when there is none. Only meaningful when `which` found nothing.
|
|
26
|
+
*/
|
|
27
|
+
export async function brokenOnPath(bin, env) {
|
|
28
|
+
for (const dir of (env.PATH ?? '').split(delimiter)) {
|
|
29
|
+
if (!dir)
|
|
30
|
+
continue;
|
|
31
|
+
const p = join(dir, bin);
|
|
32
|
+
if (await lstatOrNull(p))
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/** What to tell a user whose Host cannot run: the install command when the Family has one, else the page. */
|
|
38
|
+
export function installHint(family) {
|
|
39
|
+
return family.installCommand
|
|
40
|
+
? `\`${family.installCommand}\` (or see ${family.installUrl})`
|
|
41
|
+
: family.installUrl;
|
|
42
|
+
}
|
|
23
43
|
/**
|
|
24
44
|
* Why a Host could not run `sideby` by name, or null when it can: something the Host runs on its own (the status line
|
|
25
45
|
* tap, the handoff hook) needs sideby installed on PATH, not the npx cache, which can disappear. `why` completes
|
|
@@ -141,7 +161,7 @@ export function spawnHost(p) {
|
|
|
141
161
|
child.once('error', (err) => {
|
|
142
162
|
cleanup();
|
|
143
163
|
const msg = err.code === 'ENOENT'
|
|
144
|
-
? `${p.family.title} is not installed (\`${p.bin}\` not found on PATH); install it: ${p.family
|
|
164
|
+
? `${p.family.title} is not installed (\`${p.bin}\` not found on PATH); install it: ${installHint(p.family)}`
|
|
145
165
|
: `could not start ${p.bin}: ${err.message}`;
|
|
146
166
|
process.stderr.write(`sideby: ${msg}\n`);
|
|
147
167
|
resolve(1);
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Files larger than this are skipped and reported. */
|
|
2
|
+
export declare const MAX_LEVELDB_FILE: number;
|
|
3
|
+
/** Snappy raw-format decompression (https://github.com/google/snappy/blob/main/format_description.txt). */
|
|
4
|
+
export declare function snappyDecompress(src: Uint8Array): Uint8Array;
|
|
5
|
+
/**
|
|
6
|
+
* The Local Storage key names of `origins` (`https://claude.ai`), from a `Local Storage/leveldb` directory. A key
|
|
7
|
+
* deleted later is left out. `changedAt` is the newest modification time of the files read. Files that are too large
|
|
8
|
+
* or unreadable go to `problems` (their name only).
|
|
9
|
+
*/
|
|
10
|
+
export declare function localStorageKeys(dir: string, origins: readonly string[]): Promise<{
|
|
11
|
+
keys: Map<string, Set<string>>;
|
|
12
|
+
changedAt?: number;
|
|
13
|
+
problems: string[];
|
|
14
|
+
}>;
|