sideby 0.16.1 → 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 +457 -5
- package/dist/src/cli/json-schemas.js +112 -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 +5 -0
- package/dist/src/core/local-build.js +23 -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 +11 -0
- package/dist/src/core/update.js +38 -0
- 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 +393 -9
- package/dist/src/panel/i18n.d.ts +172 -2
- package/dist/src/panel/i18n.js +375 -35
- package/dist/src/panel/page-logic.d.ts +196 -0
- package/dist/src/panel/page-logic.js +377 -0
- package/dist/src/panel/page-script.js +947 -126
- 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/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
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// Attachments of an AI Task (spec §3.28 "附件"): images and videos the user adds to a task. The type comes from the
|
|
2
|
+
// first bytes of the file only, never from its name or a browser's guess; sideby names the saved files itself.
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
import { constants } from 'node:fs';
|
|
5
|
+
import { chmod, lstat, mkdir, open, readdir, realpath, rename, rm, unlink } from 'node:fs/promises';
|
|
6
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
7
|
+
import { UserError } from './errors.js';
|
|
8
|
+
const MB = 1024 * 1024;
|
|
9
|
+
export const ATTACHMENT_LIMITS = { image: 20 * MB, video: 200 * MB };
|
|
10
|
+
/** The largest upload the Panel accepts; the per-kind limit is checked once the type is known. */
|
|
11
|
+
export const MAX_UPLOAD_BYTES = ATTACHMENT_LIMITS.video;
|
|
12
|
+
export const MAX_ATTACHMENTS = 5;
|
|
13
|
+
const ATTACHMENT_TTL_MS = 7 * 24 * 3600000;
|
|
14
|
+
// Uploads nobody sent with a task; a picture may show something private, so they go sooner than task folders.
|
|
15
|
+
const STAGED_TTL_MS = 24 * 3600000;
|
|
16
|
+
/** What the staging folder holds at most, whatever the browser does: uploads beyond it are refused. */
|
|
17
|
+
export const MAX_STAGED_FILES = 20;
|
|
18
|
+
export const MAX_STAGED_BYTES = 400 * MB;
|
|
19
|
+
// Enough for every signature below, including WebM's DocType inside the EBML header.
|
|
20
|
+
const SNIFF_BYTES = 4096;
|
|
21
|
+
const EXTS = ['png', 'jpg', 'gif', 'webp', 'mp4', 'mov', 'webm'];
|
|
22
|
+
const MP4_BRANDS = new Set([
|
|
23
|
+
'isom',
|
|
24
|
+
'iso2',
|
|
25
|
+
'iso3',
|
|
26
|
+
'iso4',
|
|
27
|
+
'iso5',
|
|
28
|
+
'iso6',
|
|
29
|
+
'mp41',
|
|
30
|
+
'mp42',
|
|
31
|
+
'avc1',
|
|
32
|
+
'M4V ',
|
|
33
|
+
'dash',
|
|
34
|
+
'mmp4',
|
|
35
|
+
'MSNV',
|
|
36
|
+
'f4v ',
|
|
37
|
+
]);
|
|
38
|
+
export const SUPPORTED_TYPES = 'a PNG, JPEG, GIF or WebP image, or an MP4, MOV or WebM video';
|
|
39
|
+
const ascii = (b, from, to) => Buffer.from(b.subarray(from, to)).toString('latin1');
|
|
40
|
+
/** The type of a file from its first bytes, or null when it is none sideby accepts. */
|
|
41
|
+
export function sniffType(head) {
|
|
42
|
+
const starts = (sig) => sig.every((v, i) => head[i] === v);
|
|
43
|
+
if (starts([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))
|
|
44
|
+
return { ext: 'png', kind: 'image', mime: 'image/png' };
|
|
45
|
+
if (starts([0xff, 0xd8, 0xff]))
|
|
46
|
+
return { ext: 'jpg', kind: 'image', mime: 'image/jpeg' };
|
|
47
|
+
if (ascii(head, 0, 6) === 'GIF87a' || ascii(head, 0, 6) === 'GIF89a')
|
|
48
|
+
return { ext: 'gif', kind: 'image', mime: 'image/gif' };
|
|
49
|
+
if (ascii(head, 0, 4) === 'RIFF' && ascii(head, 8, 12) === 'WEBP')
|
|
50
|
+
return { ext: 'webp', kind: 'image', mime: 'image/webp' };
|
|
51
|
+
if (ascii(head, 4, 8) === 'ftyp') {
|
|
52
|
+
const brand = ascii(head, 8, 12);
|
|
53
|
+
if (brand === 'qt ')
|
|
54
|
+
return { ext: 'mov', kind: 'video', mime: 'video/quicktime' };
|
|
55
|
+
// HEIC, AVIF and other ISO files share the box but are not videos sideby accepts.
|
|
56
|
+
if (MP4_BRANDS.has(brand))
|
|
57
|
+
return { ext: 'mp4', kind: 'video', mime: 'video/mp4' };
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
// Matroska and WebM share the EBML header; only the DocType tells them apart.
|
|
61
|
+
if (ebmlDocType(head) === 'webm')
|
|
62
|
+
return { ext: 'webm', kind: 'video', mime: 'video/webm' };
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
/** An EBML variable-length integer at `at`: its length and value (`keepMarker` for element ids), or null. */
|
|
66
|
+
function vint(b, at, keepMarker, maxLength) {
|
|
67
|
+
const first = b[at];
|
|
68
|
+
if (first === undefined || first === 0)
|
|
69
|
+
return null;
|
|
70
|
+
const length = Math.clz32(first) - 23;
|
|
71
|
+
if (length > maxLength || at + length > b.length)
|
|
72
|
+
return null;
|
|
73
|
+
let value = keepMarker ? first : first & (0xff >> length);
|
|
74
|
+
let allOnes = value === 0xff >> length;
|
|
75
|
+
for (let i = 1; i < length; i++) {
|
|
76
|
+
value = value * 256 + b[at + i];
|
|
77
|
+
allOnes &&= b[at + i] === 0xff;
|
|
78
|
+
}
|
|
79
|
+
return { length, value, unknown: !keepMarker && allOnes };
|
|
80
|
+
}
|
|
81
|
+
/** The DocType of a whole EBML header at the start of `head` (element 0x1A45DFA3, child 0x4282), or null. */
|
|
82
|
+
function ebmlDocType(head) {
|
|
83
|
+
const id = vint(head, 0, true, 4);
|
|
84
|
+
if (id?.value !== 0x1a45dfa3)
|
|
85
|
+
return null;
|
|
86
|
+
const size = vint(head, id.length, false, 8);
|
|
87
|
+
if (!size || size.unknown)
|
|
88
|
+
return null;
|
|
89
|
+
const start = id.length + size.length;
|
|
90
|
+
const end = start + size.value;
|
|
91
|
+
if (end > head.length)
|
|
92
|
+
return null;
|
|
93
|
+
for (let at = start; at < end;) {
|
|
94
|
+
const childId = vint(head, at, true, 4);
|
|
95
|
+
const childSize = childId && vint(head, at + childId.length, false, 8);
|
|
96
|
+
if (!childId || !childSize || childSize.unknown)
|
|
97
|
+
return null;
|
|
98
|
+
const data = at + childId.length + childSize.length;
|
|
99
|
+
if (data + childSize.value > end)
|
|
100
|
+
return null;
|
|
101
|
+
if (childId.value === 0x4282)
|
|
102
|
+
return ascii(head, data, data + childSize.value).replace(/\0+$/, '');
|
|
103
|
+
at = data + childSize.value;
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
function tooLarge(kind) {
|
|
108
|
+
return new UserError(kind === 'image'
|
|
109
|
+
? 'This image is over 20 MB; attach a smaller one.'
|
|
110
|
+
: 'This video is over 200 MB; trim it or attach a shorter one.', { code: 'attachment-too-large' });
|
|
111
|
+
}
|
|
112
|
+
function unsupported() {
|
|
113
|
+
return new UserError(`This file type is not supported; attach ${SUPPORTED_TYPES}.`, {
|
|
114
|
+
code: 'attachment-type',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
function stagingFull() {
|
|
118
|
+
return new UserError(`Too many attached files are waiting to be sent (at most ${MAX_STAGED_FILES} files, ${MAX_STAGED_BYTES / MB} MB in all). Send your task with the files you have or remove pending ones, then attach again; unsent files are cleared after a day.`, { code: 'attachment-quota' });
|
|
119
|
+
}
|
|
120
|
+
function tooMany() {
|
|
121
|
+
return new UserError(`Attach at most ${MAX_ATTACHMENTS} files to one task.`, {
|
|
122
|
+
code: 'too-many-attachments',
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
/** A directory the user owns, mode 700; a link or someone else's directory is refused. */
|
|
126
|
+
export async function privateDir(path) {
|
|
127
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
128
|
+
const s = await lstat(path);
|
|
129
|
+
if (!s.isDirectory() || s.uid !== process.getuid?.())
|
|
130
|
+
throw new UserError('Unsafe task directory; fix its ownership and rerun `sideby ask`.');
|
|
131
|
+
await chmod(path, 0o700);
|
|
132
|
+
return path;
|
|
133
|
+
}
|
|
134
|
+
const ID = /^[a-f0-9]{16}$/;
|
|
135
|
+
function unsafeDir(path) {
|
|
136
|
+
return new UserError(`Unsafe attachment folder ${path}: it must be your own real folder that nobody else can write. Remove it and attach again.`, { code: 'attachment-unsafe' });
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Checks `stateDir/ai/attachments/<sub…>` one folder at a time: each must be a real directory (not a link), owned by
|
|
140
|
+
* the user and not writable by group or others. A missing one is made (mode 700) when `create` is set; otherwise the
|
|
141
|
+
* answer is null. Every path sideby reads, writes or removes under the attachments folder goes through here first.
|
|
142
|
+
*/
|
|
143
|
+
async function attachmentDir(paths, sub, create) {
|
|
144
|
+
if (create)
|
|
145
|
+
await mkdir(paths.stateDir, { recursive: true, mode: 0o700 });
|
|
146
|
+
let dir = paths.stateDir;
|
|
147
|
+
for (const part of ['ai', 'attachments', ...sub]) {
|
|
148
|
+
dir = join(dir, part);
|
|
149
|
+
let s = await lstat(dir).catch((err) => {
|
|
150
|
+
if (err.code === 'ENOENT')
|
|
151
|
+
return null;
|
|
152
|
+
throw err;
|
|
153
|
+
});
|
|
154
|
+
if (!s) {
|
|
155
|
+
if (!create)
|
|
156
|
+
return null;
|
|
157
|
+
await mkdir(dir, { mode: 0o700 }).catch((err) => {
|
|
158
|
+
if (err.code !== 'EEXIST')
|
|
159
|
+
throw err;
|
|
160
|
+
});
|
|
161
|
+
s = await lstat(dir);
|
|
162
|
+
if (s.isDirectory() && s.uid === process.getuid?.())
|
|
163
|
+
await chmod(dir, 0o700);
|
|
164
|
+
s = await lstat(dir);
|
|
165
|
+
}
|
|
166
|
+
if (!s.isDirectory() || s.uid !== process.getuid?.() || (s.mode & 0o022) !== 0)
|
|
167
|
+
throw unsafeDir(dir);
|
|
168
|
+
}
|
|
169
|
+
return dir;
|
|
170
|
+
}
|
|
171
|
+
const attachmentsRoot = (paths, create) => attachmentDir(paths, [], create);
|
|
172
|
+
const stagingDir = async (paths) => (await attachmentDir(paths, ['staging'], true));
|
|
173
|
+
const taskDir = async (paths, taskId) => {
|
|
174
|
+
if (!ID.test(taskId))
|
|
175
|
+
throw new Error('invalid task id');
|
|
176
|
+
return (await attachmentDir(paths, [taskId], true));
|
|
177
|
+
};
|
|
178
|
+
/**
|
|
179
|
+
* Streams `source` into `<dir>/<name>.<ext>` (mode 600, through a temp file and a rename). It stops as soon as the
|
|
180
|
+
* first bytes show an unsupported type or the file passes its kind's limit, so a wrong upload is not read whole.
|
|
181
|
+
*/
|
|
182
|
+
async function saveStream(source, dir, name, budget = Number.POSITIVE_INFINITY) {
|
|
183
|
+
const tmp = join(dir, `.${name}.${randomBytes(6).toString('hex')}.tmp`);
|
|
184
|
+
const fh = await open(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
185
|
+
let bytes = 0;
|
|
186
|
+
let head = Buffer.alloc(0);
|
|
187
|
+
let type = null;
|
|
188
|
+
try {
|
|
189
|
+
for await (const chunk of source) {
|
|
190
|
+
const buf = Buffer.from(chunk);
|
|
191
|
+
bytes += buf.length;
|
|
192
|
+
if (!type && head.length < SNIFF_BYTES) {
|
|
193
|
+
head = Buffer.concat([head, buf.subarray(0, SNIFF_BYTES - head.length)]);
|
|
194
|
+
if (head.length >= SNIFF_BYTES) {
|
|
195
|
+
type = sniffType(head);
|
|
196
|
+
if (!type)
|
|
197
|
+
throw unsupported();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (bytes > (type ? ATTACHMENT_LIMITS[type.kind] : MAX_UPLOAD_BYTES))
|
|
201
|
+
throw tooLarge(type?.kind ?? 'video');
|
|
202
|
+
if (bytes > budget)
|
|
203
|
+
throw stagingFull();
|
|
204
|
+
await fh.write(buf);
|
|
205
|
+
}
|
|
206
|
+
type ??= sniffType(head);
|
|
207
|
+
if (!type)
|
|
208
|
+
throw unsupported();
|
|
209
|
+
if (bytes > ATTACHMENT_LIMITS[type.kind])
|
|
210
|
+
throw tooLarge(type.kind);
|
|
211
|
+
await fh.chmod(0o600);
|
|
212
|
+
await fh.close();
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
await fh.close().catch(() => undefined);
|
|
216
|
+
await rm(tmp, { force: true });
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
const path = join(dir, `${name}.${type.ext}`);
|
|
220
|
+
try {
|
|
221
|
+
await rename(tmp, path);
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
await rm(tmp, { force: true });
|
|
225
|
+
throw err;
|
|
226
|
+
}
|
|
227
|
+
return { path, type, bytes };
|
|
228
|
+
}
|
|
229
|
+
/** Opens one of the user's files for `--attach`: a regular file, not a link. */
|
|
230
|
+
async function openUserFile(file) {
|
|
231
|
+
const path = resolve(file);
|
|
232
|
+
let fh;
|
|
233
|
+
try {
|
|
234
|
+
fh = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
const code = err.code;
|
|
238
|
+
throw new UserError(code === 'ELOOP'
|
|
239
|
+
? `${path} is a link; attach the file itself.`
|
|
240
|
+
: `Cannot read ${path}; check the path and rerun \`sideby ask\`.`, { code: code === 'ELOOP' ? 'attachment-link' : 'attachment-missing' });
|
|
241
|
+
}
|
|
242
|
+
const s = await fh.stat();
|
|
243
|
+
if (!s.isFile()) {
|
|
244
|
+
await fh.close();
|
|
245
|
+
throw new UserError(`${path} is not a file; attach an image or a video.`, { code: 'attachment-missing' });
|
|
246
|
+
}
|
|
247
|
+
return { path, fh, size: s.size };
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* `sideby ask --print --attach`: checks the user's files as an upload would be checked and returns them where they
|
|
251
|
+
* are, since `--print` writes nothing.
|
|
252
|
+
*/
|
|
253
|
+
export async function checkAttachmentFiles(files) {
|
|
254
|
+
if (files.length > MAX_ATTACHMENTS)
|
|
255
|
+
throw tooMany();
|
|
256
|
+
const out = [];
|
|
257
|
+
for (const file of files) {
|
|
258
|
+
const { path, fh, size } = await openUserFile(file);
|
|
259
|
+
try {
|
|
260
|
+
const head = Buffer.alloc(Math.min(SNIFF_BYTES, size));
|
|
261
|
+
await fh.read(head, 0, head.length, 0);
|
|
262
|
+
const type = sniffType(head);
|
|
263
|
+
if (!type)
|
|
264
|
+
throw unsupported();
|
|
265
|
+
if (size > ATTACHMENT_LIMITS[type.kind])
|
|
266
|
+
throw tooLarge(type.kind);
|
|
267
|
+
out.push({ path, kind: type.kind, bytes: size });
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
await fh.close();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
/** `sideby ask --attach`: copies the user's files into the task's attachment folder as `1.png`, `2.mp4`, … */
|
|
276
|
+
export async function copyAttachments(paths, files, taskId) {
|
|
277
|
+
if (files.length > MAX_ATTACHMENTS)
|
|
278
|
+
throw tooMany();
|
|
279
|
+
await checkAttachmentFiles(files);
|
|
280
|
+
const dir = await taskDir(paths, taskId);
|
|
281
|
+
const out = [];
|
|
282
|
+
for (const [i, file] of files.entries()) {
|
|
283
|
+
const { fh } = await openUserFile(file);
|
|
284
|
+
try {
|
|
285
|
+
const saved = await saveStream(fh.createReadStream({ autoClose: false }), dir, String(i + 1));
|
|
286
|
+
out.push({ path: saved.path, kind: saved.type.kind, bytes: saved.bytes });
|
|
287
|
+
}
|
|
288
|
+
finally {
|
|
289
|
+
await fh.close();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
/** A Panel upload: saved under a random id in the staging folder until `/api/ask` takes it into a task. */
|
|
295
|
+
// One upload at a time per staging folder: the count and the write must not interleave, or two uploads that arrive
|
|
296
|
+
// together both see room for one more and the folder ends up over its limit.
|
|
297
|
+
const stagingLocks = new Map();
|
|
298
|
+
export async function stageUpload(paths, source, now = Date.now()) {
|
|
299
|
+
const dir = await stagingDir(paths);
|
|
300
|
+
const before = stagingLocks.get(dir) ?? Promise.resolve();
|
|
301
|
+
const run = before.catch(() => { }).then(() => stageLocked(dir, source, now));
|
|
302
|
+
stagingLocks.set(dir, run);
|
|
303
|
+
try {
|
|
304
|
+
return await run;
|
|
305
|
+
}
|
|
306
|
+
finally {
|
|
307
|
+
if (stagingLocks.get(dir) === run)
|
|
308
|
+
stagingLocks.delete(dir);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function stageLocked(dir, source, now) {
|
|
312
|
+
const kept = await cleanStaging(dir, now);
|
|
313
|
+
if (kept.files >= MAX_STAGED_FILES || kept.bytes >= MAX_STAGED_BYTES)
|
|
314
|
+
throw stagingFull();
|
|
315
|
+
const id = randomBytes(8).toString('hex');
|
|
316
|
+
const saved = await saveStream(source, dir, id, MAX_STAGED_BYTES - kept.bytes);
|
|
317
|
+
return { id, kind: saved.type.kind, bytes: saved.bytes, mime: saved.type.mime };
|
|
318
|
+
}
|
|
319
|
+
// A staged upload (`<id>.<ext>`) or the temp file of one still being written (`.<id>.<hex>.tmp`).
|
|
320
|
+
const STAGED_NAME = new RegExp(`^(?:[a-f0-9]{16}\\.(?:${EXTS.join('|')})|\\.[a-f0-9]{16}\\.[a-f0-9]{12}\\.tmp)$`);
|
|
321
|
+
/** True when `path` is still inside `root` once links are resolved (`root` itself already checked). */
|
|
322
|
+
async function inside(root, path) {
|
|
323
|
+
const real = await realpath(path).catch(() => null);
|
|
324
|
+
if (!real)
|
|
325
|
+
return false;
|
|
326
|
+
const rel = relative(await realpath(root), real);
|
|
327
|
+
return rel !== '' && !rel.startsWith(`..${sep}`) && rel !== '..' && !rel.startsWith(sep);
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Removes staged uploads older than a day from a checked staging folder and returns what is left. Only regular files
|
|
331
|
+
* of the user with names sideby gives are counted or removed.
|
|
332
|
+
*/
|
|
333
|
+
async function cleanStaging(dir, now) {
|
|
334
|
+
const uid = process.getuid?.();
|
|
335
|
+
const left = { files: 0, bytes: 0 };
|
|
336
|
+
for (const name of await readdir(dir)) {
|
|
337
|
+
if (!STAGED_NAME.test(name))
|
|
338
|
+
continue;
|
|
339
|
+
const path = join(dir, name);
|
|
340
|
+
try {
|
|
341
|
+
const s = await lstat(path);
|
|
342
|
+
if (!s.isFile() || s.uid !== uid)
|
|
343
|
+
continue;
|
|
344
|
+
if (now - s.mtimeMs > STAGED_TTL_MS && (await inside(dir, path)))
|
|
345
|
+
await unlink(path);
|
|
346
|
+
else if (!name.startsWith('.')) {
|
|
347
|
+
left.files++;
|
|
348
|
+
left.bytes += s.size;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
catch {
|
|
352
|
+
// Taken into a task or removed by another sideby meanwhile.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return left;
|
|
356
|
+
}
|
|
357
|
+
async function findStaged(paths, ids) {
|
|
358
|
+
if (ids.length > MAX_ATTACHMENTS)
|
|
359
|
+
throw tooMany();
|
|
360
|
+
if (new Set(ids).size !== ids.length)
|
|
361
|
+
throw new UserError('The same file is attached twice; remove one.');
|
|
362
|
+
const dir = await stagingDir(paths);
|
|
363
|
+
const names = await readdir(dir);
|
|
364
|
+
return Promise.all(ids.map(async (id) => {
|
|
365
|
+
const gone = new UserError('An attached file is no longer there; add it again.', {
|
|
366
|
+
code: 'attachment-missing',
|
|
367
|
+
});
|
|
368
|
+
if (!ID.test(id))
|
|
369
|
+
throw gone;
|
|
370
|
+
const name = names.find((n) => EXTS.some((e) => n === `${id}.${e}`));
|
|
371
|
+
if (!name)
|
|
372
|
+
throw gone;
|
|
373
|
+
const path = join(dir, name);
|
|
374
|
+
const s = await lstat(path);
|
|
375
|
+
if (!s.isFile() || s.uid !== process.getuid?.())
|
|
376
|
+
throw gone;
|
|
377
|
+
const ext = name.slice(id.length + 1);
|
|
378
|
+
const kind = ext === 'mp4' || ext === 'mov' || ext === 'webm' ? 'video' : 'image';
|
|
379
|
+
return { path, ext, kind, bytes: s.size };
|
|
380
|
+
}));
|
|
381
|
+
}
|
|
382
|
+
/** The staged uploads where they are, for a prompt shown before the task exists. */
|
|
383
|
+
export async function stagedAttachments(paths, ids) {
|
|
384
|
+
return (await findStaged(paths, ids)).map(({ path, kind, bytes }) => ({ path, kind, bytes }));
|
|
385
|
+
}
|
|
386
|
+
/** Deletes staged uploads the person removed before sending; an id that is already gone is skipped. */
|
|
387
|
+
export async function removeStaged(paths, ids) {
|
|
388
|
+
let removed = 0;
|
|
389
|
+
for (const id of ids) {
|
|
390
|
+
const found = await findStaged(paths, [id]).catch(() => []);
|
|
391
|
+
for (const f of found) {
|
|
392
|
+
await unlink(f.path);
|
|
393
|
+
removed++;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return removed;
|
|
397
|
+
}
|
|
398
|
+
/** Moves staged uploads into the task's attachment folder, in the order given. */
|
|
399
|
+
export async function takeStaged(paths, ids, taskId) {
|
|
400
|
+
const found = await findStaged(paths, ids);
|
|
401
|
+
if (!found.length)
|
|
402
|
+
return [];
|
|
403
|
+
const dir = await taskDir(paths, taskId);
|
|
404
|
+
const out = [];
|
|
405
|
+
for (const [i, f] of found.entries()) {
|
|
406
|
+
const path = join(dir, `${i + 1}.${f.ext}`);
|
|
407
|
+
await rename(f.path, path);
|
|
408
|
+
out.push({ path, kind: f.kind, bytes: f.bytes });
|
|
409
|
+
}
|
|
410
|
+
return out;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Drops task attachment folders older than 7 days and staged uploads older than a day; leaves anything else, and
|
|
414
|
+
* does nothing when a folder on the way is not the user's own real folder.
|
|
415
|
+
*/
|
|
416
|
+
export async function removeOldAttachments(paths, now = Date.now()) {
|
|
417
|
+
const base = await attachmentsRoot(paths, false).catch(() => null);
|
|
418
|
+
if (!base)
|
|
419
|
+
return;
|
|
420
|
+
const uid = process.getuid?.();
|
|
421
|
+
for (const name of await readdir(base).catch(() => [])) {
|
|
422
|
+
const path = join(base, name);
|
|
423
|
+
try {
|
|
424
|
+
if (ID.test(name)) {
|
|
425
|
+
const s = await lstat(path);
|
|
426
|
+
if (s.isDirectory() &&
|
|
427
|
+
s.uid === uid &&
|
|
428
|
+
now - s.mtimeMs > ATTACHMENT_TTL_MS &&
|
|
429
|
+
(await inside(base, path)))
|
|
430
|
+
await rm(path, { recursive: true, force: true });
|
|
431
|
+
}
|
|
432
|
+
else if (name === 'staging') {
|
|
433
|
+
const staging = await attachmentDir(paths, ['staging'], false);
|
|
434
|
+
if (staging)
|
|
435
|
+
await cleanStaging(staging, now);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
// Another sideby may be cleaning too; a leftover is retried next time.
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type AskLauncher } from './terminal.ts';
|
|
2
|
+
export declare const AUTOSTART_LABEL = "dev.sideby.panel";
|
|
3
|
+
/** Present in every agent sideby writes; a file without it is someone else's. */
|
|
4
|
+
export declare const AUTOSTART_MARKER = "Written by `sideby autostart`";
|
|
5
|
+
export type AutostartState = 'off' | 'on' | 'stale' | 'foreign';
|
|
6
|
+
/** Runs a program and resolves with its exit code and output; never rejects. */
|
|
7
|
+
export type AutostartExec = (file: string, args: readonly string[]) => Promise<{
|
|
8
|
+
code: number;
|
|
9
|
+
output: string;
|
|
10
|
+
}>;
|
|
11
|
+
export interface AutostartContext {
|
|
12
|
+
home: string;
|
|
13
|
+
platform?: NodeJS.Platform;
|
|
14
|
+
/** The Node and sideby entry the agent should start; this process's by default. */
|
|
15
|
+
launcher?: AskLauncher;
|
|
16
|
+
exec?: AutostartExec;
|
|
17
|
+
uid?: number;
|
|
18
|
+
/** The home launchd reads LaunchAgents from (the login user's); `launchctl` runs only when `home` is it. */
|
|
19
|
+
loginHome?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface AutostartStatus {
|
|
22
|
+
/** False on a system sideby cannot start at login yet; `reason` says so. */
|
|
23
|
+
supported: boolean;
|
|
24
|
+
reason?: string;
|
|
25
|
+
state: AutostartState;
|
|
26
|
+
path: string;
|
|
27
|
+
/** What sideby's agent starts (absent when off or foreign). */
|
|
28
|
+
programArguments?: string[];
|
|
29
|
+
/** The agent starts this sideby with this Node. */
|
|
30
|
+
current: boolean;
|
|
31
|
+
/** Recorded paths that no longer exist (state `stale`). */
|
|
32
|
+
missing?: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface AutostartPlan {
|
|
35
|
+
action: 'on' | 'off';
|
|
36
|
+
status: AutostartStatus;
|
|
37
|
+
/** False when nothing needs to change. */
|
|
38
|
+
changed: boolean;
|
|
39
|
+
path: string;
|
|
40
|
+
/** The file `on` writes. */
|
|
41
|
+
content?: string;
|
|
42
|
+
programArguments?: string[];
|
|
43
|
+
/** Programs run after the write (`on`) or before the removal (`off`). */
|
|
44
|
+
commands: string[][];
|
|
45
|
+
/** Why it cannot be done, with what to do instead; nothing is changed then. */
|
|
46
|
+
blocked?: {
|
|
47
|
+
code: string;
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
50
|
+
/** Hash of the file when planned (null: none); apply refuses when it changed since. */
|
|
51
|
+
hash: string | null;
|
|
52
|
+
}
|
|
53
|
+
export interface AutostartResult {
|
|
54
|
+
changed: boolean;
|
|
55
|
+
state: AutostartState;
|
|
56
|
+
path: string;
|
|
57
|
+
programArguments?: string[];
|
|
58
|
+
/** launchd took the change now (false: it takes effect at the next login, `note` says why). */
|
|
59
|
+
loaded: boolean;
|
|
60
|
+
note?: string;
|
|
61
|
+
}
|
|
62
|
+
export declare const autostartPath: (home: string) => string;
|
|
63
|
+
export declare const programArgumentsFor: (l: AskLauncher) => string[];
|
|
64
|
+
/** The agent's file: starts the Panel once at login, never again by itself, and lets it outlive the job. */
|
|
65
|
+
export declare function plistContent(args: readonly string[]): string;
|
|
66
|
+
/** The ProgramArguments of a file sideby wrote; null when it holds none. */
|
|
67
|
+
export declare function parseProgramArguments(content: string): string[] | null;
|
|
68
|
+
/** Whether a sideby agent (with the marker) is installed; reads only that one file. */
|
|
69
|
+
export declare function autostartInstalled(home: string): Promise<boolean>;
|
|
70
|
+
export declare function autostartStatus(ctx: AutostartContext): Promise<AutostartStatus>;
|
|
71
|
+
/** Why the agent cannot start this launcher, with what to do instead; null when it can. */
|
|
72
|
+
export declare function autostartLauncherProblem(l: AskLauncher): Promise<{
|
|
73
|
+
code: string;
|
|
74
|
+
message: string;
|
|
75
|
+
} | null>;
|
|
76
|
+
/** What `on` or `off` would change; writes nothing. */
|
|
77
|
+
export declare function planAutostart(ctx: AutostartContext, action: 'on' | 'off'): Promise<AutostartPlan>;
|
|
78
|
+
/** Carries out a plan the user confirmed. Refuses, changing nothing, when the file changed since it was planned. */
|
|
79
|
+
export declare function applyAutostart(ctx: AutostartContext, plan: AutostartPlan): Promise<AutostartResult>;
|
|
80
|
+
/**
|
|
81
|
+
* After `sideby update` or `pnpm local`: rewrites an existing sideby agent so it starts `launcher`. Never creates one,
|
|
82
|
+
* never touches a foreign file, and never throws; `rewritten` says whether the file changed.
|
|
83
|
+
*/
|
|
84
|
+
export declare function refreshAutostart(ctx: AutostartContext): Promise<{
|
|
85
|
+
rewritten: boolean;
|
|
86
|
+
state: AutostartState;
|
|
87
|
+
note?: string;
|
|
88
|
+
}>;
|