sideby 0.6.0 → 0.7.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 +3 -2
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +2 -0
- package/dist/src/cli/commands.js +29 -2
- package/dist/src/cli/json-schemas.js +22 -0
- package/dist/src/cli/main.js +26 -2
- package/dist/src/core/desktop-app.d.ts +96 -0
- package/dist/src/core/desktop-app.js +371 -0
- package/dist/src/core/doctor.d.ts +6 -1
- package/dist/src/core/doctor.js +19 -0
- package/dist/src/core/tuning-apply.js +1 -1
- package/dist/src/families/claude/desktop.d.ts +6 -0
- package/dist/src/families/claude/desktop.js +28 -0
- package/dist/src/families/codex/desktop.d.ts +7 -0
- package/dist/src/families/codex/desktop.js +25 -0
- package/dist/src/families/desktop.d.ts +27 -0
- package/dist/src/families/desktop.js +9 -0
- package/dist/src/panel/handler.js +24 -5
- package/dist/src/panel/i18n.d.ts +8 -0
- package/dist/src/panel/i18n.js +16 -0
- package/dist/src/panel/page-logic.js +2 -2
- package/dist/src/panel/page-script.js +29 -2
- package/dist/src/runtime.d.ts +16 -0
- package/dist/src/runtime.js +5 -0
- package/package.json +2 -2
- package/schemas/desktop.json +80 -0
- package/schemas/list.json +20 -0
- package/skills/sideby/SKILL.md +4 -2
package/README.md
CHANGED
|
@@ -76,7 +76,8 @@ The agent checks Node and your Host CLIs, installs sideby and the skill, creates
|
|
|
76
76
|
| Let a running task move to the next account by itself when quota runs low (off by default; see the risk) | `sideby handoff enable` (or the panel) | [Auto Handoff](docs/guide/usage.md#auto-handoff) |
|
|
77
77
|
| Short commands such as `ccw` | `sideby alias add ccw claude:work` | [Shell functions](docs/guide/configuration.md#shell-functions) |
|
|
78
78
|
| Reopen a session from Orca and the like in the account that holds it | `"resumeRouting": true` in config | [Resuming from other tools](docs/guide/configuration.md#resuming-from-other-tools) |
|
|
79
|
-
| Open
|
|
79
|
+
| Open Claude Desktop or the Codex app as one account, several side by side (macOS) | `sideby desktop claude:work` (or the panel) | [Desktop apps](docs/guide/usage.md#desktop-apps) |
|
|
80
|
+
| Open the panel from the Dock or app menu | `sideby app install` | [Panel app](docs/guide/panel.md#panel-app) |
|
|
80
81
|
| Show the panel inside your own local page | `createPanelHandler()` | [Embed the panel](docs/guide/panel.md#embed-the-panel) |
|
|
81
82
|
| Add a Host or a pre-launch check | a local plugin | [Plugins](docs/guide/plugins.md) |
|
|
82
83
|
|
|
@@ -114,7 +115,7 @@ Usage questions and ideas go in [Discussions](https://github.com/Adonis0123/side
|
|
|
114
115
|
- [Using sideby](docs/guide/usage.md): accounts, API accounts, Doctor, quota, Handoff, Host notes
|
|
115
116
|
- [Commands](docs/guide/commands.md): every command, exit codes and `--json`
|
|
116
117
|
- [Configuration](docs/guide/configuration.md): `config.json`, aliases, shell functions
|
|
117
|
-
- [Panel](docs/guide/panel.md): the panel,
|
|
118
|
+
- [Panel](docs/guide/panel.md): the panel, panel app and embedding
|
|
118
119
|
- [Plugins](docs/guide/plugins.md): write your own Family or launch hook
|
|
119
120
|
- [FAQ](docs/guide/faq.md): terms of service, data locations, alternatives, uninstall
|
|
120
121
|
- [Changelog](https://github.com/Adonis0123/sideby/releases)
|
package/dist/build.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"id":"
|
|
1
|
+
{"id":"20261014T084812-64e992"}
|
|
@@ -57,6 +57,8 @@ export declare function cmdPlugins(a: ParsedArgs, io: Io): Promise<number>;
|
|
|
57
57
|
export declare function cmdShellInit(a: ParsedArgs, io: Io): Promise<number>;
|
|
58
58
|
export declare function cmdUi(a: ParsedArgs, io: Io): Promise<number>;
|
|
59
59
|
export declare function cmdApp(a: ParsedArgs, io: Io): Promise<number>;
|
|
60
|
+
/** `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20). */
|
|
61
|
+
export declare function cmdDesktop(a: ParsedArgs, io: Io): Promise<number>;
|
|
60
62
|
export declare function cmdAlias(a: ParsedArgs, io: Io): Promise<number>;
|
|
61
63
|
/** `sideby families [family]`: each Family's layout, sign-in and Shared Items, from its FamilyDef (spec §3.16). */
|
|
62
64
|
export declare function cmdFamilies(a: ParsedArgs, io: Io): Promise<number>;
|
package/dist/src/cli/commands.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { fileURLToPath } from 'node:url';
|
|
2
2
|
import { aliasesByAccount } from '../core/accounts.js';
|
|
3
3
|
import { aliasRefs } from '../core/config.js';
|
|
4
|
+
import { openDesktopApp, withDesktopApps } from '../core/desktop-app.js';
|
|
4
5
|
import { UserError } from '../core/errors.js';
|
|
5
6
|
import { requestHandoff } from '../core/handoff-chain.js';
|
|
6
7
|
import { explain, readyDecision, runWithHandoff } from '../core/handoff-run.js';
|
|
@@ -53,7 +54,8 @@ function tilde(text, home) {
|
|
|
53
54
|
export async function cmdList(a, io) {
|
|
54
55
|
const rt = await createRuntime();
|
|
55
56
|
const accounts = await rt.accounts();
|
|
56
|
-
const statuses = await Promise.all(accounts.map((x) => rt.status(x)));
|
|
57
|
+
const statuses = await withDesktopApps(rt, accounts, Promise.all(accounts.map((x) => rt.status(x))));
|
|
58
|
+
const hasDesktop = statuses.some((s) => s.desktopApp);
|
|
57
59
|
const families = await rt.familyInfo();
|
|
58
60
|
if (a.flags.has('json')) {
|
|
59
61
|
json(io, { families, accounts: statuses, pluginErrors: [...rt.pluginErrors] });
|
|
@@ -82,8 +84,9 @@ export async function cmdList(a, io) {
|
|
|
82
84
|
s.identity?.email ?? c.dim('—'),
|
|
83
85
|
s.model ?? c.dim('—'),
|
|
84
86
|
s.hostInstalled ? tildify(s.dir, home) : `${tildify(s.dir, home)} ${c.yellow('(host not installed)')}`,
|
|
87
|
+
...(hasDesktop ? [s.desktopApp?.running ? c.green('open') : c.dim('—')] : []),
|
|
85
88
|
]);
|
|
86
|
-
io.out(table(rows, ['ACCOUNT', 'TYPE', 'LOGIN', 'EMAIL', 'MODEL', 'DIR']));
|
|
89
|
+
io.out(table(rows, ['ACCOUNT', 'TYPE', 'LOGIN', 'EMAIL', 'MODEL', 'DIR', ...(hasDesktop ? ['DESKTOP'] : [])]));
|
|
87
90
|
io.out(c.dim('\nsideby run <account> · sideby new <family> <name> · sideby quota · sideby ui'));
|
|
88
91
|
}
|
|
89
92
|
for (const s of statuses)
|
|
@@ -897,6 +900,30 @@ export async function cmdApp(a, io) {
|
|
|
897
900
|
}
|
|
898
901
|
return r.skipped.length ? 1 : 0;
|
|
899
902
|
}
|
|
903
|
+
/** `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20). */
|
|
904
|
+
export async function cmdDesktop(a, io) {
|
|
905
|
+
const [ref, ...extra] = a.positionals;
|
|
906
|
+
if (!ref || extra.length || a.rest.length)
|
|
907
|
+
throw new UsageError('usage: sideby desktop <account>');
|
|
908
|
+
const rt = await createRuntime();
|
|
909
|
+
const r = await openDesktopApp(rt, ref);
|
|
910
|
+
if (a.flags.has('json')) {
|
|
911
|
+
json(io, r);
|
|
912
|
+
return 0;
|
|
913
|
+
}
|
|
914
|
+
const home = rt.paths.home;
|
|
915
|
+
if (r.action === 'started')
|
|
916
|
+
io.out(`${c.green('✓')} opened ${r.app} as ${c.bold(r.ref)}`);
|
|
917
|
+
else if (r.action === 'focused')
|
|
918
|
+
io.out(`${c.green('✓')} ${r.app} is already open as ${c.bold(r.ref)}; brought it to the front`);
|
|
919
|
+
if (r.appData)
|
|
920
|
+
io.out(c.dim(` its data: ${tildify(r.appData, home)}`));
|
|
921
|
+
for (const f of r.linked)
|
|
922
|
+
io.out(c.dim(` linked ${f} to the main account's`));
|
|
923
|
+
for (const n of r.notices)
|
|
924
|
+
io.err(c.cyan(n));
|
|
925
|
+
return 0;
|
|
926
|
+
}
|
|
900
927
|
export async function cmdAlias(a, io) {
|
|
901
928
|
const [sub, alias, target, ...more] = a.positionals;
|
|
902
929
|
const usage = 'usage: sideby alias add <short-command> <account> [-- host args] | sideby alias rm <short-command>';
|
|
@@ -37,6 +37,9 @@ const AccountStatus = Type.Intersect([
|
|
|
37
37
|
description: "Who the account is signed in as, from the host's login file: identity fields only, never a token (ADR-0003).",
|
|
38
38
|
})),
|
|
39
39
|
problems: Type.Optional(Type.Array(Str)),
|
|
40
|
+
desktopApp: Type.Optional(Type.Object({ app: Str, installed: Type.Boolean(), running: Type.Boolean() }, {
|
|
41
|
+
description: "The family's desktop app for this account (macOS, subscription accounts of claude and codex): whether it is installed and whether this account's own window runs now. Open it with `sideby desktop <account>`.",
|
|
42
|
+
})),
|
|
40
43
|
}),
|
|
41
44
|
]);
|
|
42
45
|
const FamilyInfo = Type.Object({
|
|
@@ -395,6 +398,25 @@ export const OUTPUT_SCHEMAS = {
|
|
|
395
398
|
removed: Type.Array(Str),
|
|
396
399
|
skipped: Type.Array(Type.Object({ path: Str, reason: Str })),
|
|
397
400
|
}, { title: 'sideby app uninstall --json' }),
|
|
401
|
+
desktop: Type.Object({
|
|
402
|
+
schemaVersion: Version,
|
|
403
|
+
ref: Str,
|
|
404
|
+
family: Str,
|
|
405
|
+
app: Type.String({ description: 'The desktop app, for example `Claude Desktop`.' }),
|
|
406
|
+
appPath: Str,
|
|
407
|
+
action: Type.Union([Type.Literal('started'), Type.Literal('focused'), Type.Literal('already-running')], {
|
|
408
|
+
description: '`started`: a new window for this account; `focused`: its running window was brought to the front; `already-running`: it runs but could not be brought to the front.',
|
|
409
|
+
}),
|
|
410
|
+
appData: Type.Optional(Type.String({
|
|
411
|
+
description: "This account's app data folder; absent for the main account, which uses the app's default.",
|
|
412
|
+
})),
|
|
413
|
+
createdAppData: Type.Boolean(),
|
|
414
|
+
linked: Type.Array(Str, {
|
|
415
|
+
description: "Files linked into a new app data folder from the main account's (claude_desktop_config.json).",
|
|
416
|
+
}),
|
|
417
|
+
command: Type.Optional(Type.Array(Str, { description: 'The `open` command sideby ran.' })),
|
|
418
|
+
notices: Type.Array(Str),
|
|
419
|
+
}, { title: 'sideby desktop --json' }),
|
|
398
420
|
error: Type.Object({
|
|
399
421
|
schemaVersion: Version,
|
|
400
422
|
error: Type.String({ description: 'What went wrong and what to do next.' }),
|
package/dist/src/cli/main.js
CHANGED
|
@@ -45,6 +45,8 @@ Usage:
|
|
|
45
45
|
sideby ui [--port n] [--no-open] open the local panel
|
|
46
46
|
sideby ui --background same, without a terminal: keeps running after this command exits
|
|
47
47
|
sideby ui --stop stop the background panel
|
|
48
|
+
sideby desktop <account> open the desktop app (Claude Desktop, the Codex app) as this
|
|
49
|
+
account, next to the others (macOS)
|
|
48
50
|
sideby app install [--url <url>] add a sideby app that opens the panel (macOS, Linux)
|
|
49
51
|
sideby app uninstall remove that app
|
|
50
52
|
sideby shell-init zsh|bash print shell functions, e.g. eval "$(sideby shell-init zsh)"
|
|
@@ -53,8 +55,9 @@ Usage:
|
|
|
53
55
|
sideby plugins list loaded plugins and load errors
|
|
54
56
|
|
|
55
57
|
Options:
|
|
56
|
-
--json machine-readable output (list, new, next, alias, doctor, quota, tune, handoff, families, plugins, app
|
|
57
|
-
|
|
58
|
+
--json machine-readable output (list, new, next, alias, doctor, quota, tune, handoff, families, plugins, app,
|
|
59
|
+
desktop)
|
|
60
|
+
-h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families, desktop
|
|
58
61
|
-v, --version show the version
|
|
59
62
|
|
|
60
63
|
An <account> is <family>:<name> (claude:work) or just <name> when it is unique.
|
|
@@ -125,6 +128,24 @@ Examples:
|
|
|
125
128
|
sideby doctor --json
|
|
126
129
|
sideby doctor claude:008 --fix
|
|
127
130
|
Exit: 0 when nothing fails (warnings allowed); 1 when a check fails.`,
|
|
131
|
+
desktop: `sideby desktop <account> [--json]
|
|
132
|
+
|
|
133
|
+
Open the host's desktop app as one account: Claude Desktop for claude accounts, the Codex app (ChatGPT.app) for codex
|
|
134
|
+
accounts. Each account gets its own window, side by side with the others; macOS only for now.
|
|
135
|
+
|
|
136
|
+
The main account opens the app as usual. Any other subscription account opens a new window with that account's
|
|
137
|
+
directory (CLAUDE_CONFIG_DIR, CODEX_HOME) and its own app data folder,
|
|
138
|
+
~/Library/Application Support/sideby/<family>/<name>/. When that account's window is already open, sideby brings it
|
|
139
|
+
to the front instead of starting another one. API accounts are refused: the apps sign in with a subscription only.
|
|
140
|
+
|
|
141
|
+
sideby never copies or changes the app, and never closes a window. The first time, sign in inside Claude Desktop;
|
|
142
|
+
close other Claude Desktop windows while you do, because the sign-in may come back to one of them.
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
sideby desktop claude:work
|
|
146
|
+
sideby desktop cc008 --json
|
|
147
|
+
Exit: 0 opened or brought to the front; 1 not macOS, no desktop app for that host, API account, app not installed,
|
|
148
|
+
or the app did not open; 2 malformed command line.`,
|
|
128
149
|
families: `sideby families [family] [--json]
|
|
129
150
|
|
|
130
151
|
For each host (built in or from a plugin): where its accounts live, the variable that selects one, how to sign
|
|
@@ -163,6 +184,7 @@ const COMMANDS = {
|
|
|
163
184
|
},
|
|
164
185
|
ui: { flags: ['no-open', 'background', 'stop', 'serve-detached'], valued: ['port'] },
|
|
165
186
|
app: { flags: ['json'], valued: ['url'] },
|
|
187
|
+
desktop: { flags: ['json'] },
|
|
166
188
|
'shell-init': { flags: ['write'] },
|
|
167
189
|
plugins: { flags: ['json'] },
|
|
168
190
|
families: { flags: ['json'] },
|
|
@@ -266,6 +288,8 @@ export async function main(argv) {
|
|
|
266
288
|
return await cmds.cmdUi(parsed, io);
|
|
267
289
|
case 'app':
|
|
268
290
|
return await cmds.cmdApp(parsed, io);
|
|
291
|
+
case 'desktop':
|
|
292
|
+
return await cmds.cmdDesktop(parsed, io);
|
|
269
293
|
case 'shell-init':
|
|
270
294
|
return await cmds.cmdShellInit(parsed, io);
|
|
271
295
|
case 'plugins':
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { type DesktopAppDef } from '../families/desktop.ts';
|
|
2
|
+
import type { LoadedPlugin } from '../plugins/loader.ts';
|
|
3
|
+
import type { Runtime } from '../runtime.ts';
|
|
4
|
+
import type { Account, CheckResult, Env } from '../types.ts';
|
|
5
|
+
export interface ExecResult {
|
|
6
|
+
code: number;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
}
|
|
10
|
+
/** What opening a Desktop App needs from the machine; tests replace all of it. */
|
|
11
|
+
export interface DesktopDeps {
|
|
12
|
+
platform: NodeJS.Platform;
|
|
13
|
+
/** Folders searched for the app, in order (normally /Applications and ~/Applications). */
|
|
14
|
+
appDirs: readonly string[];
|
|
15
|
+
/** Runs a program and resolves with its exit code and output; never rejects. */
|
|
16
|
+
exec(cmd: string, args: readonly string[], env?: Env): Promise<ExecResult>;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Internal: makes every Desktop App call made with this Runtime use `deps` instead of the machine. Only
|
|
20
|
+
* `createRuntime` calls it, from its `desktop` option, which is the one way to replace the machine (tests).
|
|
21
|
+
*/
|
|
22
|
+
export declare function bindDesktopDeps(rt: Runtime, deps: Partial<DesktopDeps>): void;
|
|
23
|
+
/** The Desktop App of a Family, only when a built-in Plugin provides that Family (a plugin may not claim one). */
|
|
24
|
+
export declare function desktopAppOf(plugins: readonly LoadedPlugin[], familyId: string): DesktopAppDef | undefined;
|
|
25
|
+
/** Where a non-main Account's App Data lives: `~/Library/Application Support/sideby/<family>/<name>`. */
|
|
26
|
+
export declare function appDataDir(home: string, account: Pick<Account, 'family' | 'name'>): string;
|
|
27
|
+
export interface FoundApp {
|
|
28
|
+
path: string;
|
|
29
|
+
/** `Contents/MacOS/<executable>`: the app's main process runs this. */
|
|
30
|
+
executable: string;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Finds the app by bundle id: the usual names in the app folders first, then (with `spotlight`) Spotlight. Listing
|
|
34
|
+
* Accounts leaves Spotlight out to stay fast; opening one asks it too.
|
|
35
|
+
*/
|
|
36
|
+
export declare function findApp(def: DesktopAppDef, deps: DesktopDeps, opts?: {
|
|
37
|
+
spotlight?: boolean;
|
|
38
|
+
}): Promise<FoundApp | null>;
|
|
39
|
+
export interface Proc {
|
|
40
|
+
pid: number;
|
|
41
|
+
command: string;
|
|
42
|
+
}
|
|
43
|
+
/** The running instance of this Account: the one with its App Data, or for the Main Account the one without any. */
|
|
44
|
+
export declare function instanceOf(app: FoundApp, appData: string | null, procs: readonly Proc[]): Proc | undefined;
|
|
45
|
+
export interface DesktopOpenResult {
|
|
46
|
+
ref: string;
|
|
47
|
+
family: string;
|
|
48
|
+
/** The app as messages name it, for example `Claude Desktop`. */
|
|
49
|
+
app: string;
|
|
50
|
+
appPath: string;
|
|
51
|
+
/** `started`: a new instance; `focused`: the running one is in front; `already-running`: it runs but stayed behind. */
|
|
52
|
+
action: 'started' | 'focused' | 'already-running';
|
|
53
|
+
/** The Account's App Data; absent for the Main Account, which uses the app's default. */
|
|
54
|
+
appData?: string;
|
|
55
|
+
/** True when this call created the App Data folder. */
|
|
56
|
+
createdAppData: boolean;
|
|
57
|
+
/** Files linked into a new App Data from the Main Account's default one. */
|
|
58
|
+
linked: string[];
|
|
59
|
+
/** The `open` arguments, when sideby started the app. */
|
|
60
|
+
command?: string[];
|
|
61
|
+
/** What the user should know: sign in first, close other windows while signing in. */
|
|
62
|
+
notices: string[];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Opens the Account's Desktop App, or brings its running instance to the front. Never starts a second instance of the
|
|
66
|
+
* same Account, never ends a process, never touches the app bundle or the Host account directory.
|
|
67
|
+
*/
|
|
68
|
+
export declare function openDesktopApp(rt: Runtime, ref: string): Promise<DesktopOpenResult>;
|
|
69
|
+
export interface DesktopAppStatus {
|
|
70
|
+
/** The app as messages name it. */
|
|
71
|
+
app: string;
|
|
72
|
+
installed: boolean;
|
|
73
|
+
/** That Account's own instance runs now. */
|
|
74
|
+
running: boolean;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Desktop App state per Account ref for `sideby list` and the Panel: only on macOS, only Subscription Accounts of a
|
|
78
|
+
* Family that has one. One process listing for all of them; never throws.
|
|
79
|
+
*/
|
|
80
|
+
export declare function desktopStatuses(rt: Runtime, accounts: readonly Account[]): Promise<Map<string, DesktopAppStatus>>;
|
|
81
|
+
/**
|
|
82
|
+
* Adds `desktopApp` to each status whose Account has one, for `sideby list` and the Panel. The process listing runs
|
|
83
|
+
* while `statuses` is still being built; the order of the statuses is kept.
|
|
84
|
+
*/
|
|
85
|
+
export declare function withDesktopApps<T extends {
|
|
86
|
+
ref: string;
|
|
87
|
+
}>(rt: Runtime, accounts: readonly Account[], statuses: Promise<readonly T[]>): Promise<(T & {
|
|
88
|
+
desktopApp?: DesktopAppStatus;
|
|
89
|
+
})[]>;
|
|
90
|
+
/**
|
|
91
|
+
* Doctor's extra checks for an Account's App Data (spec §3.20): the Desktop App's shared files under `link` rules,
|
|
92
|
+
* only when that Account's App Data folder exists, never for the Main Account (it uses the app's default) or an API
|
|
93
|
+
* Account. Results are named `app-data/<path>` and are not counted in `shared`. The App Data is the repair boundary:
|
|
94
|
+
* a fix may only add a missing link inside it, never replace a real file.
|
|
95
|
+
*/
|
|
96
|
+
export declare function checkAppData(plugins: readonly LoadedPlugin[], home: string, account: Account): Promise<CheckResult[]>;
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// Opens a Family's Desktop App for one Account (spec §3.20, ADR-0015): macOS only, built-in claude and codex only.
|
|
2
|
+
// The app bundle is started unchanged with `open`; another Account's instance gets its own App Data folder. Every
|
|
3
|
+
// program sideby runs here (`open`, `ps`, `osascript`, `plutil`, `mdfind`) goes through `DesktopDeps.exec`, so
|
|
4
|
+
// tests never start a real app.
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { chmod, mkdir, readdir, readFile, symlink } from 'node:fs/promises';
|
|
7
|
+
import { basename, join } from 'node:path';
|
|
8
|
+
import { desktopDef } from '../families/desktop.js';
|
|
9
|
+
import { UserError } from './errors.js';
|
|
10
|
+
import { lstatOrNull, statOrNull } from './fs-safe.js';
|
|
11
|
+
import { checkItem } from './share-modes.js';
|
|
12
|
+
const OPEN = '/usr/bin/open';
|
|
13
|
+
const PS = '/bin/ps';
|
|
14
|
+
const OSASCRIPT = '/usr/bin/osascript';
|
|
15
|
+
const PLUTIL = '/usr/bin/plutil';
|
|
16
|
+
const MDFIND = '/usr/bin/mdfind';
|
|
17
|
+
function defaultExec(cmd, args, env) {
|
|
18
|
+
return new Promise((resolve) => execFile(cmd, [...args], { timeout: 15_000, maxBuffer: 16 * 1024 * 1024, ...(env ? { env: env } : {}) }, (err, stdout, stderr) => {
|
|
19
|
+
const code = err ? (typeof err.code === 'number' ? err.code : 1) : 0;
|
|
20
|
+
resolve({ code, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
23
|
+
const overrides = new WeakMap();
|
|
24
|
+
/**
|
|
25
|
+
* Internal: makes every Desktop App call made with this Runtime use `deps` instead of the machine. Only
|
|
26
|
+
* `createRuntime` calls it, from its `desktop` option, which is the one way to replace the machine (tests).
|
|
27
|
+
*/
|
|
28
|
+
export function bindDesktopDeps(rt, deps) {
|
|
29
|
+
overrides.set(rt, deps);
|
|
30
|
+
}
|
|
31
|
+
function depsFor(rt) {
|
|
32
|
+
const o = overrides.get(rt) ?? {};
|
|
33
|
+
return {
|
|
34
|
+
platform: o.platform ?? process.platform,
|
|
35
|
+
appDirs: o.appDirs ?? ['/Applications', join(rt.paths.home, 'Applications')],
|
|
36
|
+
exec: o.exec ?? defaultExec,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/** The Desktop App of a Family, only when a built-in Plugin provides that Family (a plugin may not claim one). */
|
|
40
|
+
export function desktopAppOf(plugins, familyId) {
|
|
41
|
+
const builtin = plugins.some((p) => p.source === 'builtin' && p.families.includes(familyId));
|
|
42
|
+
return builtin ? desktopDef(familyId) : undefined;
|
|
43
|
+
}
|
|
44
|
+
/** Where a non-main Account's App Data lives: `~/Library/Application Support/sideby/<family>/<name>`. */
|
|
45
|
+
export function appDataDir(home, account) {
|
|
46
|
+
return join(home, 'Library', 'Application Support', 'sideby', account.family, account.name);
|
|
47
|
+
}
|
|
48
|
+
const plistString = (xml, key) => {
|
|
49
|
+
const m = new RegExp(`<key>${key}</key>\\s*<string>([^<]*)</string>`).exec(xml);
|
|
50
|
+
return m?.[1];
|
|
51
|
+
};
|
|
52
|
+
/** Reads string keys of an app's Info.plist: XML directly, a binary plist through `plutil`. */
|
|
53
|
+
async function bundleInfo(deps, app) {
|
|
54
|
+
const file = join(app, 'Contents', 'Info.plist');
|
|
55
|
+
let text;
|
|
56
|
+
try {
|
|
57
|
+
text = await readFile(file, 'utf8');
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
if (text.startsWith('<?xml') || text.includes('<plist'))
|
|
63
|
+
return { id: plistString(text, 'CFBundleIdentifier'), exe: plistString(text, 'CFBundleExecutable') };
|
|
64
|
+
const read = async (key) => {
|
|
65
|
+
const r = await deps.exec(PLUTIL, ['-extract', key, 'raw', '-o', '-', file]);
|
|
66
|
+
return r.code === 0 ? r.stdout.trim() || undefined : undefined;
|
|
67
|
+
};
|
|
68
|
+
return { id: await read('CFBundleIdentifier'), exe: await read('CFBundleExecutable') };
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Finds the app by bundle id: the usual names in the app folders first, then (with `spotlight`) Spotlight. Listing
|
|
72
|
+
* Accounts leaves Spotlight out to stay fast; opening one asks it too.
|
|
73
|
+
*/
|
|
74
|
+
export async function findApp(def, deps, opts = {}) {
|
|
75
|
+
const check = async (path) => {
|
|
76
|
+
if (!(await statOrNull(path))?.isDirectory())
|
|
77
|
+
return null;
|
|
78
|
+
const info = await bundleInfo(deps, path);
|
|
79
|
+
if (info.id !== def.bundleId)
|
|
80
|
+
return null;
|
|
81
|
+
return { path, executable: info.exe || basename(path, '.app') };
|
|
82
|
+
};
|
|
83
|
+
for (const dir of deps.appDirs)
|
|
84
|
+
for (const name of def.bundleNames) {
|
|
85
|
+
const found = await check(join(dir, name));
|
|
86
|
+
if (found)
|
|
87
|
+
return found;
|
|
88
|
+
}
|
|
89
|
+
if (!opts.spotlight)
|
|
90
|
+
return null;
|
|
91
|
+
const r = await deps.exec(MDFIND, [`kMDItemCFBundleIdentifier == "${def.bundleId}"`]);
|
|
92
|
+
if (r.code !== 0)
|
|
93
|
+
return null;
|
|
94
|
+
for (const line of r.stdout.split('\n')) {
|
|
95
|
+
const p = line.trim();
|
|
96
|
+
if (!p.endsWith('.app') || p.includes('/.Trash/'))
|
|
97
|
+
continue;
|
|
98
|
+
const found = await check(p);
|
|
99
|
+
if (found)
|
|
100
|
+
return found;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
/** Every process with its command line, or null when `ps` failed (then nothing is known to run). */
|
|
105
|
+
async function processes(deps) {
|
|
106
|
+
const r = await deps.exec(PS, ['-axo', 'pid=,command=']);
|
|
107
|
+
if (r.code !== 0)
|
|
108
|
+
return null;
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const line of r.stdout.split('\n')) {
|
|
111
|
+
const m = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
112
|
+
if (m)
|
|
113
|
+
out.push({ pid: Number(m[1]), command: m[2].trimEnd() });
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
/** The arguments after the app's own executable, when `p` is the app's main process (not a helper). */
|
|
118
|
+
function mainArgs(app, p) {
|
|
119
|
+
const exe = `/${basename(app.path)}/Contents/MacOS/${app.executable}`;
|
|
120
|
+
const at = p.command.indexOf(exe);
|
|
121
|
+
if (at === -1)
|
|
122
|
+
return null;
|
|
123
|
+
const rest = p.command.slice(at + exe.length);
|
|
124
|
+
return rest === '' || rest.startsWith(' ') ? rest : null;
|
|
125
|
+
}
|
|
126
|
+
/** The running instance of this Account: the one with its App Data, or for the Main Account the one without any. */
|
|
127
|
+
export function instanceOf(app, appData, procs) {
|
|
128
|
+
return procs.find((p) => {
|
|
129
|
+
const args = mainArgs(app, p);
|
|
130
|
+
if (args === null)
|
|
131
|
+
return false;
|
|
132
|
+
if (appData === null)
|
|
133
|
+
return !args.includes('--user-data-dir=');
|
|
134
|
+
const flag = ` --user-data-dir=${appData}`;
|
|
135
|
+
const at = args.indexOf(flag);
|
|
136
|
+
if (at === -1)
|
|
137
|
+
return false;
|
|
138
|
+
const after = args.slice(at + flag.length);
|
|
139
|
+
return after === '' || after.startsWith(' ') || after === '/' || after.startsWith('/ ');
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
/** Brings a running app to the front through NSRunningApplication (no Automation permission needed). */
|
|
143
|
+
async function activate(deps, pid) {
|
|
144
|
+
const script = [
|
|
145
|
+
"ObjC.import('AppKit');",
|
|
146
|
+
`const app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(${Math.trunc(pid)});`,
|
|
147
|
+
"app.isNil() ? 'missing' : (app.activateWithOptions($.NSApplicationActivateAllWindows | $.NSApplicationActivateIgnoringOtherApps) ? 'ok' : 'refused')",
|
|
148
|
+
].join(' ');
|
|
149
|
+
const r = await deps.exec(OSASCRIPT, ['-l', 'JavaScript', '-e', script]);
|
|
150
|
+
return r.code === 0 && r.stdout.trim() === 'ok';
|
|
151
|
+
}
|
|
152
|
+
/** The environment `open` runs with: the caller's, without anything that would pick another Account (spec §3.5). */
|
|
153
|
+
function openEnv(base, family, def) {
|
|
154
|
+
const env = { ...base };
|
|
155
|
+
for (const v of family.hijackVars)
|
|
156
|
+
delete env[v];
|
|
157
|
+
delete env[family.selectVar];
|
|
158
|
+
for (const v of def.clearVars)
|
|
159
|
+
delete env[v];
|
|
160
|
+
for (const k of Object.keys(env))
|
|
161
|
+
if (k.startsWith('SIDEBY_') || isSessionVar(k))
|
|
162
|
+
delete env[k];
|
|
163
|
+
return env;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Variables an agent session sets for its own children; a Desktop App started from that session must not inherit
|
|
167
|
+
* them. Named one by one, so a user setting such as `CLAUDE_CODE_USE_BEDROCK` still reaches the app.
|
|
168
|
+
*/
|
|
169
|
+
const SESSION_PREFIXES = ['CODEX_COMPANION_', 'CODEX_SANDBOX'];
|
|
170
|
+
const SESSION_VARS = new Set([
|
|
171
|
+
'CLAUDE_CODE_SESSION_ID',
|
|
172
|
+
'CLAUDE_CODE_MESSAGING_TOKEN',
|
|
173
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
174
|
+
'CLAUDE_CODE_CHILD_SESSION',
|
|
175
|
+
'CLAUDE_PID',
|
|
176
|
+
'CLAUDE_PLUGIN_DATA',
|
|
177
|
+
'CLAUDECODE',
|
|
178
|
+
'CODEX_THREAD_ID',
|
|
179
|
+
]);
|
|
180
|
+
function isSessionVar(k) {
|
|
181
|
+
return SESSION_VARS.has(k) || SESSION_PREFIXES.some((p) => k.startsWith(p));
|
|
182
|
+
}
|
|
183
|
+
function notMac(platform) {
|
|
184
|
+
return new UserError(`opening a desktop app per account works on macOS only for now (this is ${platform}); use \`sideby run <account>\` for the terminal`, { code: 'not-macos' });
|
|
185
|
+
}
|
|
186
|
+
function notInstalled(def, deps) {
|
|
187
|
+
return new UserError(`${def.title} is not installed (looked in ${deps.appDirs.join(' and ')}); install it from ${def.installUrl}, then try again`, { code: 'app-not-installed' });
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Opens the Account's Desktop App, or brings its running instance to the front. Never starts a second instance of the
|
|
191
|
+
* same Account, never ends a process, never touches the app bundle or the Host account directory.
|
|
192
|
+
*/
|
|
193
|
+
export async function openDesktopApp(rt, ref) {
|
|
194
|
+
const deps = depsFor(rt);
|
|
195
|
+
const account = await rt.resolve(ref);
|
|
196
|
+
const family = rt.families.get(account.family);
|
|
197
|
+
const def = family ? desktopAppOf(rt.plugins, account.family) : undefined;
|
|
198
|
+
if (!family || !def) {
|
|
199
|
+
const have = [...rt.families.keys()].filter((id) => desktopAppOf(rt.plugins, id));
|
|
200
|
+
throw new UserError(`${account.ref}: ${family?.title ?? account.family} has no desktop app sideby can open${have.length ? ` (only ${have.join(' and ')} accounts do)` : ''}; use \`sideby run ${account.ref}\``, { code: 'no-desktop-app' });
|
|
201
|
+
}
|
|
202
|
+
if (account.kind === 'api')
|
|
203
|
+
throw new UserError(`${account.ref} is an API account; ${def.title} signs in with a subscription only, so open it for a subscription account, or use \`sideby run ${account.ref}\``, { code: 'api-account' });
|
|
204
|
+
if (deps.platform !== 'darwin')
|
|
205
|
+
throw notMac(deps.platform);
|
|
206
|
+
const app = await findApp(def, deps, { spotlight: true });
|
|
207
|
+
if (!app)
|
|
208
|
+
throw notInstalled(def, deps);
|
|
209
|
+
const home = rt.paths.home;
|
|
210
|
+
const appData = account.isMain ? null : appDataDir(home, account);
|
|
211
|
+
const listed = await processes(deps);
|
|
212
|
+
const procs = listed ?? [];
|
|
213
|
+
const base = {
|
|
214
|
+
ref: account.ref,
|
|
215
|
+
family: account.family,
|
|
216
|
+
app: def.title,
|
|
217
|
+
appPath: app.path,
|
|
218
|
+
...(appData ? { appData } : {}),
|
|
219
|
+
createdAppData: false,
|
|
220
|
+
linked: [],
|
|
221
|
+
notices: [],
|
|
222
|
+
};
|
|
223
|
+
const running = instanceOf(app, appData, procs);
|
|
224
|
+
if (running) {
|
|
225
|
+
const focused = await activate(deps, running.pid);
|
|
226
|
+
return {
|
|
227
|
+
...base,
|
|
228
|
+
action: focused ? 'focused' : 'already-running',
|
|
229
|
+
notices: focused
|
|
230
|
+
? []
|
|
231
|
+
: [
|
|
232
|
+
`${def.title} is already open for ${account.ref}; switch to it from the Dock (sideby does not start a second one)`,
|
|
233
|
+
],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
let created = false;
|
|
237
|
+
let fresh = false;
|
|
238
|
+
const linked = [];
|
|
239
|
+
if (appData) {
|
|
240
|
+
const st = await lstatOrNull(appData);
|
|
241
|
+
if (st && !st.isDirectory())
|
|
242
|
+
throw new UserError(`${appData} is not a folder; move it away, then try again (sideby keeps ${def.title}'s data for ${account.ref} there)`, { code: 'app-data-invalid' });
|
|
243
|
+
if (!st) {
|
|
244
|
+
await mkdir(appData, { recursive: true, mode: 0o700 });
|
|
245
|
+
await chmod(appData, 0o700);
|
|
246
|
+
created = true;
|
|
247
|
+
}
|
|
248
|
+
const shared = new Set(def.sharedFiles.map((f) => f.path));
|
|
249
|
+
fresh = created || (await readdir(appData)).every((e) => shared.has(e));
|
|
250
|
+
if (fresh)
|
|
251
|
+
for (const f of def.sharedFiles) {
|
|
252
|
+
const target = join(appData, f.path);
|
|
253
|
+
const main = join(home, f.mainPath);
|
|
254
|
+
// Never replace anything: link only where nothing is, and only to a file the Main Account has.
|
|
255
|
+
if ((await lstatOrNull(target)) || !(await statOrNull(main))?.isFile())
|
|
256
|
+
continue;
|
|
257
|
+
await symlink(main, target);
|
|
258
|
+
linked.push(f.path);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
const notices = [];
|
|
262
|
+
if (!account.isMain && def.signInInApp && fresh) {
|
|
263
|
+
const others = procs.some((p) => mainArgs(app, p) !== null);
|
|
264
|
+
notices.push(others
|
|
265
|
+
? `First time for ${account.ref}: sign in inside ${def.title} with the account you use for ${account.ref}, not one it suggests from another window. Close the other ${def.title} windows while you sign in, because the browser may send the sign-in back to one of them`
|
|
266
|
+
: `First time for ${account.ref}: sign in inside ${def.title} with the account you use for ${account.ref}, not one it suggests from another window`);
|
|
267
|
+
}
|
|
268
|
+
if (!def.signInInApp) {
|
|
269
|
+
const login = await Promise.resolve()
|
|
270
|
+
.then(() => family.loginState?.(account))
|
|
271
|
+
.catch(() => undefined);
|
|
272
|
+
if (login === 'logged-out')
|
|
273
|
+
notices.push(`${account.ref} is not signed in yet; run \`sideby login ${account.ref}\` first. If ${def.title} offers "Welcome back" with your main account's email, do not pick it: that signs this account in as the main one. Choose to use another account`);
|
|
274
|
+
}
|
|
275
|
+
// The Main Account needs `-n` too: without it, `open` brings forward whichever instance runs, often another
|
|
276
|
+
// Account's window. A second Main instance cannot start here, since a running one was found above.
|
|
277
|
+
// Without a process list a running Main instance cannot be ruled out, so plain `open -a` (no second instance).
|
|
278
|
+
const mainArgsList = listed ? ['-n', '-a', app.path] : ['-a', app.path];
|
|
279
|
+
const args = appData ? def.openArgs(app.path, account.dir, appData) : mainArgsList;
|
|
280
|
+
const r = await deps.exec(OPEN, args, openEnv(rt.env, family, def));
|
|
281
|
+
if (r.code !== 0)
|
|
282
|
+
throw new UserError(`could not open ${def.title} for ${account.ref}: ${r.stderr.trim() || `open exited with ${r.code}`}; try opening ${app.path} once by hand, then try again`, { code: 'open-failed' });
|
|
283
|
+
return { ...base, action: 'started', createdAppData: created, linked, command: [OPEN, ...args], notices };
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Desktop App state per Account ref for `sideby list` and the Panel: only on macOS, only Subscription Accounts of a
|
|
287
|
+
* Family that has one. One process listing for all of them; never throws.
|
|
288
|
+
*/
|
|
289
|
+
export async function desktopStatuses(rt, accounts) {
|
|
290
|
+
const out = new Map();
|
|
291
|
+
const deps = depsFor(rt);
|
|
292
|
+
if (deps.platform !== 'darwin')
|
|
293
|
+
return out;
|
|
294
|
+
try {
|
|
295
|
+
const eligible = accounts.filter((a) => a.kind !== 'api' && desktopAppOf(rt.plugins, a.family));
|
|
296
|
+
if (!eligible.length)
|
|
297
|
+
return out;
|
|
298
|
+
const apps = new Map();
|
|
299
|
+
for (const id of new Set(eligible.map((a) => a.family)))
|
|
300
|
+
apps.set(id, await findApp(desktopDef(id), deps));
|
|
301
|
+
const procs = [...apps.values()].some(Boolean) ? ((await processes(deps)) ?? []) : [];
|
|
302
|
+
for (const a of eligible) {
|
|
303
|
+
const app = apps.get(a.family) ?? null;
|
|
304
|
+
out.set(a.ref, {
|
|
305
|
+
app: desktopDef(a.family).title,
|
|
306
|
+
installed: app !== null,
|
|
307
|
+
running: app
|
|
308
|
+
? Boolean(instanceOf(app, a.isMain ? null : appDataDir(rt.paths.home, a), procs))
|
|
309
|
+
: false,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
// A display nicety: a failure leaves the field out.
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Adds `desktopApp` to each status whose Account has one, for `sideby list` and the Panel. The process listing runs
|
|
320
|
+
* while `statuses` is still being built; the order of the statuses is kept.
|
|
321
|
+
*/
|
|
322
|
+
export async function withDesktopApps(rt, accounts, statuses) {
|
|
323
|
+
const [list, desktop] = await Promise.all([statuses, desktopStatuses(rt, accounts)]);
|
|
324
|
+
return list.map((s) => {
|
|
325
|
+
const d = desktop.get(s.ref);
|
|
326
|
+
return d ? { ...s, desktopApp: d } : s;
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Doctor's extra checks for an Account's App Data (spec §3.20): the Desktop App's shared files under `link` rules,
|
|
331
|
+
* only when that Account's App Data folder exists, never for the Main Account (it uses the app's default) or an API
|
|
332
|
+
* Account. Results are named `app-data/<path>` and are not counted in `shared`. The App Data is the repair boundary:
|
|
333
|
+
* a fix may only add a missing link inside it, never replace a real file.
|
|
334
|
+
*/
|
|
335
|
+
export async function checkAppData(plugins, home, account) {
|
|
336
|
+
if (account.isMain || account.kind === 'api')
|
|
337
|
+
return [];
|
|
338
|
+
const def = desktopAppOf(plugins, account.family);
|
|
339
|
+
if (!def?.sharedFiles.length)
|
|
340
|
+
return [];
|
|
341
|
+
const dir = appDataDir(home, account);
|
|
342
|
+
if (!(await lstatOrNull(dir))?.isDirectory())
|
|
343
|
+
return [];
|
|
344
|
+
const out = [];
|
|
345
|
+
for (const f of def.sharedFiles) {
|
|
346
|
+
const item = { path: f.path, mode: 'link' };
|
|
347
|
+
const mainPath = join(home, f.mainPath);
|
|
348
|
+
const p = join(dir, item.path);
|
|
349
|
+
const res = await checkItem({ account, item, mainPath, accountPath: p, accountDir: dir, force: false });
|
|
350
|
+
for (const r of res.results) {
|
|
351
|
+
if (r.level === 'ok')
|
|
352
|
+
continue;
|
|
353
|
+
// The Desktop App writes its own file when the Main Account had none at first open, and may replace the link
|
|
354
|
+
// later: not the user's doing, so a warning with the way to share, never a failure and never replaced.
|
|
355
|
+
const own = r.code === 'link.real-file'
|
|
356
|
+
? {
|
|
357
|
+
level: 'warn',
|
|
358
|
+
message: `${item.path} is ${def.title}'s own copy for this account, not a link to the main account's`,
|
|
359
|
+
hint: `${def.title} made it itself; that is fine. To share the main account's instead, quit that window, then: mv "${p}" "${p}.local" && ln -s "${mainPath}" "${p}"`,
|
|
360
|
+
}
|
|
361
|
+
: {};
|
|
362
|
+
out.push({
|
|
363
|
+
...r,
|
|
364
|
+
...own,
|
|
365
|
+
item: `app-data/${item.path}`,
|
|
366
|
+
message: `${def.title} data: ${own.message ?? r.message}`,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
return out;
|
|
371
|
+
}
|