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.
Files changed (75) hide show
  1. package/README.md +5 -3
  2. package/dist/build.json +1 -1
  3. package/dist/src/cli/commands.d.ts +16 -0
  4. package/dist/src/cli/commands.js +457 -5
  5. package/dist/src/cli/json-schemas.js +112 -0
  6. package/dist/src/cli/main.js +93 -3
  7. package/dist/src/core/ask.d.ts +150 -0
  8. package/dist/src/core/ask.js +365 -0
  9. package/dist/src/core/attachments.d.ts +50 -0
  10. package/dist/src/core/attachments.js +442 -0
  11. package/dist/src/core/autostart.d.ts +88 -0
  12. package/dist/src/core/autostart.js +256 -0
  13. package/dist/src/core/config.d.ts +97 -0
  14. package/dist/src/core/config.js +278 -10
  15. package/dist/src/core/launch.d.ts +15 -4
  16. package/dist/src/core/launch.js +27 -23
  17. package/dist/src/core/local-build.d.ts +5 -0
  18. package/dist/src/core/local-build.js +23 -3
  19. package/dist/src/core/probe.d.ts +78 -0
  20. package/dist/src/core/probe.js +127 -0
  21. package/dist/src/core/same-login.d.ts +9 -4
  22. package/dist/src/core/same-login.js +9 -4
  23. package/dist/src/core/secret-file.d.ts +26 -0
  24. package/dist/src/core/secret-file.js +126 -7
  25. package/dist/src/core/terminal.d.ts +93 -0
  26. package/dist/src/core/terminal.js +371 -0
  27. package/dist/src/core/update.d.ts +11 -0
  28. package/dist/src/core/update.js +38 -0
  29. package/dist/src/families/claude/index.js +3 -0
  30. package/dist/src/families/claude/quota-cache.d.ts +24 -7
  31. package/dist/src/families/claude/quota-cache.js +72 -21
  32. package/dist/src/families/codex/index.js +7 -0
  33. package/dist/src/families/codex/login-key.d.ts +8 -0
  34. package/dist/src/families/codex/login-key.js +33 -0
  35. package/dist/src/families/grok/index.js +3 -0
  36. package/dist/src/families/login-keys.d.ts +6 -0
  37. package/dist/src/families/login-keys.js +6 -0
  38. package/dist/src/families/pi/index.js +4 -0
  39. package/dist/src/panel/background.js +3 -2
  40. package/dist/src/panel/handler.d.ts +29 -2
  41. package/dist/src/panel/handler.js +393 -9
  42. package/dist/src/panel/i18n.d.ts +172 -2
  43. package/dist/src/panel/i18n.js +375 -35
  44. package/dist/src/panel/page-logic.d.ts +196 -0
  45. package/dist/src/panel/page-logic.js +377 -0
  46. package/dist/src/panel/page-script.js +947 -126
  47. package/dist/src/panel/page-styles.js +69 -19
  48. package/dist/src/panel/page.d.ts +5 -0
  49. package/dist/src/panel/page.js +2 -0
  50. package/dist/src/panel/server.d.ts +16 -2
  51. package/dist/src/panel/server.js +41 -13
  52. package/dist/src/plugins/check.d.ts +47 -0
  53. package/dist/src/plugins/check.js +544 -0
  54. package/dist/src/plugins/scaffold.d.ts +21 -0
  55. package/dist/src/plugins/scaffold.js +123 -0
  56. package/dist/src/quota/statusline-tap.js +1 -1
  57. package/dist/src/runtime.d.ts +10 -0
  58. package/dist/src/runtime.js +47 -16
  59. package/dist/src/types.d.ts +19 -0
  60. package/package.json +2 -2
  61. package/schemas/ask.json +180 -0
  62. package/schemas/autostart.json +165 -0
  63. package/schemas/config-get.json +132 -0
  64. package/schemas/config-set.json +107 -0
  65. package/schemas/config.json +73 -6
  66. package/schemas/key.json +93 -0
  67. package/schemas/plugins-check.json +113 -0
  68. package/schemas/plugins-new.json +87 -0
  69. package/schemas/terminal.json +152 -0
  70. package/schemas/try.json +109 -0
  71. package/schemas/update.json +4 -0
  72. package/skills/sideby/SKILL.md +12 -6
  73. package/skills/sideby/references/accounts.md +1 -1
  74. package/skills/sideby/references/ai-tasks.md +116 -0
  75. package/skills/sideby/references/panel.md +14 -0
@@ -0,0 +1,365 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, mkdtemp, open, readdir, rm, unlink } from 'node:fs/promises';
4
+ import { dirname, isAbsolute, join } from 'node:path';
5
+ import { AI_TASK_KINDS, stripSecrets, taskPrompt } from '../panel/page-logic.js';
6
+ import { checkAttachmentFiles, copyAttachments, MAX_ATTACHMENTS, privateDir, removeOldAttachments, stagedAttachments, takeStaged, } from './attachments.js';
7
+ import { editConfigAsk, editConfigTerminal } from './config.js';
8
+ import { depsFor } from './desktop-app.js';
9
+ import { UserError } from './errors.js';
10
+ import { writeFileAtomic } from './fs-safe.js';
11
+ import { planHandoff } from './handoff.js';
12
+ import { resolvePaths } from './paths.js';
13
+ import { runProbe, spawnProbe } from './probe.js';
14
+ import { askLauncher, askRunCommand, guessTerminal, installedTerminals, launcherProblem, openTerminal, TERMINALS, validateCustomTemplate, } from './terminal.js';
15
+ export async function pickTaskAccount(rt, opts = {}) {
16
+ const all = (await rt.accounts()).filter((a) => !opts.family || a.family === opts.family);
17
+ if (opts.account) {
18
+ const a = await rt.resolve(opts.account);
19
+ if (opts.family && a.family !== opts.family)
20
+ throw new UserError('Choose an account of the requested family; run `sideby list`.');
21
+ if (a.kind === 'api' || (await rt.status(a)).login === 'logged-in')
22
+ return { account: a };
23
+ return { reason: 'none-signed-in' };
24
+ }
25
+ // A remembered choice may be an API Account; only the automatic pick below skips them.
26
+ const preferred = all.find((a) => a.ref === rt.config.ask?.account);
27
+ if (preferred && (preferred.kind === 'api' || (await rt.status(preferred)).login === 'logged-in'))
28
+ return { account: preferred };
29
+ if (!all.length)
30
+ return { reason: 'no-accounts' };
31
+ const candidates = [];
32
+ for (const a of all) {
33
+ if (a.kind !== 'subscription' || (await rt.status(a)).login !== 'logged-in')
34
+ continue;
35
+ candidates.push({ ...a, login: 'logged-in', quota: await rt.quota(a, { fetch: 'never' }) });
36
+ }
37
+ if (!candidates.length)
38
+ return { reason: 'none-signed-in' };
39
+ const plan = planHandoff('', candidates, rt.readContext().now);
40
+ const account = all.find((a) => a.ref === plan.pick);
41
+ return account ? { account } : { reason: 'all-full' };
42
+ }
43
+ export async function taskWorkDir(paths = resolvePaths()) {
44
+ return privateDir(join(paths.stateDir, 'ai'));
45
+ }
46
+ const TASK_TTL_MS = 3600000;
47
+ // Tasks nobody ran (the terminal did not open, the command was not pasted) still hold a prompt: drop them.
48
+ async function removeStaleTasks(dir) {
49
+ const names = await readdir(dir).catch(() => []);
50
+ for (const name of names) {
51
+ if (!/^[a-f0-9]{16}\.json$/.test(name))
52
+ continue;
53
+ const file = join(dir, name);
54
+ try {
55
+ const s = await lstat(file);
56
+ if (s.isFile() && s.uid === process.getuid?.() && Date.now() - s.mtimeMs > TASK_TTL_MS)
57
+ await unlink(file);
58
+ }
59
+ catch {
60
+ // Another process may have consumed it; a leftover is retried next time.
61
+ }
62
+ }
63
+ }
64
+ export const newTaskId = () => randomBytes(8).toString('hex');
65
+ export async function writeTask(task, paths = resolvePaths(), id = newTaskId()) {
66
+ const dir = await privateDir(join(paths.stateDir, 'ask'));
67
+ await removeStaleTasks(dir);
68
+ await removeOldAttachments(paths);
69
+ const where = { home: paths.home, configDir: paths.configDir, stateDir: paths.stateDir };
70
+ await writeFileAtomic(join(dir, `${id}.json`), JSON.stringify({ ...task, ...where }), 0o600);
71
+ return id;
72
+ }
73
+ /**
74
+ * The state folder `ask --run --state-dir` names, accepted only when it and its `ask` folder are real directories the
75
+ * user owns that nobody else can write (the `ask` folder private, as writeTask makes it). Anything else is refused.
76
+ */
77
+ export async function taskStateDir(dir) {
78
+ const refuse = () => new UserError('This task folder is not yours or not private; click again or rerun `sideby ask`.', {
79
+ code: 'invalid-task',
80
+ });
81
+ if (!isAbsolute(dir) || dir.includes('\0'))
82
+ throw refuse();
83
+ try {
84
+ const state = await lstat(dir);
85
+ const ask = await lstat(join(dir, 'ask'));
86
+ const uid = process.getuid?.();
87
+ if (!state.isDirectory() ||
88
+ !ask.isDirectory() ||
89
+ state.uid !== uid ||
90
+ ask.uid !== uid ||
91
+ (state.mode & 0o022) !== 0 ||
92
+ (ask.mode & 0o077) !== 0)
93
+ throw refuse();
94
+ }
95
+ catch {
96
+ throw refuse();
97
+ }
98
+ return dir;
99
+ }
100
+ function validAttachments(list) {
101
+ return (Array.isArray(list) &&
102
+ list.length <= MAX_ATTACHMENTS &&
103
+ list.every((a) => typeof a?.path === 'string' && isAbsolute(a.path) && (a.kind === 'image' || a.kind === 'video')));
104
+ }
105
+ /**
106
+ * The Host arguments `ask --run` adds for a prompt task: `<imageArg>=<path>` per attached image for a Family that
107
+ * declares `imageArg` (spec §3.28 "附件"), then the prompt, always last. The `=` form keeps one value per option, so a
108
+ * Host option that takes several values cannot swallow the prompt. An image that is gone, or whose path holds a comma
109
+ * (a Host taking lists may split it), is left to the path in the prompt.
110
+ */
111
+ export async function taskHostArgs(rt, task) {
112
+ const flag = rt.families.get((await rt.resolve(task.account)).family)?.imageArg;
113
+ const images = [];
114
+ if (flag)
115
+ for (const a of task.attachments ?? []) {
116
+ if (a.kind !== 'image' || a.path.includes(','))
117
+ continue;
118
+ if (await lstat(a.path).then((s) => s.isFile(), () => false))
119
+ images.push(`${flag}=${a.path}`);
120
+ }
121
+ return [...images, task.prompt ?? ''];
122
+ }
123
+ /** `env` with the HOME and XDG folders the task was written under, so the Runtime sees what the Panel saw. */
124
+ export function taskEnv(task, env) {
125
+ if (!task.home || !task.configDir || !task.stateDir)
126
+ return env;
127
+ return {
128
+ ...env,
129
+ HOME: task.home,
130
+ XDG_CONFIG_HOME: dirname(task.configDir),
131
+ XDG_STATE_HOME: dirname(task.stateDir),
132
+ };
133
+ }
134
+ export async function readTask(id, paths = resolvePaths()) {
135
+ const invalid = () => new UserError('This task is unavailable or expired; click again or rerun `sideby ask`.', {
136
+ code: 'invalid-task',
137
+ });
138
+ if (!/^[a-f0-9]{16}$/.test(id))
139
+ throw invalid();
140
+ const file = join(paths.stateDir, 'ask', `${id}.json`);
141
+ try {
142
+ const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
143
+ try {
144
+ const s = await handle.stat();
145
+ if (!s.isFile() ||
146
+ s.uid !== process.getuid?.() ||
147
+ (s.mode & 0o7777) !== 0o600 ||
148
+ Date.now() - s.mtimeMs > TASK_TTL_MS ||
149
+ s.size > 1024 * 1024)
150
+ throw invalid();
151
+ const task = JSON.parse(await handle.readFile('utf8'));
152
+ if (!task ||
153
+ !['prompt', 'login'].includes(task.kind) ||
154
+ typeof task.account !== 'string' ||
155
+ typeof task.cwd !== 'string' ||
156
+ !isAbsolute(task.cwd) ||
157
+ (task.kind === 'prompt' && typeof task.prompt !== 'string') ||
158
+ (task.attachments !== undefined && !validAttachments(task.attachments)) ||
159
+ // Paths are all there or all absent (a task written before they were recorded), and the state folder is the
160
+ // one the task was read from.
161
+ !((task.home === undefined && task.configDir === undefined && task.stateDir === undefined) ||
162
+ ([task.home, task.configDir, task.stateDir].every((p) => typeof p === 'string' && isAbsolute(p)) &&
163
+ task.stateDir === paths.stateDir)))
164
+ throw invalid();
165
+ await unlink(file);
166
+ return task;
167
+ }
168
+ finally {
169
+ await handle.close();
170
+ }
171
+ }
172
+ catch {
173
+ throw invalid();
174
+ }
175
+ }
176
+ export async function terminals(rt) {
177
+ const deps = depsFor(rt);
178
+ // The Utilities folders are added only to the real defaults, so an injected appDirs stays the whole list.
179
+ const appDirs = deps.appDirs.some((d) => d === '/Applications')
180
+ ? [...deps.appDirs, '/System/Applications/Utilities', '/Applications/Utilities']
181
+ : deps.appDirs;
182
+ const installed = await installedTerminals({
183
+ home: rt.paths.home,
184
+ env: rt.env,
185
+ platform: deps.platform,
186
+ appDirs,
187
+ });
188
+ if (rt.config.terminal?.custom)
189
+ installed.push({ id: 'custom' });
190
+ return {
191
+ installed,
192
+ preferred: rt.config.terminal?.preferred ?? null,
193
+ guess: guessTerminal({ preferred: rt.config.terminal?.preferred, env: rt.env, installed }),
194
+ terminals: TERMINALS.map((t) => ({ ...t, installed: installed.some((i) => i.id === t.id) })),
195
+ };
196
+ }
197
+ export async function setPreferredTerminal(rt, id) {
198
+ if (id !== undefined && !TERMINALS.some((t) => t.id === id))
199
+ throw new UserError(`Unknown terminal ${JSON.stringify(id)}; choose one of ${TERMINALS.map((t) => t.id).join(', ')}. Run \`sideby terminal\` to see which are installed.`, { code: 'unknown-terminal' });
200
+ if (id === 'custom' && !rt.config.terminal?.custom)
201
+ throw new UserError("Set the custom command first: `sideby terminal --custom '<program> … {cwd} … {cmd}'`.", { code: 'custom-missing' });
202
+ const r = await editConfigTerminal(rt.paths.configFile, { preferred: id });
203
+ rt.config.terminal = r.after;
204
+ }
205
+ /** Saves `terminal.custom` after checking it names a program and both placeholders; selects it too. */
206
+ export async function setCustomTerminal(rt, template) {
207
+ try {
208
+ validateCustomTemplate(template);
209
+ }
210
+ catch (err) {
211
+ throw new UserError(err.message, { code: 'custom-invalid' });
212
+ }
213
+ const r = await editConfigTerminal(rt.paths.configFile, { custom: template, preferred: 'custom' });
214
+ rt.config.terminal = r.after;
215
+ }
216
+ /** The attachments a prompt shown before the task exists lists: uploads still staged, the user's files in place. */
217
+ export async function previewAttachments(rt, input) {
218
+ if (input.uploads?.length)
219
+ return stagedAttachments(rt.paths, input.uploads);
220
+ if (input.files?.length)
221
+ return checkAttachmentFiles(input.files);
222
+ return [];
223
+ }
224
+ async function takeAttachments(rt, input, taskId) {
225
+ if (input.uploads?.length)
226
+ return takeStaged(rt.paths, input.uploads, taskId);
227
+ if (input.files?.length)
228
+ return copyAttachments(rt.paths, input.files, taskId);
229
+ return [];
230
+ }
231
+ export function promptForTask(rt, input, attachments = []) {
232
+ if (input.task && !AI_TASK_KINDS.includes(input.task))
233
+ throw new UserError(`Choose a task: ${AI_TASK_KINDS.join(', ')}; rerun \`sideby ask\`.`);
234
+ let removedSecrets = false;
235
+ const prompt = taskPrompt({
236
+ ...input,
237
+ task: input.task ?? 'free',
238
+ attachments: attachments.map(({ path, kind }) => ({ path, kind })),
239
+ lang: input.lang ?? (/^zh/i.test(rt.env.LC_ALL || rt.env.LC_MESSAGES || rt.env.LANG || '') ? 'zh' : 'en'),
240
+ }, (text) => {
241
+ const cleaned = stripSecrets(text);
242
+ removedSecrets ||= cleaned.removed;
243
+ return cleaned;
244
+ });
245
+ return { prompt, removedSecrets };
246
+ }
247
+ const PICK_DETAIL = {
248
+ 'no-accounts': 'No account yet: create one with `sideby new <family> --next`, or paste the prompt into any AI.',
249
+ 'none-signed-in': 'No account is signed in: run `sideby list`, then `sideby login <account>`; or paste the prompt into any AI.',
250
+ 'all-full': 'Every account has used its quota: wait for a reset (`sideby quota`), or paste the prompt into any AI.',
251
+ };
252
+ async function openTask(rt, task, terminal, launcher = askLauncher(), taskId = newTaskId()) {
253
+ const choices = await terminals(rt);
254
+ const id = terminal ?? choices.guess.id;
255
+ if (!TERMINALS.some((t) => t.id === id))
256
+ throw new UserError(`Unknown terminal; choose one of ${TERMINALS.map((t) => t.id).join(', ')}, or run \`sideby terminal\` to see which are installed.`);
257
+ await writeTask(task, rt.paths, taskId);
258
+ const problem = launcherProblem(launcher, rt.paths.stateDir);
259
+ // An unsafe path cannot go into the command; the user pastes it where sideby is on PATH.
260
+ const command = askRunCommand(taskId, rt.paths.stateDir, problem?.reason === 'unsafe-path' ? null : launcher);
261
+ const deps = depsFor(rt);
262
+ const result = problem ??
263
+ (await openTerminal(id, {
264
+ cwd: task.cwd,
265
+ command,
266
+ custom: rt.config.terminal?.custom,
267
+ installed: choices.installed,
268
+ platform: deps.platform,
269
+ }, deps.exec));
270
+ return {
271
+ schemaVersion: 1,
272
+ task: taskId,
273
+ account: task.account,
274
+ terminal: id,
275
+ opened: result.ok,
276
+ command,
277
+ ...(!result.ok
278
+ ? {
279
+ reason: result.reason,
280
+ detail: 'detail' in result
281
+ ? result.detail
282
+ : 'This system has no supported terminal launcher; paste the task command into a terminal.',
283
+ }
284
+ : {}),
285
+ };
286
+ }
287
+ export async function startAiTask(rt, input) {
288
+ // Checks the task before any attachment is moved or copied.
289
+ promptForTask(rt, input);
290
+ const taskId = newTaskId();
291
+ const attachments = await takeAttachments(rt, input, taskId);
292
+ const { prompt, removedSecrets } = promptForTask(rt, input, attachments);
293
+ const pick = await pickTaskAccount(rt, input);
294
+ // Without an account the prompt is copied instead; the saved attachments stay where it says, until the cleanup.
295
+ if ('reason' in pick)
296
+ return {
297
+ schemaVersion: 1,
298
+ task: null,
299
+ account: null,
300
+ terminal: null,
301
+ opened: false,
302
+ command: null,
303
+ prompt,
304
+ reason: pick.reason,
305
+ detail: PICK_DETAIL[pick.reason],
306
+ removedSecrets,
307
+ attachments,
308
+ };
309
+ if (input.remember && input.account && rt.config.ask?.account !== pick.account.ref) {
310
+ const saved = await editConfigAsk(rt.paths.configFile, pick.account.ref);
311
+ rt.config.ask = saved.after;
312
+ }
313
+ // A terminal the person picked is kept as their preferred one, as `sideby terminal --set` would.
314
+ if (input.remember && input.terminal && rt.config.terminal?.preferred !== input.terminal)
315
+ await setPreferredTerminal(rt, input.terminal);
316
+ const task = {
317
+ kind: 'prompt',
318
+ account: pick.account.ref,
319
+ prompt,
320
+ cwd: await taskWorkDir(rt.paths),
321
+ };
322
+ if (attachments.length)
323
+ task.attachments = attachments.map(({ path, kind }) => ({ path, kind }));
324
+ return {
325
+ ...(await openTask(rt, task, input.terminal, undefined, taskId)),
326
+ removedSecrets,
327
+ attachments,
328
+ };
329
+ }
330
+ export async function openLoginInTerminal(rt, ref) {
331
+ const account = await rt.resolve(ref);
332
+ return openTask(rt, { kind: 'login', account: account.ref, cwd: await taskWorkDir(rt.paths) });
333
+ }
334
+ /**
335
+ * What `try` will run for an Account, computed as `sideby run` would (Family defaults, config arguments) but without
336
+ * reading its Secret File or running `launch.before` hooks: nothing runs before the user agrees.
337
+ */
338
+ export async function tryPreview(rt, ref) {
339
+ const account = await rt.resolve(ref);
340
+ const family = rt.families.get(account.family);
341
+ if (!family?.probe)
342
+ throw new UserError(`This tool cannot be tried; use \`sideby run ${account.ref}\` instead.`, {
343
+ code: 'no-probe',
344
+ });
345
+ const { bin, args } = await rt.launchCommand(account.ref, family.probe.args, 'run');
346
+ return { account, family, command: [bin, ...args] };
347
+ }
348
+ /**
349
+ * `sideby try` and the Panel (spec §3.28): runs the Family's probe once the way Launch starts the Host, in a temp
350
+ * folder under the task directory that is removed afterwards. The caller has already said it spends a few tokens. The
351
+ * launch is prepared once here, so `launch.before` hooks run once, and the command returned is what actually ran.
352
+ * `spawn` replaces the process start (tests); by default the Host runs with stdin ignored and is killed at the timeout.
353
+ */
354
+ export async function tryAccount(rt, ref, spawn = spawnProbe) {
355
+ const { account, family } = await tryPreview(rt, ref);
356
+ const launch = await rt.prepareLaunch(account.ref, family.probe.args, 'run');
357
+ const cwd = await mkdtemp(join(await taskWorkDir(rt.paths), 'probe-'));
358
+ try {
359
+ const result = await runProbe(account, { family, baseEnv: rt.env, cwd, launch, spawn });
360
+ return { account: account.ref, command: [launch.bin, ...launch.args], ...result };
361
+ }
362
+ finally {
363
+ await rm(cwd, { recursive: true, force: true });
364
+ }
365
+ }
@@ -0,0 +1,50 @@
1
+ import type { Paths } from './paths.ts';
2
+ export type AttachmentKind = 'image' | 'video';
3
+ export interface Attachment {
4
+ /** Absolute path of the saved file (`--print`: of the user's own file). */
5
+ path: string;
6
+ kind: AttachmentKind;
7
+ bytes: number;
8
+ }
9
+ interface FileType {
10
+ ext: 'png' | 'jpg' | 'gif' | 'webp' | 'mp4' | 'mov' | 'webm';
11
+ kind: AttachmentKind;
12
+ mime: string;
13
+ }
14
+ export declare const ATTACHMENT_LIMITS: Record<AttachmentKind, number>;
15
+ /** The largest upload the Panel accepts; the per-kind limit is checked once the type is known. */
16
+ export declare const MAX_UPLOAD_BYTES: number;
17
+ export declare const MAX_ATTACHMENTS = 5;
18
+ /** What the staging folder holds at most, whatever the browser does: uploads beyond it are refused. */
19
+ export declare const MAX_STAGED_FILES = 20;
20
+ export declare const MAX_STAGED_BYTES: number;
21
+ export declare const SUPPORTED_TYPES = "a PNG, JPEG, GIF or WebP image, or an MP4, MOV or WebM video";
22
+ /** The type of a file from its first bytes, or null when it is none sideby accepts. */
23
+ export declare function sniffType(head: Uint8Array): FileType | null;
24
+ /** A directory the user owns, mode 700; a link or someone else's directory is refused. */
25
+ export declare function privateDir(path: string): Promise<string>;
26
+ /**
27
+ * `sideby ask --print --attach`: checks the user's files as an upload would be checked and returns them where they
28
+ * are, since `--print` writes nothing.
29
+ */
30
+ export declare function checkAttachmentFiles(files: readonly string[]): Promise<Attachment[]>;
31
+ /** `sideby ask --attach`: copies the user's files into the task's attachment folder as `1.png`, `2.mp4`, … */
32
+ export declare function copyAttachments(paths: Pick<Paths, 'stateDir'>, files: readonly string[], taskId: string): Promise<Attachment[]>;
33
+ export declare function stageUpload(paths: Pick<Paths, 'stateDir'>, source: AsyncIterable<Uint8Array>, now?: number): Promise<{
34
+ id: string;
35
+ kind: AttachmentKind;
36
+ bytes: number;
37
+ mime: string;
38
+ }>;
39
+ /** The staged uploads where they are, for a prompt shown before the task exists. */
40
+ export declare function stagedAttachments(paths: Pick<Paths, 'stateDir'>, ids: readonly string[]): Promise<Attachment[]>;
41
+ /** Deletes staged uploads the person removed before sending; an id that is already gone is skipped. */
42
+ export declare function removeStaged(paths: Pick<Paths, 'stateDir'>, ids: readonly string[]): Promise<number>;
43
+ /** Moves staged uploads into the task's attachment folder, in the order given. */
44
+ export declare function takeStaged(paths: Pick<Paths, 'stateDir'>, ids: readonly string[], taskId: string): Promise<Attachment[]>;
45
+ /**
46
+ * Drops task attachment folders older than 7 days and staged uploads older than a day; leaves anything else, and
47
+ * does nothing when a folder on the way is not the user's own real folder.
48
+ */
49
+ export declare function removeOldAttachments(paths: Pick<Paths, 'stateDir'>, now?: number): Promise<void>;
50
+ export {};