sideby 0.15.0 → 0.16.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/dist/build.json CHANGED
@@ -1 +1 @@
1
- {"id":"20261017T115526-c6b039"}
1
+ {"id":"20261017T131419-98017e"}
@@ -71,6 +71,11 @@ export declare function cmdUi(a: ParsedArgs, io: Io): Promise<number>;
71
71
  export declare function cmdUpdate(a: ParsedArgs, io: Io): Promise<number>;
72
72
  /** The internal `sideby update-check` the CLI starts detached: one Update Check, then Auto Update if it is on. */
73
73
  export declare function cmdUpdateCheck(_a: ParsedArgs, _io: Io): Promise<number>;
74
+ /**
75
+ * `sideby local [on|off]` (ADR-0024): which side the global `sideby` runs, and a switch between the newest Local
76
+ * Build (`pnpm local` builds it) and the npm package. Shows the change first and exits 10 without `--yes`.
77
+ */
78
+ export declare function cmdLocal(a: ParsedArgs, io: Io): Promise<number>;
74
79
  export declare function cmdApp(a: ParsedArgs, io: Io): Promise<number>;
75
80
  /**
76
81
  * `sideby desktop <account>`: the Family's Desktop App as that Account (spec §3.20); `sideby desktop grok-bot:<name>`:
@@ -1346,6 +1346,89 @@ export async function cmdUpdateCheck(_a, _io) {
1346
1346
  await backgroundCheck(paths, process.env, config, () => backgroundPanelPort(paths));
1347
1347
  return 0;
1348
1348
  }
1349
+ /**
1350
+ * `sideby local [on|off]` (ADR-0024): which side the global `sideby` runs, and a switch between the newest Local
1351
+ * Build (`pnpm local` builds it) and the npm package. Shows the change first and exits 10 without `--yes`.
1352
+ */
1353
+ export async function cmdLocal(a, io) {
1354
+ const usage = 'usage: sideby local [on|off] [--yes] [--json]';
1355
+ const sub = a.positionals[0];
1356
+ if (a.positionals.length > 1 || (sub !== undefined && sub !== 'on' && sub !== 'off'))
1357
+ throw new UsageError(usage);
1358
+ const lb = await import('../core/local-build.js');
1359
+ const asJson = a.flags.has('json');
1360
+ const d = await lb.localDirs(process.env);
1361
+ if (!d)
1362
+ throw new UserError('`npm prefix -g` failed; switching needs npm on PATH and an npm global install of sideby');
1363
+ const s = await lb.localStatus(d);
1364
+ const now = new Date();
1365
+ const build = (l) => `${l.version}-dev · ${l.commit || 'no commit'}${l.dirty ? '+' : ''}${l.builtAt ? `, built ${clock(l.builtAt, now)}` : ''}`;
1366
+ if (sub === undefined) {
1367
+ if (asJson)
1368
+ json(io, { ...s, switched: false });
1369
+ else {
1370
+ io.out(s.running === 'local' && s.local
1371
+ ? `The global sideby runs the local build ${build(s.local)}.`
1372
+ : s.running === 'npm'
1373
+ ? 'The global sideby runs the npm package.'
1374
+ : 'The global sideby is not an npm global install; switching supports `npm install -g sideby` only.');
1375
+ if (s.running === 'local')
1376
+ io.out(c.dim('Back to the release: sideby local off'));
1377
+ else if (s.running === 'npm' && s.local)
1378
+ io.out(c.dim(`A local build is kept (${build(s.local)}): sideby local on`));
1379
+ }
1380
+ return 0;
1381
+ }
1382
+ const to = sub === 'on' ? 'local' : 'npm';
1383
+ const blocked = lb.switchBlocker(s, to);
1384
+ if (blocked) {
1385
+ if (asJson)
1386
+ json(io, { ...s, to, switched: false, blocked });
1387
+ else
1388
+ io.err(c.yellow(`sideby cannot switch: ${blocked}`));
1389
+ return 1;
1390
+ }
1391
+ if (s.running === to) {
1392
+ if (asJson)
1393
+ json(io, { ...s, to, switched: false });
1394
+ else
1395
+ io.out(`${c.green('✓')} The global sideby already runs the ${to === 'local' ? 'local build' : 'npm package'}.`);
1396
+ return 0;
1397
+ }
1398
+ if (!a.flags.has('yes')) {
1399
+ if (asJson)
1400
+ json(io, { ...s, to, switched: false });
1401
+ else {
1402
+ io.out(to === 'local' && s.local
1403
+ ? `Switching runs the local build ${build(s.local)} instead of the npm package. It is not rebuilt: \`pnpm local\` in the checkout builds a new one.`
1404
+ : `Switching goes back to the npm package: the kept ${s.kept ?? 'copy'} when it is the latest, else \`npm install -g sideby@latest\`.`);
1405
+ io.out(c.cyan(`\nRun it with \`sideby local ${sub} --yes\`.`));
1406
+ }
1407
+ return 10;
1408
+ }
1409
+ const { backgroundPanelPort } = await import('../panel/background.js');
1410
+ const paths = resolvePaths(process.env);
1411
+ const r = await lb.switchLocal(paths, d, to, process.env, {
1412
+ panelPort: () => backgroundPanelPort(paths),
1413
+ ...(asJson ? {} : { onOutput: (t) => process.stderr.write(t) }),
1414
+ });
1415
+ if (asJson)
1416
+ json(io, {
1417
+ ...(await lb.localStatus(d)),
1418
+ to,
1419
+ switched: r.ok,
1420
+ message: r.message,
1421
+ restartedPanel: r.restartedPanel,
1422
+ });
1423
+ else if (r.ok) {
1424
+ io.out(`${c.green('✓')} ${r.message}.`);
1425
+ if (r.restartedPanel)
1426
+ io.out(c.dim('The background panel restarted with it; open panels reload by themselves.'));
1427
+ }
1428
+ else
1429
+ io.err(c.red(`✗ ${r.message}`));
1430
+ return r.ok ? 0 : 1;
1431
+ }
1349
1432
  export async function cmdApp(a, io) {
1350
1433
  const sub = a.positionals[0];
1351
1434
  const usage = 'usage: sideby app install [--url <url>] | sideby app uninstall';
@@ -333,6 +333,25 @@ export const OUTPUT_SCHEMAS = {
333
333
  changed: Type.Boolean(),
334
334
  }),
335
335
  'update-auto': Type.Object({ schemaVersion: Version, auto: Type.Boolean(), changed: Type.Boolean() }),
336
+ local: Type.Object({
337
+ schemaVersion: Version,
338
+ running: Type.Union([Type.Literal('local'), Type.Literal('npm'), Type.Null()], {
339
+ description: 'What the global sideby runs: the Local Build, an npm package, or neither (not an npm global install).',
340
+ }),
341
+ local: Type.Union([Type.Object({ commit: Str, dirty: Type.Boolean(), builtAt: Str, version: Str }), Type.Null()], {
342
+ description: 'The newest Local Build, the one `on` switches to; null when `pnpm local` never ran.',
343
+ }),
344
+ kept: Type.Union([Str, Type.Null()], {
345
+ description: 'The npm package version kept aside while a Local Build runs.',
346
+ }),
347
+ to: Type.Optional(Type.Union([Type.Literal('local'), Type.Literal('npm')])),
348
+ switched: Type.Boolean({
349
+ description: 'A switch ran and succeeded; false for status, a preview (exit 10) or a failure.',
350
+ }),
351
+ blocked: Type.Optional(Type.String({ description: 'Why the switch cannot run, with what to do.' })),
352
+ message: Type.Optional(Str),
353
+ restartedPanel: Type.Optional(Type.Boolean()),
354
+ }, { title: 'sideby local --json' }),
336
355
  list: Type.Object({
337
356
  schemaVersion: Version,
338
357
  families: Type.Array(FamilyInfo),
@@ -72,10 +72,12 @@ Usage:
72
72
  rewrite the config's shellInitFile with them
73
73
  sideby plugins list loaded plugins and load errors
74
74
  sideby update [--yes] update sideby to the newest version (shows the command first)
75
+ sideby local [on|off] [--yes] for sideby developers: switch the global sideby between the
76
+ last local build (pnpm local) and the npm package
75
77
 
76
78
  Options:
77
79
  --json machine-readable output (list, new, next, alias, doctor, quota, tune, model, remote-control, handoff,
78
- families, plugins, app, desktop, browser, update)
80
+ families, plugins, app, desktop, browser, update, local)
79
81
  -h, --help show this help; \`sideby <command> --help\` for new, alias, login, doctor, families, desktop, browser
80
82
  -v, --version show the version
81
83
 
@@ -249,6 +251,7 @@ const COMMANDS = {
249
251
  plugins: { flags: ['json'] },
250
252
  families: { flags: ['json'] },
251
253
  update: { flags: ['json', 'yes'], valued: ['auto'] },
254
+ local: { flags: ['json', 'yes'] },
252
255
  'update-check': { flags: [] },
253
256
  };
254
257
  /** Commands that never print an Update Notice: their output is read by a shell or an agent, or they are internal. */
@@ -477,6 +480,8 @@ async function dispatch(cmd, parsed, io, cmds) {
477
480
  return await cmds.cmdUpdate(parsed, io);
478
481
  case 'update-check':
479
482
  return await cmds.cmdUpdateCheck(parsed, io);
483
+ case 'local':
484
+ return await cmds.cmdLocal(parsed, io);
480
485
  }
481
486
  return 2;
482
487
  }
@@ -0,0 +1,63 @@
1
+ import type { Env } from '../types.ts';
2
+ import type { Paths } from './paths.ts';
3
+ import { type LocalBuild } from './version.ts';
4
+ export interface LocalDirs {
5
+ /** `$XDG_DATA_HOME/sideby-dev`: `pkg` (the running build), `old/<id>`, `npm` (the package it replaced). */
6
+ dev: string;
7
+ /** npm's global prefix: `<prefix>/lib/node_modules/sideby` and `<prefix>/bin/sideby`. */
8
+ prefix: string;
9
+ }
10
+ export declare const pkgDir: (d: LocalDirs) => string;
11
+ export declare const globalPkg: (d: LocalDirs) => string;
12
+ export declare const devDir: (env: Env) => string;
13
+ /** Where the switch works, with npm's global prefix from `npm prefix -g`; null when npm cannot be asked. */
14
+ export declare function localDirs(env: Env): Promise<LocalDirs | null>;
15
+ export declare function kind(p: string): Promise<'link' | 'dir' | 'file' | null>;
16
+ /**
17
+ * Points the global npm package at `pkg`. A real npm install moves to `<dev>/npm` first, so going back needs no
18
+ * network; a link elsewhere (npm link) is replaced. Returns what it did, for the summary.
19
+ */
20
+ export declare function linkGlobal(d: LocalDirs): Promise<string>;
21
+ /** Undoes linkGlobal: the npm package it moved comes back; false when there is none to restore (install it). */
22
+ export declare function unlinkGlobal(d: LocalDirs): Promise<boolean>;
23
+ /** The version of the npm package kept aside by linkGlobal, or null when none was kept or it cannot be read. */
24
+ export declare const keptVersion: (d: LocalDirs) => Promise<string | null>;
25
+ /**
26
+ * Going back to npm: put the kept package back when it is the newest published one (or the registry cannot be
27
+ * asked), else install `latest`. After a release the kept copy is the version before it.
28
+ */
29
+ export declare function offPlan(kept: string | null, latest: string | null): 'restore' | 'install';
30
+ export interface LocalStatus {
31
+ /** What the global `sideby` runs: the Local Build in `pkg`, an npm package, or neither (not installed, a link elsewhere). */
32
+ running: 'local' | 'npm' | null;
33
+ /** The newest Local Build, the one `on` switches to; null when `pnpm local` never ran here. */
34
+ local: (LocalBuild & {
35
+ version: string;
36
+ }) | null;
37
+ /** The npm package kept aside while a Local Build runs; null when none is. */
38
+ kept: string | null;
39
+ }
40
+ export declare function localStatus(d: LocalDirs): Promise<LocalStatus>;
41
+ export type LocalTarget = 'local' | 'npm';
42
+ /** Why `to` cannot run from this status, or null when it can. Every reason says what to do. */
43
+ export declare function switchBlocker(s: LocalStatus, to: LocalTarget): string | null;
44
+ export interface SwitchResult {
45
+ ok: boolean;
46
+ message: string;
47
+ /** What the global `sideby` runs afterwards. */
48
+ running: LocalStatus['running'];
49
+ /** True when it already ran `to`, and nothing changed. */
50
+ unchanged?: boolean;
51
+ restartedPanel: boolean;
52
+ /** Another install held the lock; nothing ran. */
53
+ busy?: boolean;
54
+ }
55
+ /**
56
+ * Puts `to` behind the global `sideby`. `local` links the newest Local Build; `npm` restores the kept package or
57
+ * installs `latest` (a failed install links the Local Build back, so `sideby` never goes missing). Afterwards a
58
+ * background Panel on `panelPort` starts again from the new code. One install at a time (the Update lock).
59
+ */
60
+ export declare function switchLocal(paths: Paths, d: LocalDirs, to: LocalTarget, env: Env, opts?: {
61
+ panelPort?: () => Promise<number | null>;
62
+ onOutput?: (text: string) => void;
63
+ }): Promise<SwitchResult>;
@@ -0,0 +1,189 @@
1
+ // Switching the global `sideby` between a Local Build and the npm package (ADR-0024). `pnpm local` builds and calls
2
+ // these; `sideby local` and the Panel's version popover switch between the newest Local Build and npm without the
3
+ // checkout. Only an npm global install is handled: `<prefix>/lib/node_modules/sideby` is the folder every caller
4
+ // resolves, a link to `<dev>/pkg` while a Local Build runs.
5
+ import { lstat, mkdir, readFile, readlink, rename, rm, symlink } from 'node:fs/promises';
6
+ import { homedir } from 'node:os';
7
+ import { dirname, join, relative } from 'node:path';
8
+ import { compareVersions, fetchLatest, restartPanelByName, runProgram, takeInstallLock } from './update.js';
9
+ import { parseLocalBuild } from './version.js';
10
+ const BIN = 'dist/src/cli/main.js';
11
+ export const pkgDir = (d) => join(d.dev, 'pkg');
12
+ export const globalPkg = (d) => join(d.prefix, 'lib', 'node_modules', 'sideby');
13
+ const globalBin = (d) => join(d.prefix, 'bin', 'sideby');
14
+ export const devDir = (env) => join(env.XDG_DATA_HOME || join(env.HOME || homedir(), '.local', 'share'), 'sideby-dev');
15
+ /** Where the switch works, with npm's global prefix from `npm prefix -g`; null when npm cannot be asked. */
16
+ export async function localDirs(env) {
17
+ const r = await runProgram('npm', ['prefix', '-g'], { env }).catch(() => null);
18
+ const prefix = r && r.code === 0 ? r.output.trim().split('\n').at(-1)?.trim() : '';
19
+ return prefix ? { dev: devDir(env), prefix } : null;
20
+ }
21
+ export async function kind(p) {
22
+ try {
23
+ const s = await lstat(p);
24
+ return s.isSymbolicLink() ? 'link' : s.isDirectory() ? 'dir' : 'file';
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ /**
31
+ * Points the global npm package at `pkg`. A real npm install moves to `<dev>/npm` first, so going back needs no
32
+ * network; a link elsewhere (npm link) is replaced. Returns what it did, for the summary.
33
+ */
34
+ export async function linkGlobal(d) {
35
+ const target = globalPkg(d);
36
+ const pkg = pkgDir(d);
37
+ let did = 'the global sideby already runs the local build';
38
+ const k = await kind(target);
39
+ if (k !== 'link' || (await readlink(target)) !== pkg) {
40
+ if (k === 'dir') {
41
+ await rm(join(d.dev, 'npm'), { recursive: true, force: true });
42
+ await rename(target, join(d.dev, 'npm'));
43
+ did = `the npm package moved to ${join(d.dev, 'npm')}; the global sideby now runs the local build`;
44
+ }
45
+ else {
46
+ if (k)
47
+ await rm(target, { force: true });
48
+ did = 'the global sideby now runs the local build';
49
+ }
50
+ await mkdir(dirname(target), { recursive: true });
51
+ await symlink(pkg, target, 'dir');
52
+ }
53
+ const bin = globalBin(d);
54
+ const want = relative(dirname(bin), join(target, BIN));
55
+ if ((await kind(bin)) !== 'link' || (await readlink(bin)) !== want) {
56
+ await rm(bin, { force: true });
57
+ await mkdir(dirname(bin), { recursive: true });
58
+ await symlink(want, bin);
59
+ }
60
+ return did;
61
+ }
62
+ /** Undoes linkGlobal: the npm package it moved comes back; false when there is none to restore (install it). */
63
+ export async function unlinkGlobal(d) {
64
+ const target = globalPkg(d);
65
+ if ((await kind(target)) === 'link')
66
+ await rm(target, { force: true });
67
+ else if (await kind(target))
68
+ return true; // a real package is already there
69
+ if (!(await kind(join(d.dev, 'npm'))))
70
+ return false;
71
+ await rename(join(d.dev, 'npm'), target);
72
+ return true;
73
+ }
74
+ async function versionOf(dir) {
75
+ try {
76
+ const v = JSON.parse(await readFile(join(dir, 'package.json'), 'utf8')).version;
77
+ return typeof v === 'string' ? v : null;
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /** The version of the npm package kept aside by linkGlobal, or null when none was kept or it cannot be read. */
84
+ export const keptVersion = (d) => versionOf(join(d.dev, 'npm'));
85
+ /**
86
+ * Going back to npm: put the kept package back when it is the newest published one (or the registry cannot be
87
+ * asked), else install `latest`. After a release the kept copy is the version before it.
88
+ */
89
+ export function offPlan(kept, latest) {
90
+ if (!kept)
91
+ return 'install';
92
+ if (!latest)
93
+ return 'restore';
94
+ return (compareVersions(kept, latest) ?? -1) >= 0 ? 'restore' : 'install';
95
+ }
96
+ export async function localStatus(d) {
97
+ const k = await kind(globalPkg(d));
98
+ const running = k === 'link'
99
+ ? (await readlink(globalPkg(d))) === pkgDir(d)
100
+ ? 'local'
101
+ : null
102
+ : k === 'dir'
103
+ ? 'npm'
104
+ : null;
105
+ let local = null;
106
+ try {
107
+ const b = parseLocalBuild(JSON.parse(await readFile(join(pkgDir(d), 'dist', 'build.json'), 'utf8')));
108
+ const version = await versionOf(pkgDir(d));
109
+ if (b && version)
110
+ local = { ...b, version };
111
+ }
112
+ catch {
113
+ // no Local Build here
114
+ }
115
+ return { running, local, kept: await keptVersion(d) };
116
+ }
117
+ /** Why `to` cannot run from this status, or null when it can. Every reason says what to do. */
118
+ export function switchBlocker(s, to) {
119
+ if (s.running === null)
120
+ return 'the global sideby is not an npm global install; switching supports `npm install -g sideby` only';
121
+ if (to === 'local' && !s.local)
122
+ return 'there is no local build yet; run `pnpm local` in the sideby checkout';
123
+ return null;
124
+ }
125
+ /**
126
+ * Puts `to` behind the global `sideby`. `local` links the newest Local Build; `npm` restores the kept package or
127
+ * installs `latest` (a failed install links the Local Build back, so `sideby` never goes missing). Afterwards a
128
+ * background Panel on `panelPort` starts again from the new code. One install at a time (the Update lock).
129
+ */
130
+ export async function switchLocal(paths, d, to, env, opts = {}) {
131
+ const before = await localStatus(d);
132
+ const fail = (message, extra = {}) => ({
133
+ ok: false,
134
+ message,
135
+ running: before.running,
136
+ restartedPanel: false,
137
+ ...extra,
138
+ });
139
+ const blocked = switchBlocker(before, to);
140
+ if (blocked)
141
+ return fail(blocked);
142
+ if (before.running === to)
143
+ return {
144
+ ok: true,
145
+ unchanged: true,
146
+ message: to === 'local'
147
+ ? 'the global sideby already runs the local build'
148
+ : 'the global sideby already runs the npm package',
149
+ running: to,
150
+ restartedPanel: false,
151
+ };
152
+ const release = await takeInstallLock(paths);
153
+ if (!release)
154
+ return fail('a sideby update is running; wait for it to finish, then switch again', { busy: true });
155
+ let message;
156
+ try {
157
+ if (to === 'local') {
158
+ await linkGlobal(d);
159
+ message = `the global sideby runs the local build ${before.local?.commit ?? ''}`.trim();
160
+ }
161
+ else {
162
+ const latest = await fetchLatest(env).catch(() => null);
163
+ if (offPlan(before.kept, latest) === 'restore') {
164
+ await unlinkGlobal(d);
165
+ message = `the global sideby runs the npm package ${before.kept} again`;
166
+ }
167
+ else {
168
+ const want = latest ?? 'latest';
169
+ await rm(globalPkg(d), { force: true });
170
+ const r = await runProgram('npm', ['install', '-g', `sideby@${want}`], {
171
+ env,
172
+ ...(opts.onOutput ? { onOutput: opts.onOutput } : {}),
173
+ });
174
+ if (r.code !== 0) {
175
+ await linkGlobal(d);
176
+ return fail(`\`npm install -g sideby@${want}\` failed (exit ${r.code}); the local build stays. Run it yourself to see why`);
177
+ }
178
+ await rm(join(d.dev, 'npm'), { recursive: true, force: true });
179
+ message = `the global sideby runs the npm package ${want}`;
180
+ }
181
+ }
182
+ }
183
+ finally {
184
+ await release().catch(() => { });
185
+ }
186
+ const port = opts.panelPort ? await opts.panelPort().catch(() => null) : null;
187
+ const restartedPanel = port !== null && (await restartPanelByName(env, port));
188
+ return { ok: true, message, running: to, restartedPanel };
189
+ }
@@ -91,6 +91,12 @@ export interface UpdateDeps {
91
91
  }
92
92
  /** Internal, for tests: replaces parts of the machine. Not part of the plugin or Portal contract. */
93
93
  export declare function bindUpdateDeps(deps: Partial<UpdateDeps>): () => void;
94
+ /** Runs a program through the swappable machine (tests bind `exec`); a Local Build switch installs with it. */
95
+ export declare const runProgram: UpdateDeps['exec'];
96
+ /** Starts the background Panel again from the code `sideby` now names; see `UpdateDeps.restartPanel`. */
97
+ export declare const restartPanelByName: (env: Env, port: number) => Promise<boolean>;
98
+ /** The lock every install shares (`update-install.lock`), so a Local Build switch never races an Update. */
99
+ export declare const takeInstallLock: (paths: Paths) => Promise<(() => Promise<void>) | null>;
94
100
  /** -1, 0 or 1 like a sort comparator; a prerelease sorts before its release. Null when either is not a version. */
95
101
  export declare function compareVersions(a: string, b: string): number | null;
96
102
  /** `latest` is newer than `current`. */
@@ -102,6 +102,14 @@ export function bindUpdateDeps(deps) {
102
102
  };
103
103
  }
104
104
  const deps = () => ({ ...realDeps, ...override });
105
+ /** Runs a program through the swappable machine (tests bind `exec`); a Local Build switch installs with it. */
106
+ export const runProgram = (file, args, opts) => deps().exec(file, args, opts);
107
+ /** Starts the background Panel again from the code `sideby` now names; see `UpdateDeps.restartPanel`. */
108
+ export const restartPanelByName = (env, port) => deps()
109
+ .restartPanel(env, port)
110
+ .catch(() => false);
111
+ /** The lock every install shares (`update-install.lock`), so a Local Build switch never races an Update. */
112
+ export const takeInstallLock = (paths) => takeLock(paths, 'update-install.lock').catch(() => null);
105
113
  // Build metadata is limited to what semver allows, so a version never carries a terminal escape into the notice.
106
114
  const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
107
115
  function parseVersion(v) {
@@ -102,6 +102,11 @@ export interface PanelUpdate {
102
102
  * Local Build, which `pnpm local --off` brings back to npm together with the skill.
103
103
  */
104
104
  skillUpdate?: import('../core/update.ts').SkillUpdatePlan;
105
+ /**
106
+ * The switch between a Local Build and the npm package (ADR-0024): present only while a Local Build runs or one is
107
+ * kept next to an npm install, so a plain install never sees it.
108
+ */
109
+ localSwitch?: import('../core/local-build.ts').LocalStatus;
105
110
  }
106
111
  export interface PanelState {
107
112
  schemaVersion: 1;
@@ -76,8 +76,40 @@ async function updateState(rt, readOnly) {
76
76
  canRun: Boolean(plan?.canRun),
77
77
  skill: SKILL_INSTALL,
78
78
  ...(skillUpdate ? { skillUpdate } : {}),
79
+ ...(await localSwitchState(rt, method)),
79
80
  };
80
81
  }
82
+ // npm's global prefix, asked once per process: the page reads the state every 20 s and `npm prefix -g` takes a while.
83
+ let localDirsMemo = null;
84
+ async function localDirsFor(rt) {
85
+ const lb = await import('../core/local-build.js');
86
+ localDirsMemo ??= lb.localDirs(rt.env).then((d) => {
87
+ if (!d)
88
+ localDirsMemo = null; // ask again next time
89
+ return d;
90
+ });
91
+ return localDirsMemo;
92
+ }
93
+ // An npm install knows its prefix; only a running Local Build needs npm asked where the global package is.
94
+ async function switchDirs(rt, method) {
95
+ const lb = await import('../core/local-build.js');
96
+ return method.prefix ? { dev: lb.devDir(rt.env), prefix: method.prefix } : localDirsFor(rt);
97
+ }
98
+ async function localSwitchState(rt, method) {
99
+ if (method.kind !== 'local' && method.kind !== 'npm')
100
+ return {};
101
+ try {
102
+ const lb = await import('../core/local-build.js');
103
+ const d = await switchDirs(rt, method);
104
+ if (!d)
105
+ return {};
106
+ const s = await lb.localStatus(d);
107
+ return s.running === 'local' || (s.running === 'npm' && s.local) ? { localSwitch: s } : {};
108
+ }
109
+ catch {
110
+ return {};
111
+ }
112
+ }
81
113
  /** The `handoff` map of the Panel state, from the statuses and Quota already read for the page. */
82
114
  export function handoffByFamily(rt, accounts, now) {
83
115
  const out = {};
@@ -696,6 +728,37 @@ export function createPanelHandler(opts) {
696
728
  };
697
729
  },
698
730
  },
731
+ // The Local Build switch (ADR-0024): without `confirm` the current status; with it, `to` goes behind the global
732
+ // `sideby` and a background Panel restarts from it. The page then waits for the new build and reloads.
733
+ '/api/local': {
734
+ async run(body) {
735
+ if (!updates)
736
+ throw new HttpError(403, 'updates are managed by the page that shows this panel');
737
+ const to = body.to;
738
+ if (to !== 'local' && to !== 'npm')
739
+ throw new HttpError(400, '"to" must be "local" or "npm"');
740
+ const rt = await getRuntime();
741
+ const lb = await import('../core/local-build.js');
742
+ const d = await switchDirs(rt, await update.installMethod(rt.env));
743
+ if (!d)
744
+ throw new UserError('`npm prefix -g` failed; switching needs npm on PATH and an npm global install');
745
+ if (!optBool(body, 'confirm'))
746
+ return { ...(await lb.localStatus(d)), to, switched: false };
747
+ const { backgroundPanelPort } = await import('./background.js');
748
+ const r = await lb.switchLocal(rt.paths, d, to, rt.env, {
749
+ panelPort: () => backgroundPanelPort(rt.paths),
750
+ });
751
+ if (!r.ok)
752
+ throw new UserError(r.message);
753
+ return {
754
+ ...(await lb.localStatus(d)),
755
+ to,
756
+ switched: !r.unchanged,
757
+ message: r.message,
758
+ restartedPanel: r.restartedPanel,
759
+ };
760
+ },
761
+ },
699
762
  // The Auto Update switch: config `update.auto`, shown as a diff first like every config change.
700
763
  '/api/update/settings': {
701
764
  writes: (body) => body.confirm === true,
@@ -30,6 +30,13 @@ declare const en: {
30
30
  'upd.verTitle': string;
31
31
  'upd.local': string;
32
32
  'upd.localDirty': string;
33
+ 'loc.toNpm': string;
34
+ 'loc.toNpmHint': string;
35
+ 'loc.toLocal': string;
36
+ 'loc.toLocalHint': string;
37
+ 'loc.switching': string;
38
+ 'loc.stuck': string;
39
+ 'loc.reload': string;
33
40
  'upd.open': string;
34
41
  'upd.autoIsOn': string;
35
42
  'upd.autoIsOff': string;
@@ -32,6 +32,13 @@ const en = {
32
32
  'upd.verTitle': 'sideby {v} · check for updates',
33
33
  'upd.local': 'Local build from this checkout, built {at}.',
34
34
  'upd.localDirty': 'Local build from this checkout, with changes not committed yet, built {at}.',
35
+ 'loc.toNpm': 'Switch to the release',
36
+ 'loc.toNpmHint': 'Go back to the version published on npm; switch here again to come back.',
37
+ 'loc.toLocal': 'Switch to the local build',
38
+ 'loc.toLocalHint': 'A local build is kept: {b}, built {at}. It is not rebuilt; pnpm local in the checkout builds a new one.',
39
+ 'loc.switching': 'Switching…',
40
+ 'loc.stuck': 'Switched, but this page still runs the old code. Reload it; a panel started with sideby ui needs a restart.',
41
+ 'loc.reload': 'Reload page',
35
42
  'upd.open': 'Update…',
36
43
  'upd.autoIsOn': 'Auto Update: on',
37
44
  'upd.autoIsOff': 'Auto Update: off',
@@ -774,6 +781,13 @@ const zh = {
774
781
  'upd.verTitle': '当前 sideby {v} · 检查更新',
775
782
  'upd.local': '本地构建,来自仓库代码,构建于 {at}。',
776
783
  'upd.localDirty': '本地构建,含还没提交的改动,构建于 {at}。',
784
+ 'loc.toNpm': '切换到正式版',
785
+ 'loc.toNpmHint': '回到 npm 上发布的版本,之后可以在这里再切回来。',
786
+ 'loc.toLocal': '切换到本地构建',
787
+ 'loc.toLocalHint': '本机留着一份本地构建:{b},构建于 {at}。切换不会重新构建;要新的就在仓库里运行 pnpm local。',
788
+ 'loc.switching': '正在切换…',
789
+ 'loc.stuck': '已经切换,但这个页面还在跑旧代码。刷新页面;用 sideby ui 打开的面板要重启一下。',
790
+ 'loc.reload': '刷新页面',
777
791
  'upd.open': '更新…',
778
792
  'upd.autoIsOn': '自动更新:开',
779
793
  'upd.autoIsOff': '自动更新:关',
@@ -273,6 +273,7 @@ const MAIN = String.raw `
273
273
  x: 'M6 6l12 12M18 6L6 18',
274
274
  arrowUp: 'M12 19V5M5 12l7-7 7 7',
275
275
  arrowDown: 'M12 5v14M5 12l7 7 7-7',
276
+ swap: 'M7 4 3 8l4 4M3 8h14M17 20l4-4-4-4M21 16H7',
276
277
  // Lucide "pin" (ISC license)
277
278
  pin: 'M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z',
278
279
  bot: 'M6 9h12a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-6a2 2 0 0 1 2-2zM12 5v4M9.5 13.5v1M14.5 13.5v1',
@@ -3698,11 +3699,55 @@ const MAIN = String.raw `
3698
3699
  vp.checking ? spinner() : icon('refresh'), vp.checking ? t('upd.checking') : t('upd.check'))))
3699
3700
  }
3700
3701
  if (vp.error) kids.push(errorNote(vp.error, null))
3702
+ const sw = localSwitchKids(u)
3703
+ if (sw) kids.push(sw)
3701
3704
  if (u && !BOOT.readOnly && !isLocal(u))
3702
3705
  kids.push(h('p', { class: 'muted small popover-foot' }, t(u.auto ? 'upd.autoIsOn' : 'upd.autoIsOff'), ' · ',
3703
3706
  h('button', { class: 'link-btn', type: 'button', onclick: toDialog }, t('upd.settings'))))
3704
3707
  return kids
3705
3708
  }
3709
+ // The switch between a Local Build and the npm package (ADR-0024), only where the state offers it: the other side,
3710
+ // one button. After the switch the page waits for the server to run the new build and reloads into it.
3711
+ const vs = { busy: false, to: null, waitUntil: 0, stuck: false, error: null }
3712
+ function localSwitchKids(u) {
3713
+ const ls = u && u.localSwitch
3714
+ if (!ls || BOOT.readOnly) return null
3715
+ // While it switches the state already shows the other side; keep the words of the side clicked.
3716
+ const toNpm = vs.busy ? vs.to === 'npm' : ls.running === 'local'
3717
+ if (!toNpm && !ls.local) return null
3718
+ const l = ls.local
3719
+ const build = l ? l.version + '-dev · ' + (l.commit || '?') + (l.dirty ? '+' : '') : ''
3720
+ const kids = [h('p', { class: 'muted small' }, toNpm ? t('loc.toNpmHint') : t('loc.toLocalHint', { b: build, at: l.builtAt ? clock(l.builtAt) : '' }))]
3721
+ if (vs.stuck) {
3722
+ kids.push(h('p', { class: 'small' }, t('loc.stuck')))
3723
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm btn-primary', type: 'button', onclick: () => location.reload() }, icon('refresh'), t('loc.reload'))))
3724
+ } else {
3725
+ kids.push(h('div', { class: 'popover-actions' }, h('button', { class: 'btn btn-sm', type: 'button', disabled: vs.busy, 'aria-busy': vs.busy ? 'true' : null, onclick: () => localSwitchTo(toNpm ? 'npm' : 'local') },
3726
+ vs.busy ? spinner() : icon('swap'), vs.busy ? t('loc.switching') : t(toNpm ? 'loc.toNpm' : 'loc.toLocal'))))
3727
+ }
3728
+ if (vs.error) kids.push(errorNote(vs.error, null))
3729
+ return h('div', { class: 'popover-foot' }, kids)
3730
+ }
3731
+ async function localSwitchTo(to) {
3732
+ vs.busy = true
3733
+ vs.to = to
3734
+ vs.error = null
3735
+ vs.stuck = false
3736
+ renderVerPop()
3737
+ try {
3738
+ await api('/api/local', { to, confirm: true })
3739
+ } catch (e) {
3740
+ // A background Panel restarting from the new code may drop this answer: wait for the build all the same.
3741
+ if (!e.network) { vs.busy = false; vs.error = e; renderVerPop(); return }
3742
+ }
3743
+ vs.waitUntil = Date.now() + 20000
3744
+ localWait()
3745
+ }
3746
+ function localWait() {
3747
+ if (S.newBuild) { location.reload(); return }
3748
+ if (Date.now() > vs.waitUntil) { vs.busy = false; vs.stuck = true; renderVerPop(); return }
3749
+ setTimeout(() => { void load({ silent: true }).then(localWait, localWait) }, 1500)
3750
+ }
3706
3751
  async function verPopCheck() {
3707
3752
  vp.checking = true
3708
3753
  vp.error = null
@@ -200,8 +200,10 @@ code, .mono { font-family: var(--mono); font-size: 0.92em; }
200
200
  .tag-login { font-weight: 400; }
201
201
  .login-detail { flex-direction: column; gap: 8px; }
202
202
  .login-detail .rd-note { margin: 0; }
203
- .login-dirs { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--ctl-radius); background: var(--surface); }
204
- .login-dir { display: grid; grid-template-columns: minmax(120px, 1fr) auto var(--l-w) minmax(0, 1.4fr) auto auto auto; column-gap: 14px; align-items: center; padding: 6px 6px 6px 12px; }
203
+ /* The directories share one set of columns (subgrid), so a wider cell in one line moves every line alike. */
204
+ .login-dirs { display: grid; grid-template-columns: minmax(120px, 1fr) auto var(--l-w) minmax(0, 1.4fr) auto auto auto; column-gap: 14px; border: 1px solid var(--border); border-radius: var(--ctl-radius); background: var(--surface); }
205
+ .login-dir { grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; align-items: center; padding: 6px 6px 6px 12px; }
206
+ .login-dir > * { justify-self: start; min-width: 0; }
205
207
  .login-dir + .login-dir { border-top: 1px solid var(--border); }
206
208
  .login-dir .ld-name { display: flex; align-items: center; gap: 6px; min-width: 0; }
207
209
  .login-dir .ld-name .name { font-weight: var(--strong-weight); }
@@ -697,7 +699,8 @@ select:is(:hover, :focus) { --chevron: var(--text); }
697
699
  .stat:nth-child(n) { border-top: 0; }
698
700
  .stat:nth-child(4) { border-left: 1px solid var(--border); }
699
701
  .row-end .icon-btn { width: 36px; height: 36px; }
700
- .login-dir { grid-template-columns: minmax(0, 1fr) auto auto; row-gap: 6px; }
702
+ .login-dirs { grid-template-columns: minmax(0, 1fr); }
703
+ .login-dir { grid-template-columns: minmax(0, 1fr) auto auto; column-gap: 14px; row-gap: 6px; }
701
704
  .login-dir .usage, .login-dir .health { display: none; }
702
705
  .grid { grid-template-columns: 1fr; }
703
706
  .panel { padding: 14px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sideby",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Run work, personal and API-key accounts at the same time, without their configs drifting apart.",
5
5
  "license": "MIT",
6
6
  "author": "Adonis0123 (https://github.com/Adonis0123)",
@@ -0,0 +1,165 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "sideby/local.json",
4
+ "type": "object",
5
+ "required": [
6
+ "schemaVersion",
7
+ "running",
8
+ "local",
9
+ "kept",
10
+ "switched"
11
+ ],
12
+ "properties": {
13
+ "schemaVersion": {
14
+ "type": "number",
15
+ "const": 1
16
+ },
17
+ "running": {
18
+ "anyOf": [
19
+ {
20
+ "type": "string",
21
+ "const": "local"
22
+ },
23
+ {
24
+ "type": "string",
25
+ "const": "npm"
26
+ },
27
+ {
28
+ "type": "null"
29
+ }
30
+ ],
31
+ "description": "What the global sideby runs: the Local Build, an npm package, or neither (not an npm global install)."
32
+ },
33
+ "local": {
34
+ "anyOf": [
35
+ {
36
+ "type": "object",
37
+ "required": [
38
+ "commit",
39
+ "dirty",
40
+ "builtAt",
41
+ "version"
42
+ ],
43
+ "properties": {
44
+ "commit": {
45
+ "type": "string"
46
+ },
47
+ "dirty": {
48
+ "type": "boolean"
49
+ },
50
+ "builtAt": {
51
+ "type": "string"
52
+ },
53
+ "version": {
54
+ "type": "string"
55
+ }
56
+ }
57
+ },
58
+ {
59
+ "type": "null"
60
+ }
61
+ ],
62
+ "description": "The newest Local Build, the one `on` switches to; null when `pnpm local` never ran."
63
+ },
64
+ "kept": {
65
+ "anyOf": [
66
+ {
67
+ "type": "string"
68
+ },
69
+ {
70
+ "type": "null"
71
+ }
72
+ ],
73
+ "description": "The npm package version kept aside while a Local Build runs."
74
+ },
75
+ "to": {
76
+ "anyOf": [
77
+ {
78
+ "type": "string",
79
+ "const": "local"
80
+ },
81
+ {
82
+ "type": "string",
83
+ "const": "npm"
84
+ }
85
+ ]
86
+ },
87
+ "switched": {
88
+ "type": "boolean",
89
+ "description": "A switch ran and succeeded; false for status, a preview (exit 10) or a failure."
90
+ },
91
+ "blocked": {
92
+ "type": "string",
93
+ "description": "Why the switch cannot run, with what to do."
94
+ },
95
+ "message": {
96
+ "type": "string"
97
+ },
98
+ "restartedPanel": {
99
+ "type": "boolean"
100
+ },
101
+ "_notice": {
102
+ "type": "object",
103
+ "properties": {
104
+ "update": {
105
+ "type": "object",
106
+ "required": [
107
+ "current",
108
+ "latest",
109
+ "command",
110
+ "message"
111
+ ],
112
+ "properties": {
113
+ "current": {
114
+ "type": "string"
115
+ },
116
+ "latest": {
117
+ "type": "string"
118
+ },
119
+ "command": {
120
+ "type": "string",
121
+ "description": "Run it after the user agrees: it shows the plan and exits 10; then --yes."
122
+ },
123
+ "message": {
124
+ "type": "string"
125
+ }
126
+ }
127
+ },
128
+ "skill": {
129
+ "type": "object",
130
+ "required": [
131
+ "installed",
132
+ "current",
133
+ "command",
134
+ "message"
135
+ ],
136
+ "properties": {
137
+ "installed": {
138
+ "anyOf": [
139
+ {
140
+ "type": "string"
141
+ },
142
+ {
143
+ "type": "null"
144
+ }
145
+ ],
146
+ "description": "The skill's version; null when it has none."
147
+ },
148
+ "current": {
149
+ "type": "string"
150
+ },
151
+ "command": {
152
+ "type": "string",
153
+ "description": "`sideby update`, which updates the skill with sideby: run it after the user agrees; it shows the plan and exits 10, then --yes."
154
+ },
155
+ "message": {
156
+ "type": "string"
157
+ }
158
+ }
159
+ }
160
+ },
161
+ "description": "Not the answer to the task: mention it briefly afterwards and offer to update."
162
+ }
163
+ },
164
+ "title": "sideby local --json"
165
+ }
@@ -2,7 +2,7 @@
2
2
  name: sideby
3
3
  description: Use when the user runs, or wants to run, several AI coding accounts (Claude Code, Codex, Grok Build, pi) side by side. Covers installing sideby and setting up a new account ("install sideby", "set up another Claude account", "add cc008", "配置一个 cc00x", "再加一个 Codex 账号"), explaining what accounts share and what stays separate ("what do my accounts share?", "这些账号共享了什么"), showing which account a session is ("show [cc001] in the status line", "加上账号标识"), seeing accounts, quota and usage, moving on when one hits its 5-hour or weekly limit, moving the current task to another account ("用 cc008 继续", "交回 cc008"), adding a short command (alias), and repairing drifted account directories (skills, hooks, rules or settings not shared, wrong symlinks, credential file permissions). Drives the `sideby` CLI with `--json`; the agent does every step except browser sign-in, filling in secrets and starting interactive sessions, which it hands to the user as one command (inside an Auto Handoff session, `sideby handoff ready --to` moves the task itself).
4
4
  metadata:
5
- version: "0.15.0"
5
+ version: "0.16.0"
6
6
  requires:
7
7
  bins: [sideby, node]
8
8
  cliHelp: sideby --help
@@ -45,6 +45,7 @@ Account refs are `<family>:<name>`; a bare `<name>` works only when it is unique
45
45
  | Turn quota checks on or off, or refresh fetched quota | `sideby quota check <family> on|off --json`, `sideby quota [family] --refresh --json` | `references/quota.md` |
46
46
  | Set the model, effort or Remote Control | `sideby model <ref> --json`, `sideby remote-control --json` | `references/panel.md` |
47
47
  | Turn Auto Update on or off | `sideby update --auto on|off --json` | `references/update.md` |
48
+ | Switch between a local build of sideby and the release ("切换到正式版", "切回 dev 版本") | `sideby local --json`, `sideby local on|off --json` (exits 10 first) | `references/update.md` |
48
49
  | Repair drift or permissions | `sideby doctor [ref] --json`, then `--fix` after they agree | `references/diagnose.md` |
49
50
  | Keep working because an account hit its limit | `sideby next <family> --json` | `references/handoff.md` |
50
51
  | Turn on Auto Handoff ("自动交接", "hand over when quota runs low"), see what it still needs, hand this session over now, or pick an account with room for a subtask | `sideby handoff status --json`, `sideby handoff enable --json` (exit 10 first), `sideby handoff ready`, `sideby next <family> --json` | `references/handoff.md` |
@@ -18,3 +18,12 @@ What to do:
18
18
  ## Auto Update
19
19
 
20
20
  When the user asks to change Auto Update, run `sideby update --auto on|off --json`. It saves immediately and returns `auto` and `changed` (exit 0, including no-op). It keeps the Update Check setting and does not check or install in this invocation. It needs no `--yes`; ordinary `sideby update --json` still previews an installation with exit 10. Never enable Auto Update unless the user asks.
21
+
22
+ ## Local Build and the release
23
+
24
+ For people who work on sideby itself. A Local Build (`pnpm local` in a sideby checkout) runs the checkout's code behind the global `sideby`. When the user asks to go back to the release, or to the local build again ("切换到正式版", "切回 dev 版本"):
25
+
26
+ 1. Run `sideby local --json`. `running` is what runs now (`local` or `npm`), `local` the kept Local Build (`version`, `commit`, `dirty`, `builtAt`), `kept` the npm version set aside.
27
+ 2. Run `sideby local off --json` (back to npm) or `sideby local on --json` (the kept Local Build). It exits `10` with the change; after their yes, add `--yes`. `on` does not rebuild: `pnpm local` in the checkout makes a new build. `blocked` (exit 1) says why it cannot and what to do.
28
+ 3. A background panel restarts by itself and open panels reload; a `sideby ui` in a terminal needs a restart.
29
+