sideby 0.16.0 → 0.17.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 +5 -3
- package/dist/build.json +1 -1
- package/dist/src/cli/commands.d.ts +16 -0
- package/dist/src/cli/commands.js +460 -5
- package/dist/src/cli/json-schemas.js +115 -0
- package/dist/src/cli/main.js +93 -3
- package/dist/src/core/ask.d.ts +150 -0
- package/dist/src/core/ask.js +365 -0
- package/dist/src/core/attachments.d.ts +50 -0
- package/dist/src/core/attachments.js +442 -0
- package/dist/src/core/autostart.d.ts +88 -0
- package/dist/src/core/autostart.js +256 -0
- package/dist/src/core/config.d.ts +97 -0
- package/dist/src/core/config.js +278 -10
- package/dist/src/core/launch.d.ts +15 -4
- package/dist/src/core/launch.js +27 -23
- package/dist/src/core/local-build.d.ts +17 -0
- package/dist/src/core/local-build.js +39 -3
- package/dist/src/core/probe.d.ts +78 -0
- package/dist/src/core/probe.js +127 -0
- package/dist/src/core/same-login.d.ts +9 -4
- package/dist/src/core/same-login.js +9 -4
- package/dist/src/core/secret-file.d.ts +26 -0
- package/dist/src/core/secret-file.js +126 -7
- package/dist/src/core/terminal.d.ts +93 -0
- package/dist/src/core/terminal.js +371 -0
- package/dist/src/core/update.d.ts +17 -0
- package/dist/src/core/update.js +63 -1
- package/dist/src/families/claude/index.js +3 -0
- package/dist/src/families/claude/quota-cache.d.ts +24 -7
- package/dist/src/families/claude/quota-cache.js +72 -21
- package/dist/src/families/codex/index.js +7 -0
- package/dist/src/families/codex/login-key.d.ts +8 -0
- package/dist/src/families/codex/login-key.js +33 -0
- package/dist/src/families/grok/index.js +3 -0
- package/dist/src/families/login-keys.d.ts +6 -0
- package/dist/src/families/login-keys.js +6 -0
- package/dist/src/families/pi/index.js +4 -0
- package/dist/src/panel/background.js +3 -2
- package/dist/src/panel/handler.d.ts +29 -2
- package/dist/src/panel/handler.js +396 -9
- package/dist/src/panel/i18n.d.ts +175 -2
- package/dist/src/panel/i18n.js +381 -35
- package/dist/src/panel/page-logic.d.ts +214 -0
- package/dist/src/panel/page-logic.js +394 -0
- package/dist/src/panel/page-script.js +984 -134
- package/dist/src/panel/page-styles.js +69 -19
- package/dist/src/panel/page.d.ts +5 -0
- package/dist/src/panel/page.js +2 -0
- package/dist/src/panel/server.d.ts +16 -2
- package/dist/src/panel/server.js +41 -13
- package/dist/src/plugins/check.d.ts +47 -0
- package/dist/src/plugins/check.js +544 -0
- package/dist/src/plugins/scaffold.d.ts +21 -0
- package/dist/src/plugins/scaffold.js +123 -0
- package/dist/src/quota/statusline-tap.js +1 -1
- package/dist/src/runtime.d.ts +10 -0
- package/dist/src/runtime.js +47 -16
- package/dist/src/types.d.ts +19 -0
- package/package.json +2 -2
- package/schemas/ask.json +180 -0
- package/schemas/autostart.json +165 -0
- package/schemas/config-get.json +132 -0
- package/schemas/config-set.json +107 -0
- package/schemas/config.json +73 -6
- package/schemas/key.json +93 -0
- package/schemas/local.json +4 -0
- package/schemas/plugins-check.json +113 -0
- package/schemas/plugins-new.json +87 -0
- package/schemas/terminal.json +152 -0
- package/schemas/try.json +109 -0
- package/schemas/update.json +4 -0
- package/skills/sideby/SKILL.md +12 -6
- package/skills/sideby/references/accounts.md +1 -1
- package/skills/sideby/references/ai-tasks.md +116 -0
- package/skills/sideby/references/panel.md +14 -0
- package/skills/sideby/references/update.md +1 -1
package/dist/src/core/config.js
CHANGED
|
@@ -68,15 +68,45 @@ const HandoffSchema = Type.Object({
|
|
|
68
68
|
description: 'Accounts (ref or short command) that may take over from an account of another organization.',
|
|
69
69
|
})),
|
|
70
70
|
families: Type.Optional(Type.Record(Type.String(), Type.Object({
|
|
71
|
-
policy: Type.Optional(Type.Union([Type.Literal('pressure'), Type.Literal('order')]
|
|
72
|
-
|
|
71
|
+
policy: Type.Optional(Type.Union([Type.Literal('pressure'), Type.Literal('order')], {
|
|
72
|
+
description: '`pressure` picks the account with the most Quota left; `order` follows `order`.',
|
|
73
|
+
})),
|
|
74
|
+
order: Type.Optional(Type.Array(Type.String(), { description: 'Accounts (ref or short command) to try, in order.' })),
|
|
73
75
|
}, { additionalProperties: false }), {
|
|
74
76
|
description: 'Handoff Order per family the session starts in: `pressure` (default) or an `order` list.',
|
|
75
77
|
})),
|
|
76
78
|
}, { additionalProperties: false });
|
|
77
79
|
export const ConfigSchema = Type.Object({
|
|
78
80
|
$schema: Type.Optional(Type.String()),
|
|
79
|
-
|
|
81
|
+
panel: Type.Optional(Type.Object({
|
|
82
|
+
theme: Type.Optional(Type.String({
|
|
83
|
+
enum: ['system', 'light', 'dark'],
|
|
84
|
+
description: 'Panel colours: follow the system (default), always light, or always dark.',
|
|
85
|
+
})),
|
|
86
|
+
language: Type.Optional(Type.String({
|
|
87
|
+
enum: ['auto', 'en', 'zh'],
|
|
88
|
+
description: "Panel language: the browser's (default), English, or Chinese.",
|
|
89
|
+
})),
|
|
90
|
+
}, { additionalProperties: false, description: 'How the Panel looks (spec §3.29).' })),
|
|
91
|
+
terminal: Type.Optional(Type.Object({
|
|
92
|
+
preferred: Type.Optional(Type.String({
|
|
93
|
+
enum: ['terminal', 'iterm2', 'ghostty', 'orca', 'wezterm', 'kitty', 'alacritty', 'custom'],
|
|
94
|
+
description: 'The terminal sideby opens for AI Tasks; without it sideby picks the one you use.',
|
|
95
|
+
})),
|
|
96
|
+
custom: Type.Optional(Type.String({
|
|
97
|
+
description: 'Your own launch command for `preferred: custom`, with `{cwd}` and `{cmd}` placeholders.',
|
|
98
|
+
})),
|
|
99
|
+
}, { additionalProperties: false, description: 'The terminal AI Tasks open in (spec §3.28).' })),
|
|
100
|
+
ask: Type.Optional(Type.Object({
|
|
101
|
+
account: Type.Optional(Type.String({
|
|
102
|
+
description: 'The account AI Tasks start (`<family>:<name>` or a short command); sideby picks one without it.',
|
|
103
|
+
})),
|
|
104
|
+
}, { additionalProperties: false, description: 'AI Tasks (spec §3.28).' })),
|
|
105
|
+
accounts: Type.Optional(Type.Record(Type.String(), Type.Object({
|
|
106
|
+
args: Type.Optional(Type.Array(Type.String(), {
|
|
107
|
+
description: 'Host arguments sideby starts the account with; `sideby config` and `sideby model` set only the model and effort ones.',
|
|
108
|
+
})),
|
|
109
|
+
}, { additionalProperties: false }), { description: 'Per-Account settings keyed by `<family>:<name>`.' })),
|
|
80
110
|
aliases: Type.Optional(Type.Record(Type.String({ pattern: '^[A-Za-z_][A-Za-z0-9_-]*$' }), AliasSchema, {
|
|
81
111
|
description: 'Short shell function names created by `sideby shell-init`, mapped to an Account ref or to `{ account, args }`.',
|
|
82
112
|
})),
|
|
@@ -85,7 +115,9 @@ export const ConfigSchema = Type.Object({
|
|
|
85
115
|
plugins: Type.Optional(Type.Record(Type.String(), Type.Record(Type.String(), Type.Unknown()), {
|
|
86
116
|
description: 'Per-Plugin settings; `enabled: false` turns a Plugin off.',
|
|
87
117
|
})),
|
|
88
|
-
extraSharedItems: Type.Optional(Type.Record(Type.String(), Type.Array(SharedItemSchema)
|
|
118
|
+
extraSharedItems: Type.Optional(Type.Record(Type.String(), Type.Array(SharedItemSchema), {
|
|
119
|
+
description: 'Per family, more items each account shares with the main account: `{ path, mode }` with a relative path.',
|
|
120
|
+
})),
|
|
89
121
|
accountTitle: Type.Optional(Type.Boolean({
|
|
90
122
|
description: 'Set the terminal title to `[<short command or account>]` when sideby starts an Account, and keep Hosts that allow it (Codex) from replacing it. Off by default.',
|
|
91
123
|
})),
|
|
@@ -127,8 +159,8 @@ export const ConfigSchema = Type.Object({
|
|
|
127
159
|
})),
|
|
128
160
|
}, { additionalProperties: false, description: 'Quota that sideby asks for (spec §3.26, ADR-0025).' })),
|
|
129
161
|
shellInitFile: Type.Optional(Type.Object({
|
|
130
|
-
zsh: Type.Optional(Type.String({ minLength: 1 })),
|
|
131
|
-
bash: Type.Optional(Type.String({ minLength: 1 })),
|
|
162
|
+
zsh: Type.Optional(Type.String({ minLength: 1, description: 'The file for zsh.' })),
|
|
163
|
+
bash: Type.Optional(Type.String({ minLength: 1, description: 'The file for bash.' })),
|
|
132
164
|
}, {
|
|
133
165
|
additionalProperties: false,
|
|
134
166
|
description: 'Absolute or `~/` paths of files sideby rewrites with the `sideby shell-init <shell>` output whenever it adds an Account or an alias; source the file from your shell rc.',
|
|
@@ -191,8 +223,8 @@ async function configTarget(file) {
|
|
|
191
223
|
return (await lstatOrNull(file))?.isSymbolicLink() ? ((await realpathOrNull(file)) ?? file) : file;
|
|
192
224
|
}
|
|
193
225
|
/**
|
|
194
|
-
* Re-reads the config file, lets `edit` change one top-level `key` (sideby writes
|
|
195
|
-
* `
|
|
226
|
+
* Re-reads the config file, lets `edit` change one top-level `key` (the keys sideby writes are listed in spec §3.10;
|
|
227
|
+
* `sideby config set|unset` may change any but plugin settings, §3.29), and an `edit` that returns undefined removes it, and writes the result: every other key and their order kept (with `$schema`), 2-space JSON
|
|
196
228
|
* with a trailing newline, atomically, keeping the file's mode (0600 for a new file). Refuses, without writing, a
|
|
197
229
|
* file that is not valid JSON or not a valid config, a result that is not a valid config, or a file that changed
|
|
198
230
|
* while sideby wrote it. `edit` returns null to leave the file alone; `dryRun` returns the change without writing.
|
|
@@ -233,8 +265,18 @@ async function editConfigKey(file, key, retry, edit, opts = {}) {
|
|
|
233
265
|
: { ...data, [key]: after };
|
|
234
266
|
if (after === undefined)
|
|
235
267
|
delete next[key];
|
|
236
|
-
if (!Value.Check(ConfigSchema, next))
|
|
237
|
-
|
|
268
|
+
if (!Value.Check(ConfigSchema, next)) {
|
|
269
|
+
const first = [...Value.Errors(ConfigSchema, next)][0];
|
|
270
|
+
const where = (first?.instancePath ?? '').replace(/^\//, '').split('/').join('.') || key;
|
|
271
|
+
throw new ConfigError(`${where} ${first?.message ?? 'is invalid'}, so nothing was changed; \`sideby config get\` lists the keys and the values they take`, { code: 'bad-value' });
|
|
272
|
+
}
|
|
273
|
+
for (const alias of Object.keys(next.aliases ?? {})) {
|
|
274
|
+
const problem = aliasProblem(alias);
|
|
275
|
+
if (problem)
|
|
276
|
+
throw new ConfigError(`alias "${alias}" ${problem} (it becomes a shell function name); pick another name`, {
|
|
277
|
+
code: 'bad-value',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
238
280
|
const h = handoffSettings(next);
|
|
239
281
|
if (h.prepareAt >= h.threshold)
|
|
240
282
|
throw new ConfigError(`handoff.prepareAt must be lower than handoff.threshold; fix them in ${file} and ${retry} again`);
|
|
@@ -346,3 +388,229 @@ export async function removeConfigAlias(file, alias) {
|
|
|
346
388
|
});
|
|
347
389
|
return { status, aliases };
|
|
348
390
|
}
|
|
391
|
+
export function editConfigTerminal(file, patch) {
|
|
392
|
+
return editConfigKey(file, 'terminal', 'run `sideby terminal`', (current) => {
|
|
393
|
+
const next = { ...current, ...patch };
|
|
394
|
+
for (const key of ['preferred', 'custom'])
|
|
395
|
+
if (next[key] === undefined)
|
|
396
|
+
delete next[key];
|
|
397
|
+
return Object.keys(next).length ? next : undefined;
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
export function editConfigAsk(file, account) {
|
|
401
|
+
return editConfigKey(file, 'ask', 'run `sideby ask`', () => (account ? { account } : undefined));
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The Panel's theme and language (spec §3.29): a value sets the key, null removes it (back to the default), and an
|
|
405
|
+
* empty `panel` goes away with it (see `editConfigKey` for how the file is written).
|
|
406
|
+
*/
|
|
407
|
+
export function editConfigPanel(file, patch, opts = {}) {
|
|
408
|
+
return editConfigKey(file, 'panel', 'change the setting', (current) => {
|
|
409
|
+
const next = { ...current };
|
|
410
|
+
for (const k of ['theme', 'language'])
|
|
411
|
+
if (patch[k] === null)
|
|
412
|
+
delete next[k];
|
|
413
|
+
else if (patch[k] !== undefined)
|
|
414
|
+
next[k] = patch[k];
|
|
415
|
+
return Object.keys(next).length ? next : undefined;
|
|
416
|
+
}, opts);
|
|
417
|
+
}
|
|
418
|
+
/** The name shown for a key the user picks, per record in the schema. */
|
|
419
|
+
const PLACEHOLDERS = {
|
|
420
|
+
accounts: '<family>:<name>',
|
|
421
|
+
aliases: '<short>',
|
|
422
|
+
plugins: '<plugin>',
|
|
423
|
+
extraSharedItems: '<family>',
|
|
424
|
+
'handoff.families': '<family>',
|
|
425
|
+
'display.accounts': '<family>',
|
|
426
|
+
'display.pinned': '<family>',
|
|
427
|
+
'quota.check': '<family>',
|
|
428
|
+
};
|
|
429
|
+
const BAD_SEGMENT = new Set(['__proto__', 'constructor', 'prototype']);
|
|
430
|
+
function takes(node) {
|
|
431
|
+
if (node.anyOf) {
|
|
432
|
+
const consts = node.anyOf.filter((n) => n.const !== undefined).map((n) => String(n.const));
|
|
433
|
+
if (consts.length === node.anyOf.length)
|
|
434
|
+
return `one of: ${consts.join(', ')}`;
|
|
435
|
+
return 'an account ref (`<family>:<name>`), or JSON {"account": …, "args": […]}';
|
|
436
|
+
}
|
|
437
|
+
if (node.enum)
|
|
438
|
+
return `one of: ${node.enum.join(', ')}`;
|
|
439
|
+
if (node.type === 'boolean')
|
|
440
|
+
return 'true or false';
|
|
441
|
+
if (node.type === 'integer')
|
|
442
|
+
return node.maximum !== undefined
|
|
443
|
+
? `a whole number from ${node.minimum ?? 0} to ${node.maximum}`
|
|
444
|
+
: `a whole number, ${node.minimum ?? 0} or more`;
|
|
445
|
+
if (node.type === 'array')
|
|
446
|
+
return node.items?.type === 'string' ? 'a JSON list of text, such as ["a","b"]' : 'a JSON list';
|
|
447
|
+
if (node.type === 'string')
|
|
448
|
+
return 'text';
|
|
449
|
+
return 'a JSON object';
|
|
450
|
+
}
|
|
451
|
+
/** Every key `sideby config set` takes, with its meaning and current value, in schema order. */
|
|
452
|
+
export function settableKeys(config) {
|
|
453
|
+
const out = [];
|
|
454
|
+
const walk = (node, path, meaning) => {
|
|
455
|
+
const key = path.join('.');
|
|
456
|
+
const here = node.description ?? meaning;
|
|
457
|
+
if (node.properties) {
|
|
458
|
+
for (const [name, child] of Object.entries(node.properties))
|
|
459
|
+
if (name !== '$schema')
|
|
460
|
+
walk(child, [...path, name], here);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
const pattern = node.patternProperties && Object.values(node.patternProperties)[0];
|
|
464
|
+
if (pattern) {
|
|
465
|
+
const holder = PLACEHOLDERS[key] ?? '<name>';
|
|
466
|
+
if (key === 'plugins') {
|
|
467
|
+
walk({
|
|
468
|
+
type: 'boolean',
|
|
469
|
+
description: 'false turns the plugin off; its own settings are not changed here.',
|
|
470
|
+
}, [...path, holder, 'enabled'], here);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (pattern.properties)
|
|
474
|
+
return walk(pattern, [...path, holder], here);
|
|
475
|
+
return leaf(pattern, [...path, holder], here);
|
|
476
|
+
}
|
|
477
|
+
leaf(node, path, here);
|
|
478
|
+
};
|
|
479
|
+
const leaf = (node, path, meaning) => {
|
|
480
|
+
const key = path.join('.');
|
|
481
|
+
const item = { key, meaning: node.description ?? meaning, takes: takes(node) };
|
|
482
|
+
const holder = path.findIndex((p) => p.startsWith('<'));
|
|
483
|
+
if (holder < 0) {
|
|
484
|
+
const v = getIn(config, path);
|
|
485
|
+
if (v !== undefined)
|
|
486
|
+
item.value = v;
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
const entries = [];
|
|
490
|
+
const parent = getIn(config, path.slice(0, holder));
|
|
491
|
+
if (parent && typeof parent === 'object')
|
|
492
|
+
for (const name of Object.keys(parent)) {
|
|
493
|
+
const concrete = [...path.slice(0, holder), name, ...path.slice(holder + 1)];
|
|
494
|
+
const v = getIn(config, concrete);
|
|
495
|
+
if (v !== undefined)
|
|
496
|
+
entries.push({ key: concrete.join('.'), value: v });
|
|
497
|
+
}
|
|
498
|
+
if (entries.length)
|
|
499
|
+
item.entries = entries;
|
|
500
|
+
}
|
|
501
|
+
out.push(item);
|
|
502
|
+
};
|
|
503
|
+
walk(ConfigSchema, [], '');
|
|
504
|
+
return out;
|
|
505
|
+
}
|
|
506
|
+
function getIn(value, path) {
|
|
507
|
+
let at = value;
|
|
508
|
+
for (const seg of path) {
|
|
509
|
+
if (!at || typeof at !== 'object' || Array.isArray(at) || !Object.hasOwn(at, seg))
|
|
510
|
+
return undefined;
|
|
511
|
+
at = at[seg];
|
|
512
|
+
}
|
|
513
|
+
return at;
|
|
514
|
+
}
|
|
515
|
+
/** Splits and checks a dot path; refuses what `config` may not read or change. */
|
|
516
|
+
export function configKeyPath(key) {
|
|
517
|
+
return describeConfigKey(key).path;
|
|
518
|
+
}
|
|
519
|
+
/** The path of `key`, what it does and the values it takes; refuses what `config` may not read or change. */
|
|
520
|
+
export function describeConfigKey(key) {
|
|
521
|
+
const path = key.split('.');
|
|
522
|
+
const list = '`sideby config get` lists the keys you can use';
|
|
523
|
+
if (!key || path.some((p) => !p || BAD_SEGMENT.has(p)))
|
|
524
|
+
throw new ConfigError(`"${key}" is not a config key; ${list}`, { code: 'bad-key' });
|
|
525
|
+
if (path[0] === '$schema')
|
|
526
|
+
throw new ConfigError(`$schema is for editors and is not a setting; ${list}`, { code: 'bad-key' });
|
|
527
|
+
if (path[0] === 'plugins' && !(path.length === 3 && path[2] === 'enabled'))
|
|
528
|
+
throw new ConfigError(`a plugin's own settings may hold secrets, so \`sideby config\` does not read or change them (only plugins.<plugin>.enabled); edit them in the config file`, { code: 'plugin-config' });
|
|
529
|
+
let node = ConfigSchema;
|
|
530
|
+
let meaning = '';
|
|
531
|
+
for (const seg of path) {
|
|
532
|
+
const n = node;
|
|
533
|
+
if (!n)
|
|
534
|
+
break;
|
|
535
|
+
const holder = n.anyOf ?? [n];
|
|
536
|
+
node = undefined;
|
|
537
|
+
for (const h of holder) {
|
|
538
|
+
if (h.properties?.[seg])
|
|
539
|
+
node = h.properties[seg];
|
|
540
|
+
else if (h.patternProperties) {
|
|
541
|
+
const [re, child] = Object.entries(h.patternProperties)[0];
|
|
542
|
+
if (new RegExp(re).test(seg))
|
|
543
|
+
node = child;
|
|
544
|
+
}
|
|
545
|
+
if (node)
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
if (node?.description)
|
|
549
|
+
meaning = node.description;
|
|
550
|
+
}
|
|
551
|
+
if (!node)
|
|
552
|
+
throw new ConfigError(`"${key}" is not a config key; ${list}`, { code: 'bad-key' });
|
|
553
|
+
if (path[0] === 'plugins')
|
|
554
|
+
return { path, meaning: 'false turns the plugin off.', takes: 'true or false' };
|
|
555
|
+
return { path, meaning, takes: takes(node) };
|
|
556
|
+
}
|
|
557
|
+
/** A value from the command line: JSON when it parses (90, true, ["a"]), else the text itself. */
|
|
558
|
+
export function parseConfigValue(text) {
|
|
559
|
+
try {
|
|
560
|
+
return JSON.parse(text);
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
return text;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/** `sideby config get [key]`: the value at `key` (undefined when not set). */
|
|
567
|
+
export async function configGet(file, key) {
|
|
568
|
+
const path = configKeyPath(key);
|
|
569
|
+
return { key, value: getIn(await loadConfig(file), path) };
|
|
570
|
+
}
|
|
571
|
+
function setIn(value, path, next, key) {
|
|
572
|
+
const [seg, ...rest] = path;
|
|
573
|
+
if (seg === undefined)
|
|
574
|
+
return next;
|
|
575
|
+
if (value !== undefined && (typeof value !== 'object' || value === null || Array.isArray(value)))
|
|
576
|
+
throw new ConfigError(`${key} cannot be set inside a value that is not an object; set the part before it as a whole (JSON), or unset it first`, { code: 'bad-key' });
|
|
577
|
+
const obj = { ...value };
|
|
578
|
+
const child = setIn(obj[seg], rest, next, key);
|
|
579
|
+
if (child === undefined || (rest.length && isEmptyObject(child) && next === undefined))
|
|
580
|
+
delete obj[seg];
|
|
581
|
+
else
|
|
582
|
+
obj[seg] = child;
|
|
583
|
+
return obj;
|
|
584
|
+
}
|
|
585
|
+
const isEmptyObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v) && Object.keys(v).length === 0;
|
|
586
|
+
/**
|
|
587
|
+
* `sideby config set|unset` (spec §3.29): sets `key` to `value`, or removes it when `value` is undefined (and parents
|
|
588
|
+
* left empty with it). The whole result must match the schema; `accounts.<ref>.args` takes only what `argsProblem`
|
|
589
|
+
* accepts (the model and effort arguments, as `sideby model`), and without `argsProblem` it is refused. Written as
|
|
590
|
+
* every other config change (see `editConfigKey`); `dryRun` shows the change without writing.
|
|
591
|
+
*/
|
|
592
|
+
export async function editConfigPath(file, key, value, opts = {}) {
|
|
593
|
+
const path = configKeyPath(key);
|
|
594
|
+
if (path[0] === 'plugins' && value !== undefined && typeof value !== 'boolean')
|
|
595
|
+
throw new ConfigError(`${key} takes true or false`, { code: 'bad-value' });
|
|
596
|
+
const top = path[0];
|
|
597
|
+
const r = await editConfigKey(file, top, 'run the command', (current) => {
|
|
598
|
+
const next = setIn(current, path.slice(1), value, key);
|
|
599
|
+
const result = path.length === 1 ? next : isEmptyObject(next) ? undefined : next;
|
|
600
|
+
if (top === 'accounts')
|
|
601
|
+
checkAccountArgs(current, result, opts.argsProblem);
|
|
602
|
+
return result;
|
|
603
|
+
}, opts);
|
|
604
|
+
const inner = path.slice(1);
|
|
605
|
+
return { key, before: getIn(r.before, inner), after: getIn(r.after, inner), changed: r.changed };
|
|
606
|
+
}
|
|
607
|
+
function checkAccountArgs(before, after, argsProblem) {
|
|
608
|
+
for (const [ref, entry] of Object.entries(after ?? {})) {
|
|
609
|
+
const args = entry.args;
|
|
610
|
+
if (!args || JSON.stringify(args) === JSON.stringify(before?.[ref]?.args))
|
|
611
|
+
continue;
|
|
612
|
+
const problem = argsProblem ? argsProblem(ref, args) : 'sideby cannot check these arguments here';
|
|
613
|
+
if (problem)
|
|
614
|
+
throw new ConfigError(`accounts.${ref}.args: ${problem}. \`sideby config\` sets only the model and effort arguments, as \`sideby model ${ref}\` does; other host arguments go in a short command (\`sideby alias add <short> ${ref} -- <args>\`)`, { code: 'bad-value' });
|
|
615
|
+
}
|
|
616
|
+
}
|
|
@@ -39,18 +39,29 @@ export declare function sidebyBinProblem(env: Env, why: string): Promise<string
|
|
|
39
39
|
* the Family's `selectVar` set for every Account but the Main Account.
|
|
40
40
|
*/
|
|
41
41
|
export declare function accountEnv(family: FamilyDef, account: Account, baseEnv: Env): Env;
|
|
42
|
-
export
|
|
42
|
+
export interface LaunchCommandOptions {
|
|
43
43
|
account: Account;
|
|
44
44
|
family: FamilyDef;
|
|
45
|
-
baseEnv: Env;
|
|
46
45
|
accountArgs: readonly string[];
|
|
47
46
|
userArgs: readonly string[];
|
|
48
47
|
command: 'run' | 'login';
|
|
48
|
+
/** Config `accountTitle`: set the terminal title to `[<label>]` and keep the Host from replacing it. */
|
|
49
|
+
title?: boolean;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The Host and its arguments for a Launch before any `launch.before` hook: Family defaults, the title arguments, the
|
|
53
|
+
* Account's config arguments, then the user's. Pure: reads no file and runs no Plugin, so a preview can show it.
|
|
54
|
+
*/
|
|
55
|
+
export declare function launchCommand(opts: LaunchCommandOptions): {
|
|
56
|
+
bin: string;
|
|
57
|
+
args: string[];
|
|
58
|
+
notice?: string;
|
|
59
|
+
};
|
|
60
|
+
export declare function prepareLaunch(opts: LaunchCommandOptions & {
|
|
61
|
+
baseEnv: Env;
|
|
49
62
|
bus: HookBus;
|
|
50
63
|
/** What the Host may show as the Account (`SIDEBY_LABEL`): the short command, else the ref. */
|
|
51
64
|
label?: string;
|
|
52
|
-
/** Config `accountTitle`: set the terminal title to `[<label>]` and keep the Host from replacing it. */
|
|
53
|
-
title?: boolean;
|
|
54
65
|
}): Promise<PreparedLaunch>;
|
|
55
66
|
/**
|
|
56
67
|
* Runs the Host with the terminal attached and returns sideby's exit code (spec §3.5):
|
package/dist/src/core/launch.js
CHANGED
|
@@ -72,6 +72,32 @@ export function accountEnv(family, account, baseEnv) {
|
|
|
72
72
|
env[family.selectVar] = account.dir;
|
|
73
73
|
return env;
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* The Host and its arguments for a Launch before any `launch.before` hook: Family defaults, the title arguments, the
|
|
77
|
+
* Account's config arguments, then the user's. Pure: reads no file and runs no Plugin, so a preview can show it.
|
|
78
|
+
*/
|
|
79
|
+
export function launchCommand(opts) {
|
|
80
|
+
const { account, family } = opts;
|
|
81
|
+
if (opts.command === 'login') {
|
|
82
|
+
// Family arguments still apply: Codex must store a non-main login in the Account's own file.
|
|
83
|
+
const base = family.defaultArgs?.(account, []) ?? [];
|
|
84
|
+
return family.login.args
|
|
85
|
+
? { bin: family.bin, args: [...base, ...family.login.args] }
|
|
86
|
+
: { bin: family.bin, args: base, notice: family.login.hint };
|
|
87
|
+
}
|
|
88
|
+
const keep = opts.title && family.keepTitle && !opts.userArgs.some((a) => a.includes(family.keepTitle.key))
|
|
89
|
+
? family.keepTitle.args
|
|
90
|
+
: [];
|
|
91
|
+
return {
|
|
92
|
+
bin: family.bin,
|
|
93
|
+
args: [
|
|
94
|
+
...(family.defaultArgs?.(account, opts.userArgs) ?? []),
|
|
95
|
+
...keep,
|
|
96
|
+
...opts.accountArgs,
|
|
97
|
+
...opts.userArgs,
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
75
101
|
export async function prepareLaunch(opts) {
|
|
76
102
|
const { account, family } = opts;
|
|
77
103
|
const env = accountEnv(family, account, opts.baseEnv);
|
|
@@ -84,29 +110,7 @@ export async function prepareLaunch(opts) {
|
|
|
84
110
|
// inherited from another sideby Launch never leaks through.
|
|
85
111
|
env.SIDEBY_ACCOUNT = account.ref;
|
|
86
112
|
env.SIDEBY_LABEL = opts.label || account.ref;
|
|
87
|
-
|
|
88
|
-
let notice;
|
|
89
|
-
if (opts.command === 'login') {
|
|
90
|
-
// Family arguments still apply: Codex must store a non-main login in the Account's own file.
|
|
91
|
-
const base = family.defaultArgs?.(account, []) ?? [];
|
|
92
|
-
if (family.login.args)
|
|
93
|
-
args = [...base, ...family.login.args];
|
|
94
|
-
else {
|
|
95
|
-
args = base;
|
|
96
|
-
notice = family.login.hint;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
const keep = opts.title && family.keepTitle && !opts.userArgs.some((a) => a.includes(family.keepTitle.key))
|
|
101
|
-
? family.keepTitle.args
|
|
102
|
-
: [];
|
|
103
|
-
args = [
|
|
104
|
-
...(family.defaultArgs?.(account, opts.userArgs) ?? []),
|
|
105
|
-
...keep,
|
|
106
|
-
...opts.accountArgs,
|
|
107
|
-
...opts.userArgs,
|
|
108
|
-
];
|
|
109
|
-
}
|
|
113
|
+
const { args, notice } = launchCommand(opts);
|
|
110
114
|
await opts.bus.run('launch.before', family.id, {
|
|
111
115
|
account,
|
|
112
116
|
family,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Env } from '../types.ts';
|
|
2
|
+
import { type AutostartContext } from './autostart.ts';
|
|
2
3
|
import type { Paths } from './paths.ts';
|
|
3
4
|
import { type LocalBuild } from './version.ts';
|
|
4
5
|
export interface LocalDirs {
|
|
@@ -49,8 +50,17 @@ export interface SwitchResult {
|
|
|
49
50
|
/** True when it already ran `to`, and nothing changed. */
|
|
50
51
|
unchanged?: boolean;
|
|
51
52
|
restartedPanel: boolean;
|
|
53
|
+
/**
|
|
54
|
+
* Back on npm, `sideby update --yes` of the npm code brought the skill agents read to its version (true), or that
|
|
55
|
+
* failed (false); absent for a switch to the Local Build, which has no skill of its own to bring.
|
|
56
|
+
*/
|
|
57
|
+
skillSynced?: boolean;
|
|
58
|
+
/** With `skillSync: 'background'`: that `sideby update --yes` started on its own and is not waited for. */
|
|
59
|
+
skillSyncStarted?: boolean;
|
|
52
60
|
/** Another install held the lock; nothing ran. */
|
|
53
61
|
busy?: boolean;
|
|
62
|
+
/** An existing login item (ADR-0029) now starts the sideby switched to; absent when there was none to change. */
|
|
63
|
+
refreshedAutostart?: boolean;
|
|
54
64
|
}
|
|
55
65
|
/**
|
|
56
66
|
* Puts `to` behind the global `sideby`. `local` links the newest Local Build; `npm` restores the kept package or
|
|
@@ -60,4 +70,11 @@ export interface SwitchResult {
|
|
|
60
70
|
export declare function switchLocal(paths: Paths, d: LocalDirs, to: LocalTarget, env: Env, opts?: {
|
|
61
71
|
panelPort?: () => Promise<number | null>;
|
|
62
72
|
onOutput?: (text: string) => void;
|
|
73
|
+
/**
|
|
74
|
+
* Back on npm, wait for the skill (a terminal shows its progress) or start it on its own: it fetches from the
|
|
75
|
+
* network and can take minutes, longer than a Portal waits for an answer (`background`, the Panel).
|
|
76
|
+
*/
|
|
77
|
+
skillSync?: 'wait' | 'background';
|
|
78
|
+
/** Internal, for tests: parts of the machine the login item refresh uses. */
|
|
79
|
+
autostart?: Partial<AutostartContext>;
|
|
63
80
|
}): Promise<SwitchResult>;
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
// these; `sideby local` and the Panel's version popover switch between the newest Local Build and npm without the
|
|
3
3
|
// checkout. Only an npm global install is handled: `<prefix>/lib/node_modules/sideby` is the folder every caller
|
|
4
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';
|
|
5
|
+
import { lstat, mkdir, readFile, readlink, realpath, rename, rm, symlink } from 'node:fs/promises';
|
|
6
6
|
import { homedir } from 'node:os';
|
|
7
7
|
import { dirname, join, relative } from 'node:path';
|
|
8
|
-
import {
|
|
8
|
+
import { refreshAutostart } from './autostart.js';
|
|
9
|
+
import { compareVersions, fetchLatest, restartPanelByName, runProgram, startDetached, takeInstallLock, } from './update.js';
|
|
9
10
|
import { parseLocalBuild } from './version.js';
|
|
10
11
|
const BIN = 'dist/src/cli/main.js';
|
|
11
12
|
export const pkgDir = (d) => join(d.dev, 'pkg');
|
|
@@ -122,6 +123,21 @@ export function switchBlocker(s, to) {
|
|
|
122
123
|
return 'there is no local build yet; run `pnpm local` in the sideby checkout';
|
|
123
124
|
return null;
|
|
124
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Points an existing login item at the sideby now behind the global command (spec §3.29): its entry, with this Node.
|
|
128
|
+
* Never creates one and never throws; undefined when there is no login item of sideby's, or the entry is missing.
|
|
129
|
+
*/
|
|
130
|
+
async function refreshLoginItem(paths, d, ctx = {}) {
|
|
131
|
+
const entry = await realpath(join(globalPkg(d), BIN)).catch(() => null);
|
|
132
|
+
if (!entry)
|
|
133
|
+
return undefined;
|
|
134
|
+
const r = await refreshAutostart({
|
|
135
|
+
home: paths.home,
|
|
136
|
+
launcher: { node: process.execPath, entry },
|
|
137
|
+
...ctx,
|
|
138
|
+
});
|
|
139
|
+
return r.state === 'on' || r.state === 'stale' ? r.rewritten : undefined;
|
|
140
|
+
}
|
|
125
141
|
/**
|
|
126
142
|
* Puts `to` behind the global `sideby`. `local` links the newest Local Build; `npm` restores the kept package or
|
|
127
143
|
* installs `latest` (a failed install links the Local Build back, so `sideby` never goes missing). Afterwards a
|
|
@@ -183,7 +199,27 @@ export async function switchLocal(paths, d, to, env, opts = {}) {
|
|
|
183
199
|
finally {
|
|
184
200
|
await release().catch(() => { });
|
|
185
201
|
}
|
|
202
|
+
// The login item records the sideby it starts, like `sideby update` keeps it pointing at the one in use.
|
|
203
|
+
const refreshed = await refreshLoginItem(paths, d, opts.autostart);
|
|
204
|
+
const login = refreshed === undefined ? {} : { refreshedAutostart: refreshed };
|
|
186
205
|
const port = opts.panelPort ? await opts.panelPort().catch(() => null) : null;
|
|
187
206
|
const restartedPanel = port !== null && (await restartPanelByName(env, port));
|
|
188
|
-
|
|
207
|
+
if (to === 'local')
|
|
208
|
+
return { ok: true, message, running: to, restartedPanel, ...login };
|
|
209
|
+
// The skill is part of the version (spec §3.23): the npm code updates it as `sideby update --yes` does for anyone,
|
|
210
|
+
// by name so the new code runs, after the lock above is free. A failure keeps the switch.
|
|
211
|
+
if (opts.skillSync === 'background')
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
message,
|
|
215
|
+
running: to,
|
|
216
|
+
restartedPanel,
|
|
217
|
+
...login,
|
|
218
|
+
skillSyncStarted: await startDetached('sideby', ['update', '--yes'], env),
|
|
219
|
+
};
|
|
220
|
+
const u = await runProgram('sideby', ['update', '--yes'], {
|
|
221
|
+
env,
|
|
222
|
+
...(opts.onOutput ? { onOutput: opts.onOutput } : {}),
|
|
223
|
+
}).catch(() => ({ code: 1, output: '' }));
|
|
224
|
+
return { ok: true, message, running: to, restartedPanel, ...login, skillSynced: u.code === 0 };
|
|
189
225
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { HookBus } from '../plugins/bus.ts';
|
|
2
|
+
import type { Account, Env, FamilyDef } from '../types.ts';
|
|
3
|
+
import { type PreparedLaunch } from './launch.ts';
|
|
4
|
+
/** How long `sideby try` waits for the Host (spec §3.28). The spawner enforces it. */
|
|
5
|
+
export declare const PROBE_TIMEOUT_MS = 60000;
|
|
6
|
+
/** Combined stdout and stderr kept after redaction, in bytes. */
|
|
7
|
+
export declare const PROBE_OUTPUT_MAX_BYTES = 2048;
|
|
8
|
+
/**
|
|
9
|
+
* What the injected spawner receives. It must start `bin` with `args` only, in `cwd`, with `env`,
|
|
10
|
+
* and with stdin ignored (not a terminal and not a pipe the Host can wait on). When `timeoutMs`
|
|
11
|
+
* elapses it stops the process and resolves with `timedOut: true`. Errors must not include `env`
|
|
12
|
+
* or anything read from a Secret File.
|
|
13
|
+
*/
|
|
14
|
+
export interface ProbeSpawnRequest {
|
|
15
|
+
bin: string;
|
|
16
|
+
args: readonly string[];
|
|
17
|
+
env: Env;
|
|
18
|
+
cwd: string;
|
|
19
|
+
timeoutMs: number;
|
|
20
|
+
/** Always `ignore`: the probe is not interactive. */
|
|
21
|
+
stdin: 'ignore';
|
|
22
|
+
}
|
|
23
|
+
export interface ProbeSpawnResult {
|
|
24
|
+
/** Process exit code, or null when it timed out or the code could not be read. */
|
|
25
|
+
exitCode: number | null;
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
/** True when the spawner stopped the process because `timeoutMs` elapsed. */
|
|
29
|
+
timedOut: boolean;
|
|
30
|
+
}
|
|
31
|
+
export interface ProbeDeps {
|
|
32
|
+
family: FamilyDef;
|
|
33
|
+
/** The environment sideby itself is running in: the same `baseEnv` Launch copies. */
|
|
34
|
+
baseEnv: Env;
|
|
35
|
+
/** Working directory for the Host. The caller creates this temp directory. */
|
|
36
|
+
cwd: string;
|
|
37
|
+
spawn: (req: ProbeSpawnRequest) => Promise<ProbeSpawnResult>;
|
|
38
|
+
/** Account arguments from config, passed through Launch the same way `sideby run` does. */
|
|
39
|
+
accountArgs?: readonly string[];
|
|
40
|
+
/** What Launch would set as `SIDEBY_LABEL`. Defaults to the account ref. */
|
|
41
|
+
label?: string;
|
|
42
|
+
/** `launch.before` hooks. Pass the runtime bus so the probe sees the same environment as Launch. */
|
|
43
|
+
bus?: HookBus;
|
|
44
|
+
/**
|
|
45
|
+
* The Launch already prepared with `probe.args` as the user's arguments (the runtime's `prepareLaunch`), so its
|
|
46
|
+
* hooks and config arguments are applied once; `accountArgs`, `label` and `bus` are then unused.
|
|
47
|
+
*/
|
|
48
|
+
launch?: PreparedLaunch;
|
|
49
|
+
/** Defaults to {@link PROBE_TIMEOUT_MS}. */
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
}
|
|
52
|
+
export interface ProbeResult {
|
|
53
|
+
/** True when the Host exited 0 before the timeout. */
|
|
54
|
+
ok: boolean;
|
|
55
|
+
exitCode: number | null;
|
|
56
|
+
/**
|
|
57
|
+
* The first 2 KiB of stdout followed by stderr. Secret File values of length 4 or more are
|
|
58
|
+
* replaced with `***` before this cut, so a value that straddles the cut cannot leak.
|
|
59
|
+
*/
|
|
60
|
+
output: string;
|
|
61
|
+
/** True when the spawner stopped the Host at the timeout. `ok` is then false. */
|
|
62
|
+
timedOut: boolean;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Runs the Family's probe the way Launch would start the Host: `prepareLaunch`'s environment and
|
|
66
|
+
* arguments (with `probe.args` as the user's arguments), the caller's temp directory as cwd, stdin ignored, and a 60s
|
|
67
|
+
* timeout the spawner enforces. Returns the exit code and the redacted output.
|
|
68
|
+
* Throws a UserError when the Family has no probe; the message says this tool cannot be tried
|
|
69
|
+
* and to start the account with `sideby run` instead.
|
|
70
|
+
*/
|
|
71
|
+
export declare function runProbe(account: Account, deps: ProbeDeps): Promise<ProbeResult>;
|
|
72
|
+
/**
|
|
73
|
+
* The real spawner for {@link ProbeDeps.spawn}. The Host leads its own process group, so the timeout stops it and
|
|
74
|
+
* any child it started; the result comes at once, even while a child still holds the output pipes open.
|
|
75
|
+
*/
|
|
76
|
+
export declare function spawnProbe(request: ProbeSpawnRequest): Promise<ProbeSpawnResult>;
|
|
77
|
+
/** Replaces each secret of length 4 or more. Longer values go first so one cannot hide inside another. */
|
|
78
|
+
export declare function redactSecrets(text: string, values: readonly string[]): string;
|