sideby 0.12.0 → 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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +4 -2
  4. package/dist/src/cli/commands.js +117 -17
  5. package/dist/src/cli/json-schemas.js +52 -1
  6. package/dist/src/cli/main.js +34 -3
  7. package/dist/src/core/brand-mark.d.ts +5 -7
  8. package/dist/src/core/brand-mark.js +18 -5
  9. package/dist/src/core/browser-profiles.d.ts +71 -0
  10. package/dist/src/core/browser-profiles.js +361 -0
  11. package/dist/src/core/config.d.ts +15 -0
  12. package/dist/src/core/config.js +13 -1
  13. package/dist/src/core/create.js +2 -1
  14. package/dist/src/core/desktop-app.d.ts +3 -1
  15. package/dist/src/core/desktop-app.js +29 -8
  16. package/dist/src/core/launch.d.ts +7 -0
  17. package/dist/src/core/launch.js +22 -2
  18. package/dist/src/core/leveldb-keys.d.ts +14 -0
  19. package/dist/src/core/leveldb-keys.js +291 -0
  20. package/dist/src/core/remote-control.d.ts +17 -6
  21. package/dist/src/core/remote-control.js +40 -17
  22. package/dist/src/core/update.d.ts +10 -3
  23. package/dist/src/core/update.js +38 -3
  24. package/dist/src/core/version.d.ts +11 -0
  25. package/dist/src/core/version.js +26 -0
  26. package/dist/src/desktop/app.d.ts +2 -0
  27. package/dist/src/desktop/app.js +39 -3
  28. package/dist/src/families/browser.d.ts +20 -0
  29. package/dist/src/families/browser.js +23 -0
  30. package/dist/src/families/claude/browser.d.ts +3 -0
  31. package/dist/src/families/claude/browser.js +14 -0
  32. package/dist/src/families/claude/desktop.js +1 -0
  33. package/dist/src/families/codex/browser.d.ts +7 -0
  34. package/dist/src/families/codex/browser.js +26 -0
  35. package/dist/src/families/desktop.d.ts +5 -1
  36. package/dist/src/families/pi/index.js +2 -0
  37. package/dist/src/panel/handler.d.ts +6 -0
  38. package/dist/src/panel/handler.js +49 -6
  39. package/dist/src/panel/i18n.d.ts +75 -4
  40. package/dist/src/panel/i18n.js +162 -20
  41. package/dist/src/panel/page-logic.d.ts +23 -0
  42. package/dist/src/panel/page-logic.js +44 -0
  43. package/dist/src/panel/page-script.js +609 -180
  44. package/dist/src/panel/page-styles.js +72 -17
  45. package/dist/src/panel/page.d.ts +5 -0
  46. package/dist/src/panel/page.js +10 -9
  47. package/dist/src/runtime.d.ts +6 -1
  48. package/dist/src/runtime.js +22 -11
  49. package/dist/src/types.d.ts +2 -0
  50. package/package.json +5 -3
  51. package/schemas/browser-open.json +47 -0
  52. package/schemas/browser-switch.json +42 -0
  53. package/schemas/browser.json +99 -0
  54. package/schemas/config.json +11 -0
  55. package/schemas/families.json +6 -0
  56. package/schemas/list.json +6 -0
  57. package/schemas/remote-control.json +4 -0
  58. package/schemas/update.json +4 -0
  59. package/skills/sideby/SKILL.md +5 -3
  60. 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
@@ -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 `update`, spec §3.10), and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
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.
@@ -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.installUrl}) and run it once first`);
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 res = await checkItem({ account, item, mainPath, accountPath: p, accountDir: dir, force: false });
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
- // The Desktop App writes its own file when the Main Account had none at first open, and may replace the link
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
- ...own,
386
- item: `app-data/${item.path}`,
387
- message: `${def.title} data: ${own.message ?? r.message}`,
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
@@ -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.installUrl}`
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
+ }>;