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
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import { constants, realpathSync } from 'node:fs';
|
|
2
|
+
import { access, stat } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
/** Supported macOS terminals; custom templates have no app bundle or environment marker. */
|
|
6
|
+
export const TERMINALS = [
|
|
7
|
+
{
|
|
8
|
+
id: 'terminal',
|
|
9
|
+
name: 'Terminal',
|
|
10
|
+
bundleId: 'com.apple.Terminal',
|
|
11
|
+
app: 'Terminal.app',
|
|
12
|
+
termPrograms: ['Apple_Terminal'],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 'iterm2',
|
|
16
|
+
name: 'iTerm2',
|
|
17
|
+
bundleId: 'com.googlecode.iterm2',
|
|
18
|
+
app: 'iTerm.app',
|
|
19
|
+
termPrograms: ['iTerm.app'],
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'ghostty',
|
|
23
|
+
name: 'Ghostty',
|
|
24
|
+
bundleId: 'com.mitchellh.ghostty',
|
|
25
|
+
app: 'Ghostty.app',
|
|
26
|
+
termPrograms: ['ghostty'],
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'orca',
|
|
30
|
+
name: 'Orca',
|
|
31
|
+
bundleId: 'com.stablyai.orca',
|
|
32
|
+
app: 'Orca.app',
|
|
33
|
+
termPrograms: ['Orca'],
|
|
34
|
+
binary: 'orca',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'wezterm',
|
|
38
|
+
name: 'WezTerm',
|
|
39
|
+
bundleId: 'com.github.wez.wezterm',
|
|
40
|
+
app: 'WezTerm.app',
|
|
41
|
+
termPrograms: ['WezTerm'],
|
|
42
|
+
binary: 'wezterm',
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'kitty',
|
|
46
|
+
name: 'kitty',
|
|
47
|
+
bundleId: 'net.kovidgoyal.kitty',
|
|
48
|
+
app: 'kitty.app',
|
|
49
|
+
termPrograms: [],
|
|
50
|
+
marker: 'KITTY_WINDOW_ID',
|
|
51
|
+
binary: 'kitty',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'alacritty',
|
|
55
|
+
name: 'Alacritty',
|
|
56
|
+
bundleId: 'org.alacritty',
|
|
57
|
+
app: 'Alacritty.app',
|
|
58
|
+
termPrograms: [],
|
|
59
|
+
marker: 'ALACRITTY_WINDOW_ID',
|
|
60
|
+
},
|
|
61
|
+
{ id: 'custom', name: 'Custom', bundleId: null, app: null, termPrograms: [] },
|
|
62
|
+
];
|
|
63
|
+
/** Finds bundles and command line launchers without opening apps or reading user files. */
|
|
64
|
+
export async function installedTerminals(deps) {
|
|
65
|
+
if ((deps.platform ?? process.platform) !== 'darwin')
|
|
66
|
+
return [];
|
|
67
|
+
const fs = deps.fs ?? { stat, access };
|
|
68
|
+
const dirs = deps.appDirs ?? [
|
|
69
|
+
'/Applications',
|
|
70
|
+
join(deps.home, 'Applications'),
|
|
71
|
+
'/System/Applications/Utilities',
|
|
72
|
+
'/Applications/Utilities',
|
|
73
|
+
];
|
|
74
|
+
const found = [];
|
|
75
|
+
for (const terminal of TERMINALS) {
|
|
76
|
+
if (!terminal.app)
|
|
77
|
+
continue;
|
|
78
|
+
let appPath;
|
|
79
|
+
for (const dir of dirs) {
|
|
80
|
+
const path = join(dir, terminal.app);
|
|
81
|
+
if (await fs.stat(path).then((s) => s.isDirectory(), () => false)) {
|
|
82
|
+
appPath = path;
|
|
83
|
+
break;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
let executable;
|
|
87
|
+
if (terminal.binary) {
|
|
88
|
+
for (const dir of (deps.env.PATH ?? '').split(':').filter(Boolean)) {
|
|
89
|
+
const path = join(dir, terminal.binary);
|
|
90
|
+
if ((await fs.stat(path).then((s) => s.isFile(), () => false)) &&
|
|
91
|
+
(await fs.access(path, constants.X_OK).then(() => true, () => false))) {
|
|
92
|
+
executable = path;
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (!executable && appPath)
|
|
97
|
+
executable = join(appPath, terminal.id === 'orca' ? 'Contents/Resources/bin' : 'Contents/MacOS', terminal.binary);
|
|
98
|
+
}
|
|
99
|
+
if (appPath || (terminal.id === 'orca' && executable))
|
|
100
|
+
found.push({ id: terminal.id, ...(appPath ? { appPath } : {}), ...(executable ? { executable } : {}) });
|
|
101
|
+
}
|
|
102
|
+
return found;
|
|
103
|
+
}
|
|
104
|
+
/** Chooses an installed preference, then the launching environment, then macOS Terminal. */
|
|
105
|
+
export function guessTerminal(opts) {
|
|
106
|
+
const has = (id) => opts.installed.some((t) => t.id === id);
|
|
107
|
+
if (opts.preferred && has(opts.preferred))
|
|
108
|
+
return { id: opts.preferred, source: 'config' };
|
|
109
|
+
const match = TERMINALS.find((t) => has(t.id) &&
|
|
110
|
+
(t.termPrograms.includes(opts.env.TERM_PROGRAM ?? '') || Boolean(t.marker && opts.env[t.marker])));
|
|
111
|
+
return match ? { id: match.id, source: 'env' } : { id: 'terminal', source: 'default' };
|
|
112
|
+
}
|
|
113
|
+
/** This process's Node and the real path of the CLI entry next to this module (`main.ts` from source, `main.js` from dist). */
|
|
114
|
+
export function askLauncher() {
|
|
115
|
+
const ext = import.meta.url.endsWith('.ts') ? 'ts' : 'js';
|
|
116
|
+
const entry = fileURLToPath(new URL(`../cli/main.${ext}`, import.meta.url));
|
|
117
|
+
let real = entry;
|
|
118
|
+
try {
|
|
119
|
+
real = realpathSync(entry);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Keep the unresolved path; the terminal reports it if it is missing.
|
|
123
|
+
}
|
|
124
|
+
return { node: process.execPath, entry: real };
|
|
125
|
+
}
|
|
126
|
+
// A path the terminal's shell reads exactly: absolute, and no control character, newline or backslash
|
|
127
|
+
// (fish treats a backslash inside single quotes as an escape).
|
|
128
|
+
// Built from a string: a regex literal with control characters reads as a typo to linters.
|
|
129
|
+
const CONTROL = new RegExp(String.raw `[\x00-\x1f\x7f]`);
|
|
130
|
+
const safePath = (path) => path.startsWith('/') && !path.includes('\\') && !CONTROL.test(path);
|
|
131
|
+
/** Why the terminal cannot run this launcher, or null when it can. */
|
|
132
|
+
export function launcherProblem(launcher, stateDir) {
|
|
133
|
+
if (!safePath(launcher.node) ||
|
|
134
|
+
!safePath(launcher.entry) ||
|
|
135
|
+
(stateDir !== undefined && !safePath(stateDir)))
|
|
136
|
+
return {
|
|
137
|
+
ok: false,
|
|
138
|
+
reason: 'unsafe-path',
|
|
139
|
+
detail: 'sideby, Node or its state folder is in a folder whose name a terminal cannot run safely; install sideby with `npm i -g sideby`, or paste the task command into your terminal.',
|
|
140
|
+
};
|
|
141
|
+
if (launcher.entry.includes('/_npx/'))
|
|
142
|
+
return {
|
|
143
|
+
ok: false,
|
|
144
|
+
reason: 'npx-cache',
|
|
145
|
+
detail: 'sideby runs from the npx cache, which can disappear; install it with `npm i -g sideby` and try again, or paste the task command into your terminal now.',
|
|
146
|
+
};
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Builds the only shell command accepted by the AI Task terminal launcher: the quoted Node and CLI entry, or
|
|
151
|
+
* `sideby` by name when `launcher` is null (a command for the user to paste where sideby is on PATH). It carries the
|
|
152
|
+
* state folder the task was written to, so a terminal whose environment differs from the Panel's still finds it.
|
|
153
|
+
*/
|
|
154
|
+
export function askRunCommand(taskId, stateDir, launcher = askLauncher()) {
|
|
155
|
+
if (!/^[a-f0-9]{16}$/.test(taskId))
|
|
156
|
+
throw new Error('Invalid task id; create the AI task again.');
|
|
157
|
+
if (!launcher)
|
|
158
|
+
// Pasted by hand: a state folder a shell cannot take safely is left out, and `ask --run` uses the environment's.
|
|
159
|
+
return `sideby ask --run '${taskId}'${safePath(stateDir) ? ` --state-dir ${quote(stateDir)}` : ''}`;
|
|
160
|
+
if (!safePath(launcher.node) || !safePath(launcher.entry) || !safePath(stateDir))
|
|
161
|
+
throw new Error('Unsafe sideby path; install sideby with `npm i -g sideby` and try again.');
|
|
162
|
+
return `${quote(launcher.node)} ${quote(launcher.entry)} ask --run '${taskId}' --state-dir ${quote(stateDir)}`;
|
|
163
|
+
}
|
|
164
|
+
function quote(value) {
|
|
165
|
+
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
166
|
+
}
|
|
167
|
+
// AppleScript string literals know only \\ and \" as escapes; a control character is refused, not encoded.
|
|
168
|
+
function appleString(value) {
|
|
169
|
+
if (CONTROL.test(value))
|
|
170
|
+
throw new Error('Invalid task command; create the AI task again.');
|
|
171
|
+
return `"${value.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
|
|
172
|
+
}
|
|
173
|
+
// The quoted launcher and state paths, as askRunCommand writes them: single-quoted runs joined by \' (an escaped quote).
|
|
174
|
+
const QUOTED = String.raw `(?:'[^'\\\x00-\x1f\x7f]*'|\\')+`;
|
|
175
|
+
const TASK_COMMAND = new RegExp(`^${QUOTED} ${QUOTED} ask --run '[a-f0-9]{16}' --state-dir ${QUOTED}$`);
|
|
176
|
+
/** For `sh -c`: when the command fails, the window waits for Return instead of closing on its error. */
|
|
177
|
+
function holdOnFailure(command) {
|
|
178
|
+
return `${command} || { echo; echo "sideby: press Return to close"; read _; }`;
|
|
179
|
+
}
|
|
180
|
+
// Tokenize before substitution: paths and commands remain one argument, even with quotes or newlines.
|
|
181
|
+
function customArgs(template) {
|
|
182
|
+
const args = [];
|
|
183
|
+
let word = '', quoted = '', started = false;
|
|
184
|
+
for (let i = 0; i < template.length; i++) {
|
|
185
|
+
const c = template[i];
|
|
186
|
+
if (c === '\\' && quoted !== "'") {
|
|
187
|
+
if (++i === template.length)
|
|
188
|
+
throw new Error('Incomplete escape; fix the custom terminal template.');
|
|
189
|
+
word += template[i];
|
|
190
|
+
started = true;
|
|
191
|
+
}
|
|
192
|
+
else if (quoted) {
|
|
193
|
+
if (c === quoted)
|
|
194
|
+
quoted = '';
|
|
195
|
+
else
|
|
196
|
+
word += c;
|
|
197
|
+
}
|
|
198
|
+
else if (c === '"' || c === "'") {
|
|
199
|
+
quoted = c;
|
|
200
|
+
started = true;
|
|
201
|
+
}
|
|
202
|
+
else if (/\s/.test(c)) {
|
|
203
|
+
if (started) {
|
|
204
|
+
args.push(word);
|
|
205
|
+
word = '';
|
|
206
|
+
started = false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
word += c;
|
|
211
|
+
started = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (quoted)
|
|
215
|
+
throw new Error('Unclosed quote; fix the custom terminal template.');
|
|
216
|
+
if (started)
|
|
217
|
+
args.push(word);
|
|
218
|
+
return args;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Splits a custom terminal template into argv, throwing unless it names a program first and has both `{cmd}` and
|
|
222
|
+
* `{cwd}` in its arguments.
|
|
223
|
+
*/
|
|
224
|
+
export function validateCustomTemplate(template) {
|
|
225
|
+
const args = customArgs(template);
|
|
226
|
+
const file = args[0];
|
|
227
|
+
const rest = args.slice(1);
|
|
228
|
+
if (!file ||
|
|
229
|
+
file.includes('{') ||
|
|
230
|
+
!rest.some((a) => a.includes('{cmd}')) ||
|
|
231
|
+
!rest.some((a) => a.includes('{cwd}')) ||
|
|
232
|
+
template.includes('\0'))
|
|
233
|
+
throw new Error("Set a custom terminal executable and both {cmd} and {cwd} placeholders, such as `sideby terminal --custom 'my-term --cwd {cwd} -e {cmd}'`.");
|
|
234
|
+
return [file, ...rest];
|
|
235
|
+
}
|
|
236
|
+
/** Builds argv only. The caller supplies discovered bundle/binary paths when available. */
|
|
237
|
+
export function launchArgs(id, opts) {
|
|
238
|
+
if (!TASK_COMMAND.test(opts.command))
|
|
239
|
+
throw new Error('Invalid task command; create the AI task again.');
|
|
240
|
+
if (opts.cwd.includes('\0'))
|
|
241
|
+
throw new Error('Invalid working directory; choose another folder.');
|
|
242
|
+
// `ask --run` changes to the task's directory itself, so cwd goes only where a terminal takes it as an argument.
|
|
243
|
+
const { cwd, command } = opts;
|
|
244
|
+
const held = holdOnFailure(command);
|
|
245
|
+
switch (id) {
|
|
246
|
+
case 'terminal':
|
|
247
|
+
return {
|
|
248
|
+
file: '/usr/bin/osascript',
|
|
249
|
+
args: [
|
|
250
|
+
'-e',
|
|
251
|
+
`tell application id "com.apple.Terminal"
|
|
252
|
+
if not running then launch
|
|
253
|
+
do script ${appleString(command)}
|
|
254
|
+
activate
|
|
255
|
+
end tell`,
|
|
256
|
+
],
|
|
257
|
+
};
|
|
258
|
+
case 'iterm2':
|
|
259
|
+
return {
|
|
260
|
+
file: '/usr/bin/osascript',
|
|
261
|
+
args: [
|
|
262
|
+
'-e',
|
|
263
|
+
// Always a new window of its own: "current window" can be iTerm2's updater or settings window, and a
|
|
264
|
+
// just-started iTerm2 may open no window at all.
|
|
265
|
+
`tell application id "com.googlecode.iterm2"
|
|
266
|
+
activate
|
|
267
|
+
set taskWindow to (create window with default profile)
|
|
268
|
+
tell current session of taskWindow to write text ${appleString(command)}
|
|
269
|
+
end tell`,
|
|
270
|
+
],
|
|
271
|
+
};
|
|
272
|
+
// Use the CLI for both running and stopped Ghostty; AppleScript new window needs newer versions.
|
|
273
|
+
case 'ghostty':
|
|
274
|
+
return {
|
|
275
|
+
file: '/usr/bin/open',
|
|
276
|
+
args: [
|
|
277
|
+
'-na',
|
|
278
|
+
opts.appPath ?? 'Ghostty',
|
|
279
|
+
'--args',
|
|
280
|
+
`--working-directory=${cwd}`,
|
|
281
|
+
'-e',
|
|
282
|
+
'/bin/sh',
|
|
283
|
+
'-lc',
|
|
284
|
+
held,
|
|
285
|
+
],
|
|
286
|
+
};
|
|
287
|
+
case 'orca':
|
|
288
|
+
return {
|
|
289
|
+
file: opts.executable ?? 'orca',
|
|
290
|
+
args: [
|
|
291
|
+
'terminal',
|
|
292
|
+
'create',
|
|
293
|
+
'--worktree',
|
|
294
|
+
'active',
|
|
295
|
+
'--command',
|
|
296
|
+
command,
|
|
297
|
+
'--title',
|
|
298
|
+
'sideby',
|
|
299
|
+
'--focus',
|
|
300
|
+
'--json',
|
|
301
|
+
],
|
|
302
|
+
};
|
|
303
|
+
case 'wezterm':
|
|
304
|
+
return {
|
|
305
|
+
file: opts.executable ?? join(opts.appPath ?? '/Applications/WezTerm.app', 'Contents/MacOS/wezterm'),
|
|
306
|
+
args: ['start', '--cwd', cwd, '--', '/bin/sh', '-lc', held],
|
|
307
|
+
};
|
|
308
|
+
case 'kitty':
|
|
309
|
+
return {
|
|
310
|
+
file: opts.executable ?? join(opts.appPath ?? '/Applications/kitty.app', 'Contents/MacOS/kitty'),
|
|
311
|
+
args: ['--single-instance', '--directory', cwd, '/bin/sh', '-lc', held],
|
|
312
|
+
};
|
|
313
|
+
case 'alacritty':
|
|
314
|
+
return {
|
|
315
|
+
file: '/usr/bin/open',
|
|
316
|
+
args: [
|
|
317
|
+
'-na',
|
|
318
|
+
opts.appPath ?? 'Alacritty',
|
|
319
|
+
'--args',
|
|
320
|
+
'--working-directory',
|
|
321
|
+
cwd,
|
|
322
|
+
'-e',
|
|
323
|
+
'/bin/sh',
|
|
324
|
+
'-lc',
|
|
325
|
+
held,
|
|
326
|
+
],
|
|
327
|
+
};
|
|
328
|
+
case 'custom': {
|
|
329
|
+
const [file, ...args] = validateCustomTemplate(opts.custom ?? '');
|
|
330
|
+
return {
|
|
331
|
+
file,
|
|
332
|
+
args: args.map((a) => a.replace(/\{(cmd|cwd)\}/g, (_, key) => (key === 'cmd' ? command : cwd))),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
default:
|
|
336
|
+
throw new Error('Unknown terminal; choose an installed terminal in Settings.');
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/** Opens through the injected Desktop exec boundary, returning safe, actionable failures only. */
|
|
340
|
+
export async function openTerminal(id, opts, exec) {
|
|
341
|
+
if ((opts.platform ?? process.platform) !== 'darwin')
|
|
342
|
+
return { ok: false, reason: 'unsupported-platform' };
|
|
343
|
+
const installed = opts.installed.find((t) => t.id === id);
|
|
344
|
+
if (!installed && id !== 'custom')
|
|
345
|
+
return {
|
|
346
|
+
ok: false,
|
|
347
|
+
reason: 'not-installed',
|
|
348
|
+
detail: 'Install this terminal or choose another in Settings; paste the task command into your terminal.',
|
|
349
|
+
};
|
|
350
|
+
try {
|
|
351
|
+
const launch = launchArgs(id, { ...opts, ...installed });
|
|
352
|
+
const result = await exec(launch.file, launch.args);
|
|
353
|
+
if (result.code === 0)
|
|
354
|
+
return { ok: true };
|
|
355
|
+
if (launch.file === '/usr/bin/osascript' &&
|
|
356
|
+
(result.code === -1743 || /-1743|Not authorized to send Apple events/i.test(result.stderr)))
|
|
357
|
+
return {
|
|
358
|
+
ok: false,
|
|
359
|
+
reason: 'automation-denied',
|
|
360
|
+
detail: 'Allow Automation in System Settings > Privacy & Security > Automation, then try again; or paste the task command into your terminal.',
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
// Never expose process output or thrown messages: they may contain private data.
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
ok: false,
|
|
368
|
+
reason: 'failed',
|
|
369
|
+
detail: 'Could not open the terminal; check its settings or paste the task command into your terminal.',
|
|
370
|
+
};
|
|
371
|
+
}
|
|
@@ -66,7 +66,14 @@ export interface InstallMethod {
|
|
|
66
66
|
/** npm only: the prefix whose `lib/node_modules` holds sideby. */
|
|
67
67
|
prefix?: string;
|
|
68
68
|
}
|
|
69
|
+
/** Whether a Linux `/proc/<pid>/stat` line is of a zombie: the state after the parenthesised command name is `Z`. */
|
|
70
|
+
export declare function isZombieStat(stat: string): boolean;
|
|
69
71
|
/** Swapped in tests through `bindUpdateDeps`; the real machine otherwise. */
|
|
72
|
+
/**
|
|
73
|
+
* Whether `sideby autostart --refresh --json` rewrote the login item: it exits 0 also when nothing changed or the
|
|
74
|
+
* rewrite was refused, so only its `changed: true` counts.
|
|
75
|
+
*/
|
|
76
|
+
export declare function autostartRefreshed(code: number | null, stdout: string): boolean;
|
|
70
77
|
export interface UpdateDeps {
|
|
71
78
|
fetch: typeof fetch;
|
|
72
79
|
now: () => Date;
|
|
@@ -84,10 +91,16 @@ export interface UpdateDeps {
|
|
|
84
91
|
}>;
|
|
85
92
|
/** Starts `sideby ui --background --no-open --port <port>` from the newly installed code, detached; false when it could not start. */
|
|
86
93
|
restartPanel: (env: Env, port: number) => Promise<boolean>;
|
|
94
|
+
/** Starts a program detached, nothing kept of its output; false when it could not start. */
|
|
95
|
+
startDetached: (file: string, args: readonly string[], env: Env) => Promise<boolean>;
|
|
87
96
|
/** Whether `sideby app install` put a desktop app in this home (its files carry the app marker). */
|
|
88
97
|
appInstalled: (home: string, env: Env) => Promise<boolean>;
|
|
89
98
|
/** Runs `sideby app install --json` from the newly installed code and waits; false when it failed. */
|
|
90
99
|
refreshApp: (env: Env) => Promise<boolean>;
|
|
100
|
+
/** Whether `sideby autostart on` put a login item in this home (its file carries the sideby marker). */
|
|
101
|
+
autostartInstalled: (home: string) => Promise<boolean>;
|
|
102
|
+
/** Runs `sideby autostart --refresh --json` from the newly installed code and waits; false when it failed. */
|
|
103
|
+
refreshAutostart: (env: Env) => Promise<boolean>;
|
|
91
104
|
}
|
|
92
105
|
/** Internal, for tests: replaces parts of the machine. Not part of the plugin or Portal contract. */
|
|
93
106
|
export declare function bindUpdateDeps(deps: Partial<UpdateDeps>): () => void;
|
|
@@ -95,6 +108,8 @@ export declare function bindUpdateDeps(deps: Partial<UpdateDeps>): () => void;
|
|
|
95
108
|
export declare const runProgram: UpdateDeps['exec'];
|
|
96
109
|
/** Starts the background Panel again from the code `sideby` now names; see `UpdateDeps.restartPanel`. */
|
|
97
110
|
export declare const restartPanelByName: (env: Env, port: number) => Promise<boolean>;
|
|
111
|
+
/** Starts a program detached through the swappable machine; see `UpdateDeps.startDetached`. */
|
|
112
|
+
export declare const startDetached: (file: string, args: readonly string[], env: Env) => Promise<boolean>;
|
|
98
113
|
/** The lock every install shares (`update-install.lock`), so a Local Build switch never races an Update. */
|
|
99
114
|
export declare const takeInstallLock: (paths: Paths) => Promise<(() => Promise<void>) | null>;
|
|
100
115
|
/** -1, 0 or 1 like a sort comparator; a prerelease sorts before its release. Null when either is not a version. */
|
|
@@ -301,6 +316,8 @@ export interface UpdateResult {
|
|
|
301
316
|
restartedPanel: boolean;
|
|
302
317
|
/** The desktop app was installed and `sideby app install` ran again from the new code. */
|
|
303
318
|
refreshedApp: boolean;
|
|
319
|
+
/** A login item was installed (spec §3.29) and `sideby autostart --refresh` ran from the new code; absent without one. */
|
|
320
|
+
refreshedAutostart?: boolean;
|
|
304
321
|
/** Another Update held the lock; nothing ran. */
|
|
305
322
|
busy?: boolean;
|
|
306
323
|
/** The installed sideby skill was updated too (true), or that failed (false); absent when it was not tried. */
|
package/dist/src/core/update.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// only network request sideby makes on its own: one GET for the `latest` dist-tag, carrying nothing about the user.
|
|
3
3
|
// Everything it learns goes to one state file shared by the CLI and every Panel.
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
5
6
|
import { access, constants, mkdir, open, readdir, readFile, realpath, rm, stat } from 'node:fs/promises';
|
|
6
7
|
import { dirname, join, sep } from 'node:path';
|
|
7
8
|
import { fileURLToPath } from 'node:url';
|
|
@@ -17,6 +18,25 @@ export const RETRY_INTERVAL_MS = 60 * 60 * 1000;
|
|
|
17
18
|
export const AUTO_RETRY_MS = 24 * 60 * 60 * 1000;
|
|
18
19
|
const FETCH_TIMEOUT_MS = 5000;
|
|
19
20
|
export const RELEASE_NOTES = (v) => `https://github.com/Adonis0123/sideby/releases/tag/v${v}`;
|
|
21
|
+
/** Whether a Linux `/proc/<pid>/stat` line is of a zombie: the state after the parenthesised command name is `Z`. */
|
|
22
|
+
export function isZombieStat(stat) {
|
|
23
|
+
return /\)\s+Z\s/.test(stat.slice(stat.lastIndexOf(')')));
|
|
24
|
+
}
|
|
25
|
+
/** Swapped in tests through `bindUpdateDeps`; the real machine otherwise. */
|
|
26
|
+
/**
|
|
27
|
+
* Whether `sideby autostart --refresh --json` rewrote the login item: it exits 0 also when nothing changed or the
|
|
28
|
+
* rewrite was refused, so only its `changed: true` counts.
|
|
29
|
+
*/
|
|
30
|
+
export function autostartRefreshed(code, stdout) {
|
|
31
|
+
if (code !== 0)
|
|
32
|
+
return false;
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(stdout)?.changed === true;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
20
40
|
/** The folder of the sideby package this module belongs to: two levels above src/, three above dist/src/. */
|
|
21
41
|
async function ownPackageDir() {
|
|
22
42
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -41,11 +61,18 @@ const realDeps = {
|
|
|
41
61
|
alive(pid) {
|
|
42
62
|
try {
|
|
43
63
|
process.kill(pid, 0);
|
|
44
|
-
return true;
|
|
45
64
|
}
|
|
46
65
|
catch (err) {
|
|
47
66
|
return err.code === 'EPERM';
|
|
48
67
|
}
|
|
68
|
+
// An exited process nobody waited for (its parent went first) still answers signal 0 on Linux: a zombie holds
|
|
69
|
+
// no lock and runs no session.
|
|
70
|
+
try {
|
|
71
|
+
return !isZombieStat(readFileSync(`/proc/${pid}/stat`, 'utf8'));
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return true; // no /proc (macOS): signal 0 is the answer
|
|
75
|
+
}
|
|
49
76
|
},
|
|
50
77
|
exec: (file, args, opts) => new Promise((resolve) => {
|
|
51
78
|
let output = '';
|
|
@@ -77,6 +104,14 @@ const realDeps = {
|
|
|
77
104
|
});
|
|
78
105
|
child.once('error', () => resolve(false));
|
|
79
106
|
}),
|
|
107
|
+
startDetached: (file, args, env) => new Promise((resolve) => {
|
|
108
|
+
const child = spawn(file, args, { env: env, detached: true, stdio: 'ignore' });
|
|
109
|
+
child.once('spawn', () => {
|
|
110
|
+
child.unref();
|
|
111
|
+
resolve(true);
|
|
112
|
+
});
|
|
113
|
+
child.once('error', () => resolve(false));
|
|
114
|
+
}),
|
|
80
115
|
appInstalled: async (home, env) => {
|
|
81
116
|
const { appInstalled } = await import('../desktop/app.js');
|
|
82
117
|
return appInstalled({ home, env, platform: process.platform });
|
|
@@ -91,6 +126,25 @@ const realDeps = {
|
|
|
91
126
|
child.once('error', () => resolve(false));
|
|
92
127
|
child.once('close', (code) => resolve(code === 0));
|
|
93
128
|
}),
|
|
129
|
+
autostartInstalled: async (home) => {
|
|
130
|
+
const { autostartInstalled } = await import('./autostart.js');
|
|
131
|
+
return autostartInstalled(home);
|
|
132
|
+
},
|
|
133
|
+
refreshAutostart: (env) => new Promise((resolve) => {
|
|
134
|
+
// By name, like refreshApp: the new code records its own Node and entry; it never creates a login item.
|
|
135
|
+
const child = spawn('sideby', ['autostart', '--refresh', '--json'], {
|
|
136
|
+
env: env,
|
|
137
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
138
|
+
timeout: 60_000,
|
|
139
|
+
});
|
|
140
|
+
let out = '';
|
|
141
|
+
child.stdout?.on('data', (d) => {
|
|
142
|
+
if (out.length < 65_536)
|
|
143
|
+
out += d.toString();
|
|
144
|
+
});
|
|
145
|
+
child.once('error', () => resolve(false));
|
|
146
|
+
child.once('close', (code) => resolve(autostartRefreshed(code, out)));
|
|
147
|
+
}),
|
|
94
148
|
};
|
|
95
149
|
let override = {};
|
|
96
150
|
/** Internal, for tests: replaces parts of the machine. Not part of the plugin or Portal contract. */
|
|
@@ -108,6 +162,10 @@ export const runProgram = (file, args, opts) => deps().exec(file, args, opts);
|
|
|
108
162
|
export const restartPanelByName = (env, port) => deps()
|
|
109
163
|
.restartPanel(env, port)
|
|
110
164
|
.catch(() => false);
|
|
165
|
+
/** Starts a program detached through the swappable machine; see `UpdateDeps.startDetached`. */
|
|
166
|
+
export const startDetached = (file, args, env) => deps()
|
|
167
|
+
.startDetached(file, args, env)
|
|
168
|
+
.catch(() => false);
|
|
111
169
|
/** The lock every install shares (`update-install.lock`), so a Local Build switch never races an Update. */
|
|
112
170
|
export const takeInstallLock = (paths) => takeLock(paths, 'update-install.lock').catch(() => null);
|
|
113
171
|
// Build metadata is limited to what semver allows, so a version never carries a terminal escape into the notice.
|
|
@@ -759,6 +817,9 @@ export async function applyUpdate(paths, plan, env, opts = {}) {
|
|
|
759
817
|
// the new code writes it again. Only where one is installed; a failure here never fails the Update.
|
|
760
818
|
const installed = await d.appInstalled(paths.home, env).catch(() => false);
|
|
761
819
|
const refreshed = installed && (await d.refreshApp(env).catch(() => false));
|
|
820
|
+
// The login item records the Node and entry it starts, like the app (ADR-0029); never created here.
|
|
821
|
+
const autostart = await d.autostartInstalled(paths.home).catch(() => false);
|
|
822
|
+
const refreshedAutostart = autostart ? await d.refreshAutostart(env).catch(() => false) : undefined;
|
|
762
823
|
// The skill teaches agents this version's commands, so it follows the CLI; a failure there keeps the Update.
|
|
763
824
|
const skill = await applySkillUpdate(plan.skill, env, opts.onOutput);
|
|
764
825
|
return {
|
|
@@ -768,6 +829,7 @@ export async function applyUpdate(paths, plan, env, opts = {}) {
|
|
|
768
829
|
message: `updated sideby from ${plan.current} to ${plan.latest}`,
|
|
769
830
|
restartedPanel: restarted,
|
|
770
831
|
refreshedApp: refreshed,
|
|
832
|
+
...(refreshedAutostart !== undefined ? { refreshedAutostart } : {}),
|
|
771
833
|
...(skill ? { skillUpdated: skill.ok, skillMessage: skill.message } : {}),
|
|
772
834
|
};
|
|
773
835
|
}
|
|
@@ -105,6 +105,9 @@ export const claudeFamily = {
|
|
|
105
105
|
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
106
106
|
],
|
|
107
107
|
apiVars: ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL'],
|
|
108
|
+
secretVar: 'ANTHROPIC_AUTH_TOKEN',
|
|
109
|
+
// `-p` is one non-interactive prompt (Claude Code 2.1.301 --help, 2026-10-17).
|
|
110
|
+
probe: { args: ['-p', 'Reply with OK'] },
|
|
108
111
|
sharedItems: [
|
|
109
112
|
...LINKED.map((path) => ({ path, mode: 'link' })),
|
|
110
113
|
{ path: '.claude.json', mode: 'json-key', key: 'mcpServers', mainPath: '.claude.json', credential: true },
|
|
@@ -4,27 +4,44 @@ export interface ClaudeQuotaCache {
|
|
|
4
4
|
/** When the status line last reported these windows. */
|
|
5
5
|
observedAt: string;
|
|
6
6
|
windows: QuotaWindow[];
|
|
7
|
+
/**
|
|
8
|
+
* Per status line session id: a hash of the windows it last reported and when that changed. Only these two values
|
|
9
|
+
* are kept for a session (at most {@link MAX_SESSIONS}, none older than {@link SESSION_DAYS} days). Files written
|
|
10
|
+
* before sessions were tracked have none.
|
|
11
|
+
*/
|
|
12
|
+
sessions?: Record<string, SessionSeen>;
|
|
13
|
+
}
|
|
14
|
+
export interface SessionSeen {
|
|
15
|
+
hash: string;
|
|
16
|
+
seenAt: string;
|
|
17
|
+
}
|
|
18
|
+
/** A status line reading: the cache fields and, when Claude Code sent one, the session that reported it. */
|
|
19
|
+
export interface QuotaReading extends ClaudeQuotaCache {
|
|
20
|
+
sessionId?: string;
|
|
7
21
|
}
|
|
8
22
|
export declare function claudeQuotaCacheFile(stateDir: string, name: string): string;
|
|
9
23
|
/** sideby's state directory for this environment (same rule as the Runtime). */
|
|
10
24
|
export declare function cacheStateDir(env: Env): string;
|
|
11
25
|
/** Account name from CLAUDE_CONFIG_DIR: unset or `~/.claude` is `main`, `.claude-<name>` is `<name>`, else null. */
|
|
12
26
|
export declare function accountNameFromEnv(env: Env): string | null;
|
|
13
|
-
/** Extracts the 5h and 7d windows from status line JSON; null when there is nothing usable. */
|
|
14
|
-
export declare function parseRateLimits(input: string, observedAt: Date):
|
|
27
|
+
/** Extracts the 5h and 7d windows and the session id from status line JSON; null when there is nothing usable. */
|
|
28
|
+
export declare function parseRateLimits(input: string, observedAt: Date): QuotaReading | null;
|
|
15
29
|
/**
|
|
16
|
-
* Merges a status line reading into the
|
|
17
|
-
* idle session of the same Account
|
|
18
|
-
*
|
|
19
|
-
*
|
|
30
|
+
* Merges a status line reading into the cache (spec §3.7). Each session reports the Quota of its own last request, so
|
|
31
|
+
* an idle session of the same Account keeps repeating older numbers. A reading whose session reported exactly these
|
|
32
|
+
* windows before carries nothing new: null, so nothing is written. A reading that changed, or the first of a session,
|
|
33
|
+
* is a fresh request result and wins for every window it has, the vendor's resets included; a window it lacks is
|
|
34
|
+
* kept. A reading without a session id falls back to: within one window the higher use wins, otherwise the reading
|
|
35
|
+
* wins; null when that only repeats the cache.
|
|
20
36
|
*/
|
|
21
|
-
export declare function mergeQuotaCache(cached:
|
|
37
|
+
export declare function mergeQuotaCache(cached: Pick<ClaudeQuotaCache, 'windows' | 'sessions'> | null, next: QuotaReading): ClaudeQuotaCache | null;
|
|
22
38
|
/** Writes the cache atomically; creates the private parent directory first (writeFileAtomic does not). */
|
|
23
39
|
export declare function writeQuotaCache(stateDir: string, name: string, cache: ClaudeQuotaCache): Promise<void>;
|
|
24
40
|
export type CacheRead = {
|
|
25
41
|
status: 'ok';
|
|
26
42
|
observedAt: string;
|
|
27
43
|
windows: QuotaWindow[];
|
|
44
|
+
sessions?: Record<string, SessionSeen>;
|
|
28
45
|
} | {
|
|
29
46
|
status: 'missing';
|
|
30
47
|
} | {
|