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.
Files changed (77) 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 +460 -5
  5. package/dist/src/cli/json-schemas.js +115 -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 +17 -0
  18. package/dist/src/core/local-build.js +39 -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 +17 -0
  28. package/dist/src/core/update.js +63 -1
  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 +396 -9
  42. package/dist/src/panel/i18n.d.ts +175 -2
  43. package/dist/src/panel/i18n.js +381 -35
  44. package/dist/src/panel/page-logic.d.ts +214 -0
  45. package/dist/src/panel/page-logic.js +394 -0
  46. package/dist/src/panel/page-script.js +984 -134
  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/local.json +4 -0
  68. package/schemas/plugins-check.json +113 -0
  69. package/schemas/plugins-new.json +87 -0
  70. package/schemas/terminal.json +152 -0
  71. package/schemas/try.json +109 -0
  72. package/schemas/update.json +4 -0
  73. package/skills/sideby/SKILL.md +12 -6
  74. package/skills/sideby/references/accounts.md +1 -1
  75. package/skills/sideby/references/ai-tasks.md +116 -0
  76. package/skills/sideby/references/panel.md +14 -0
  77. package/skills/sideby/references/update.md +1 -1
@@ -0,0 +1,544 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ // `sideby plugins check` (spec §3.28 "加一个新宿主"): deterministic checks a Family Plugin must pass before anyone else
10
+ // runs it. A plugin the loader would not trust is only read, never run. A trusted one is loaded with the real loader
11
+ // in a child Node process whose HOME and XDG folders point at a throwaway HOME. That keeps the check's own writes out
12
+ // of the user's Accounts; it is not a sandbox: the plugin runs with the user's full permissions, as it would in sideby.
13
+ import { spawn } from 'node:child_process';
14
+ import { createWriteStream } from 'node:fs';
15
+ import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
16
+ import { basename, isAbsolute, join, relative } from 'node:path';
17
+ import { pathToFileURL } from 'node:url';
18
+ import { fakeHost, withFakeHome } from '../../testing/index.js';
19
+ import { SHARE_MODES } from '../core/share-mode-names.js';
20
+ import { BUILTIN_PLUGINS } from '../families/index.js';
21
+ import { createRuntime } from '../runtime.js';
22
+ import { checkTrustedTree, logoProblem } from './loader.js';
23
+ import { FAMILY_ID_RE, TODO_MARK } from './scaffold.js';
24
+ /** Name of the Account the check creates next to the Main Account in its throwaway HOME. */
25
+ const CHECK_ACCOUNT = 'check';
26
+ const SKIP_DIRS = new Set(['node_modules', '.git']);
27
+ /** How long the child process may take to load and check a plugin before its process group is killed. */
28
+ const CHECK_TIMEOUT_MS = 20_000;
29
+ /** The most the child may report; more is treated as a broken check. */
30
+ const MAX_REPORT_BYTES = 64 * 1024;
31
+ const MAX_SCAN_BYTES = 1_000_000;
32
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
33
+ /**
34
+ * Checks the Family Plugin in `dir` (the folder holding `plugin.json`): manifest, trust (owner, modes), that it
35
+ * only `import type`s from sideby, and a leak scan for home paths, personal emails and token-shaped strings. When the
36
+ * loader would trust it, also its FamilyDef fields and a discover / launch / doctor round, run by {@link checkRound} in
37
+ * a child process with a throwaway HOME, killed after `timeoutMs` (20 s). An untrusted plugin is never copied,
38
+ * changed or run. Never throws for a broken plugin: every problem is in `problems`, each with a `fix`.
39
+ */
40
+ export async function checkPlugin(opts) {
41
+ const { dir } = opts;
42
+ const problems = [];
43
+ const done = (plugin, families = []) => ({
44
+ ok: problems.length === 0,
45
+ ...(plugin ? { plugin } : {}),
46
+ families,
47
+ problems,
48
+ });
49
+ try {
50
+ if (!(await stat(dir)).isDirectory())
51
+ throw new Error('not a directory');
52
+ }
53
+ catch {
54
+ problems.push({
55
+ code: 'plugin.missing',
56
+ message: `${dir} is not a plugin directory`,
57
+ fix: 'Create it with `sideby plugins new <id>`, or pass the folder that holds plugin.json.',
58
+ });
59
+ return done();
60
+ }
61
+ let manifest;
62
+ try {
63
+ manifest = JSON.parse(await readFile(join(dir, 'plugin.json'), 'utf8'));
64
+ if (typeof manifest?.name !== 'string' || !manifest.name)
65
+ throw new Error('no name');
66
+ }
67
+ catch {
68
+ problems.push({
69
+ code: 'manifest.invalid',
70
+ message: `${join(dir, 'plugin.json')} is missing, not JSON, or has no "name"`,
71
+ fix: 'Write plugin.json as {"name": "<id>", "version": "0.1.0", "main": "index.ts"}.',
72
+ });
73
+ return done();
74
+ }
75
+ const trust = await checkTrustedTree(dir);
76
+ problems.push(...(await scanSources(dir)));
77
+ if (trust) {
78
+ // The loader would refuse it, so the check does not run it either: only the checks above, which read files.
79
+ problems.push({
80
+ code: 'trust',
81
+ message: `the loader would refuse this plugin, so it was not run: ${trust}`,
82
+ fix: `Own every file yourself and keep them private (\`chmod -R go-w "${dir}"\`), then check it again to run the rest.`,
83
+ });
84
+ return done(manifest.name);
85
+ }
86
+ const report = await withFakeHome((h) => runRound({ dir, main: manifest.main ?? 'index.ts', home: h.home, bin: h.bin, env: h.env }, opts.timeoutMs));
87
+ problems.push(...report.problems);
88
+ return done(manifest.name, report.families);
89
+ }
90
+ /**
91
+ * Runs {@link checkRound} in a child Node process with `job.env` (the throwaway HOME) as its whole environment, in
92
+ * its own process group so a timeout kills whatever the plugin started. The report comes back on fd 3, capped at
93
+ * {@link MAX_REPORT_BYTES}; the plugin's own stdout and stderr are discarded.
94
+ */
95
+ function runRound(job, timeoutMs = CHECK_TIMEOUT_MS) {
96
+ const self = import.meta.url;
97
+ const code = `const m = await import(${JSON.stringify(self)}); await m.checkRoundMain(process.argv[1])`;
98
+ return new Promise((resolve) => {
99
+ const failed = (code, message, fix) => ({
100
+ families: [],
101
+ problems: [{ code, message, fix }],
102
+ });
103
+ let child;
104
+ try {
105
+ child = spawn(process.execPath, ['--input-type=module', '-e', code, JSON.stringify(job)], {
106
+ env: job.env,
107
+ cwd: job.home,
108
+ detached: true,
109
+ stdio: ['ignore', 'pipe', 'pipe', 'pipe'],
110
+ });
111
+ }
112
+ catch (err) {
113
+ resolve(failed('check.crashed', `could not start the check: ${err.message}`, 'Check that Node can run from this folder, then check the plugin again.'));
114
+ return;
115
+ }
116
+ const chunks = [];
117
+ let size = 0;
118
+ let outcome = null;
119
+ const killGroup = () => {
120
+ try {
121
+ if (child.pid)
122
+ process.kill(-child.pid, 'SIGKILL');
123
+ }
124
+ catch {
125
+ // already gone
126
+ }
127
+ };
128
+ const finish = (r) => {
129
+ outcome ??= r;
130
+ clearTimeout(timer);
131
+ killGroup();
132
+ };
133
+ const timer = setTimeout(() => finish(failed('check.timeout', `loading and checking the plugin did not finish within ${Math.round(timeoutMs / 1000)} s, so it was stopped`, 'Keep register() and the readers free of loops and waits at load time (do slow work only when a reader is called), then check it again.')), timeoutMs);
134
+ child.stdout.resume();
135
+ child.stderr.resume();
136
+ const report = child.stdio[3];
137
+ report.on('data', (d) => {
138
+ size += d.length;
139
+ if (size > MAX_REPORT_BYTES)
140
+ return finish(failed('check.output', `the check reported more than ${MAX_REPORT_BYTES / 1024} KiB`, 'Keep error messages short: a reader should throw one sentence, not a whole file. Then check it again.'));
141
+ chunks.push(d);
142
+ });
143
+ child.on('error', (err) => finish(failed('check.crashed', `could not start the check: ${err.message}`, 'Check that Node can run from this folder, then check the plugin again.')));
144
+ child.on('close', (exit, signal) => {
145
+ if (outcome)
146
+ return resolve(outcome);
147
+ let parsed = null;
148
+ try {
149
+ const data = JSON.parse(Buffer.concat(chunks).toString('utf8'));
150
+ if (Array.isArray(data.families) && Array.isArray(data.problems))
151
+ parsed = data;
152
+ }
153
+ catch {
154
+ // no report: the process ended before it wrote one
155
+ }
156
+ finish(parsed ??
157
+ failed('check.crashed', `the plugin ended the check (${signal ?? `exit code ${exit}`}) before it finished`, 'Do not call process.exit or end the process in the plugin; run its tests (`node --test` in its folder) to find where, then check it again.'));
158
+ resolve(outcome);
159
+ });
160
+ });
161
+ }
162
+ /**
163
+ * The child side of {@link runRound}: reads the job, runs {@link checkRound} and writes its report to fd 3, then
164
+ * exits so a timer or handle the plugin left open cannot keep it alive.
165
+ */
166
+ export async function checkRoundMain(arg) {
167
+ const job = JSON.parse(arg ?? '{}');
168
+ const report = await checkRound(job);
169
+ const out = createWriteStream('', { fd: 3 });
170
+ out.end(JSON.stringify(report), () => process.exit(0));
171
+ }
172
+ /** The FakeHome helpers for a throwaway HOME made by the parent process. */
173
+ function fakeHomeAt(home, bin, env) {
174
+ return {
175
+ home,
176
+ bin,
177
+ env,
178
+ path: (...parts) => join(home, ...parts),
179
+ async write(rel, data, mode = 0o644) {
180
+ const p = join(home, rel);
181
+ await mkdir(join(p, '..'), { recursive: true });
182
+ await writeFile(p, data, { mode });
183
+ await chmod(p, mode);
184
+ return p;
185
+ },
186
+ async mkdir(rel) {
187
+ const p = join(home, rel);
188
+ await mkdir(p, { recursive: true });
189
+ return p;
190
+ },
191
+ };
192
+ }
193
+ /** Loads the trusted plugin in `job.dir` into a runtime on the throwaway HOME, then its field checks and rounds. */
194
+ async function checkRound(job) {
195
+ const problems = [];
196
+ const h = fakeHomeAt(job.home, job.bin, job.env);
197
+ await h.write('.config/sideby/config.json', JSON.stringify({ pluginDirs: [job.dir] }));
198
+ const loaded = await loadedFamilies(h, BUILTIN_PLUGINS, job.dir, (s) => s, problems);
199
+ if (!loaded)
200
+ return { families: [], problems };
201
+ let mod;
202
+ try {
203
+ mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(join(job.dir, job.main)).href)));
204
+ }
205
+ catch {
206
+ return { families: loaded, problems };
207
+ }
208
+ if (mod.default)
209
+ await checkDefs(mod.default, problems);
210
+ for (const id of loaded)
211
+ await round(h, BUILTIN_PLUGINS, id, problems);
212
+ return { families: loaded, problems };
213
+ }
214
+ /**
215
+ * `checkPlugin` for a Plugin that ships inside sideby: `dir` is its source folder, scanned for leaks. The same field
216
+ * checks and discover / launch / doctor round run; it loads as the only built-in.
217
+ */
218
+ export async function checkBuiltinPlugin(opts) {
219
+ const problems = [...(await scanSources(opts.dir))];
220
+ const builtins = [
221
+ { plugin: opts.plugin, version: '0.0.0-check', description: 'check', defaultEnabled: true },
222
+ ];
223
+ const families = await withFakeHome(async (h) => {
224
+ const loaded = await loadedFamilies(h, builtins, 'builtin', (s) => s, problems);
225
+ if (!loaded)
226
+ return [];
227
+ await checkDefs(opts.plugin, problems);
228
+ for (const id of loaded)
229
+ await round(h, builtins, id, problems);
230
+ return loaded;
231
+ });
232
+ return { ok: problems.length === 0, plugin: opts.plugin.name, families, problems };
233
+ }
234
+ /** Loads the plugins into a runtime on the fake HOME; the Family ids `source` registered, or null when it failed. */
235
+ async function loadedFamilies(h, builtins, source, said, problems) {
236
+ const rt = await createRuntime({ env: h.env, builtins });
237
+ const mine = rt.plugins.find((p) => p.source === source);
238
+ // A dropped logo is reported by defProblems as `logo.invalid`, with its own fix.
239
+ const errors = rt.pluginErrors.filter((e) => (e.where === source || e.where === mine?.name || !mine) &&
240
+ !e.message.endsWith('showing its initials instead'));
241
+ for (const e of errors)
242
+ problems.push({
243
+ code: 'load.failed',
244
+ message: said(e.message),
245
+ fix: 'Fix what the message names in the plugin, then check it again.',
246
+ });
247
+ if (!mine) {
248
+ if (errors.length === 0)
249
+ problems.push({
250
+ code: 'load.failed',
251
+ message: 'sideby did not load the plugin',
252
+ fix: 'Export `{ name, register(api) }` as the default export, with the name plugin.json has.',
253
+ });
254
+ return null;
255
+ }
256
+ if (mine.families.length === 0)
257
+ problems.push({
258
+ code: 'family.none',
259
+ message: `plugin ${mine.name} registers no Family`,
260
+ fix: "Call `api.family(def)` in register() with the Host's FamilyDef.",
261
+ });
262
+ return mine.families;
263
+ }
264
+ /** Calls the Plugin's register() with a recording `api`, to see each FamilyDef as written, before the loader cleans it. */
265
+ async function checkDefs(plugin, problems) {
266
+ const defs = [];
267
+ const api = {
268
+ family: (def) => void defs.push(def),
269
+ on: () => { },
270
+ abort: (message) => new Error(message),
271
+ fs: { writeFileAtomic: async () => { } },
272
+ };
273
+ try {
274
+ await plugin.register(api);
275
+ }
276
+ catch {
277
+ // The loader already reported it.
278
+ return;
279
+ }
280
+ for (const def of defs)
281
+ problems.push(...defProblems(def));
282
+ }
283
+ const isRelativeInside = (p) => !isAbsolute(p) && !p.split(/[\\/]/).includes('..');
284
+ /** What is missing or wrong in one FamilyDef, as written by the Plugin. */
285
+ export function defProblems(raw) {
286
+ const out = [];
287
+ const def = (typeof raw === 'object' && raw !== null ? raw : {});
288
+ const id = typeof def.id === 'string' ? def.id : '?';
289
+ const at = `family ${id}`;
290
+ const field = (name, message, fix) => out.push({ code: `field.${name}`, message: `${at}: ${message}`, fix });
291
+ const nonEmpty = (v) => typeof v === 'string' && v.trim() !== '';
292
+ if (typeof def.id !== 'string' || !FAMILY_ID_RE.test(def.id))
293
+ field('id', 'id must be lowercase letters, digits or "-", starting with a letter', 'Set `id` to a short name such as "mytool".');
294
+ if (!nonEmpty(def.title))
295
+ field('title', 'title is missing', "Set `title` to the Host's name as its docs write it.");
296
+ if (!nonEmpty(def.bin) || /[\\/\s]/.test(def.bin))
297
+ field('bin', 'bin must be the executable name, without a folder', 'Set `bin` to the command the Host installs on PATH.');
298
+ if (!nonEmpty(def.installUrl) || !/^https?:\/\/\S+$/.test(def.installUrl))
299
+ field('installUrl', 'installUrl must be an http(s) link', "Set `installUrl` to the Host's official install page.");
300
+ if (!nonEmpty(def.selectVar))
301
+ out.push({
302
+ code: 'select-var.missing',
303
+ message: `${at}: selectVar is missing, so every Account would share one config directory`,
304
+ fix: "Set `selectVar` to the environment variable that moves the Host's config directory (its docs or --help).",
305
+ });
306
+ else if (!ENV_NAME_RE.test(def.selectVar))
307
+ field('selectVar', 'selectVar is not an environment variable name', 'Use letters, digits and "_" only, such as MYTOOL_HOME.');
308
+ const layout = (typeof def.layout === 'object' && def.layout !== null ? def.layout : {});
309
+ const main = layout.main;
310
+ const account = layout.account;
311
+ if (!nonEmpty(main) || !isRelativeInside(main))
312
+ field('layout', 'layout.main must be a path relative to HOME, without ".."', 'Set `layout.main` such as ".mytool".');
313
+ if (!nonEmpty(account) ||
314
+ !isRelativeInside(account) ||
315
+ !account.split('/')[0].includes('<name>'))
316
+ field('layout', 'layout.account must be relative to HOME, without "..", with <name> in its first segment', 'Set `layout.account` such as ".mytool-<name>".');
317
+ for (const key of ['hijackVars', 'apiVars']) {
318
+ const v = def[key];
319
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string' || !ENV_NAME_RE.test(x)))
320
+ field(key, `${key} must be a list of environment variable names`, `Set \`${key}\` to a list such as ["MYTOOL_API_KEY"], or [] when there are none.`);
321
+ }
322
+ if (!Array.isArray(def.sharedItems))
323
+ field('sharedItems', 'sharedItems must be a list', 'Set `sharedItems` to the files the Accounts share, or [] when there are none.');
324
+ else
325
+ for (const item of def.sharedItems) {
326
+ const it = (typeof item === 'object' && item !== null ? item : {});
327
+ const path = typeof it.path === 'string' ? it.path : '';
328
+ if (!path) {
329
+ field('sharedItems', 'a Shared Item has no path', 'Give every Shared Item a `path` relative to the Account directory.');
330
+ continue;
331
+ }
332
+ if (isAbsolute(path))
333
+ out.push({
334
+ code: 'shared-item.absolute',
335
+ message: `${at}: Shared Item ${path} is an absolute path`,
336
+ fix: `Write it relative to the Account directory, such as "${basename(path)}".`,
337
+ });
338
+ else if (path.split(/[\\/]/).includes('..'))
339
+ out.push({
340
+ code: 'shared-item.parent',
341
+ message: `${at}: Shared Item ${path} leaves the Account directory with ".."`,
342
+ fix: 'Keep the path inside the Account directory; a file elsewhere in HOME goes in `mainPath`.',
343
+ });
344
+ if (!SHARE_MODES.includes(it.mode))
345
+ field('sharedItems', `Shared Item ${path} has no valid mode`, `Set \`mode\` to one of ${SHARE_MODES.join(', ')}.`);
346
+ if (it.mode === 'json-key' && !nonEmpty(it.key))
347
+ field('sharedItems', `Shared Item ${path} is json-key without a key`, 'Set `key` to the top-level JSON key the Accounts share.');
348
+ if (it.mainPath !== undefined && (typeof it.mainPath !== 'string' || !isRelativeInside(it.mainPath)))
349
+ out.push({
350
+ code: 'shared-item.parent',
351
+ message: `${at}: Shared Item ${path} has a mainPath outside HOME`,
352
+ fix: 'Write `mainPath` relative to HOME, without "..".',
353
+ });
354
+ }
355
+ const login = (typeof def.login === 'object' && def.login !== null ? def.login : null);
356
+ if (!login ||
357
+ !nonEmpty(login.hint) ||
358
+ !(login.args === null || (Array.isArray(login.args) && login.args.every((a) => typeof a === 'string'))))
359
+ field('login', 'login must be { args: string[] | null, hint }', "Set `login.args` to the Host's sign-in arguments (null when it signs in inside the app) and `login.hint` to one sentence.");
360
+ if (def.logo !== undefined) {
361
+ const problem = logoProblem(def.logo);
362
+ if (problem)
363
+ out.push({
364
+ code: 'logo.invalid',
365
+ message: `${at}: ${problem}`,
366
+ fix: 'Use one 24x24 SVG path from an icon set whose license allows it, or leave `logo` out.',
367
+ });
368
+ }
369
+ return out;
370
+ }
371
+ /** A discover / launch env / doctor round for one Family in the fake HOME: a Main Account and one other Account. */
372
+ async function round(h, builtins, id, problems) {
373
+ const rt = await createRuntime({ env: h.env, builtins });
374
+ const def = rt.families.get(id);
375
+ if (!def)
376
+ return;
377
+ const step = async (code, what, fn) => {
378
+ try {
379
+ await fn();
380
+ return true;
381
+ }
382
+ catch (err) {
383
+ problems.push({
384
+ code,
385
+ message: `family ${id}: ${what} failed: ${err?.message ?? String(err)}`,
386
+ fix: 'Make the reader handle a missing or empty file by returning undefined instead of throwing.',
387
+ });
388
+ return false;
389
+ }
390
+ };
391
+ const layoutOk = defProblems(def).every((p) => p.code !== 'field.layout' && p.code !== 'field.bin');
392
+ if (!layoutOk)
393
+ return;
394
+ await fakeHost(h, def.bin);
395
+ await h.mkdir(def.layout.main);
396
+ for (const item of def.sharedItems)
397
+ await seed(h, def, item.path, item);
398
+ const accountDir = def.layout.account.replace('<name>', CHECK_ACCOUNT);
399
+ await h.mkdir(accountDir);
400
+ let refs = [];
401
+ const found = await step('round.discover', 'finding Accounts', async () => {
402
+ const all = await rt.accounts();
403
+ refs = all.filter((a) => a.family === id).map((a) => a.ref);
404
+ for (const a of all.filter((x) => x.family === id)) {
405
+ const s = await rt.status(a);
406
+ if (s.problems?.length)
407
+ throw new Error(s.problems.join('; '));
408
+ }
409
+ });
410
+ const ref = `${id}:${CHECK_ACCOUNT}`;
411
+ if (found && (!refs.includes(`${id}:main`) || !refs.includes(ref)))
412
+ problems.push({
413
+ code: 'round.discover',
414
+ message: `family ${id}: sideby found ${refs.join(', ') || 'no Accounts'} in ~/${def.layout.main} and ~/${accountDir}, not both`,
415
+ fix: 'Check `layout` and `markers`: an Account directory counts when it holds proxy.env, a marker, or nothing at all.',
416
+ });
417
+ if (refs.includes(ref))
418
+ await step('round.launch', 'preparing a launch', async () => {
419
+ const launch = await rt.prepareLaunch(ref, [], 'run');
420
+ if (launch.env[def.selectVar] !== h.path(accountDir))
421
+ throw new Error(`${def.selectVar} does not point at the Account directory`);
422
+ });
423
+ await step('round.doctor', 'doctor', async () => {
424
+ await rt.doctor({ target: id, persist: false });
425
+ });
426
+ }
427
+ /** One source per Shared Item in the Main Account, so doctor has something to compare. */
428
+ async function seed(h, def, path, item) {
429
+ if (!isRelativeInside(path) || (item.mainPath !== undefined && !isRelativeInside(item.mainPath)))
430
+ return;
431
+ const rel = item.mainPath ?? join(def.layout.main, path);
432
+ const mode = item.credential ? 0o600 : 0o644;
433
+ if (item.mode === 'json-key')
434
+ await h.write(rel, `${JSON.stringify({ [item.key ?? 'x']: {} })}\n`, mode);
435
+ // A name with an extension is a file; a bare name (skills, hooks) is a folder.
436
+ else if (/\.[A-Za-z0-9]+$/.test(path))
437
+ await h.write(rel, path.endsWith('.json') ? '{}\n' : '\n', mode);
438
+ else
439
+ await h.mkdir(rel);
440
+ }
441
+ /** Every text file under `dir` with its path relative to `dir`, skipping dependencies and VCS data. */
442
+ async function sourceFiles(dir) {
443
+ const out = [];
444
+ async function walk(d) {
445
+ let entries;
446
+ try {
447
+ entries = await readdir(d, { withFileTypes: true });
448
+ }
449
+ catch {
450
+ return;
451
+ }
452
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
453
+ if (SKIP_DIRS.has(e.name))
454
+ continue;
455
+ const p = join(d, e.name);
456
+ if (e.isDirectory())
457
+ await walk(p);
458
+ else if (e.isFile()) {
459
+ const buf = await readFile(p).catch(() => null);
460
+ if (!buf || buf.length > MAX_SCAN_BYTES || buf.includes(0))
461
+ continue;
462
+ out.push({ rel: relative(dir, p), text: buf.toString('utf8') });
463
+ }
464
+ }
465
+ }
466
+ await walk(dir);
467
+ return out;
468
+ }
469
+ const isTest = (rel) => /\.test\.[cm]?[jt]s$/.test(rel);
470
+ const isCode = (rel) => /\.[cm]?[jt]sx?$/.test(rel);
471
+ // Built from pieces so this file itself does not look like a home path to scripts/leak-check.sh.
472
+ const HOME_PATH_RE = new RegExp(`/${'Users'}/[A-Za-z0-9._-]+/|/home/[a-z][a-z0-9_-]+/|[A-Za-z]:\\\\Users\\\\[^\\\\]+`);
473
+ const B = '(?:^|[^A-Za-z0-9_-])';
474
+ /** The token shapes scripts/leak-check.sh rejects. */
475
+ const TOKEN_RE = new RegExp([
476
+ `${B}sk-[A-Za-z0-9_-]{20,}`,
477
+ 'xai-[A-Za-z0-9]{20,}',
478
+ 'gh[pousr]_[A-Za-z0-9]{36}',
479
+ 'github_pat_[A-Za-z0-9_]{60,}',
480
+ 'npm_[A-Za-z0-9]{36}',
481
+ 'AKIA[0-9A-Z]{16}',
482
+ 'AIza[0-9A-Za-z_-]{35}',
483
+ 'xox[abp]-[A-Za-z0-9-]{10,}',
484
+ '-{5}BEGIN [A-Z ]*PRIVATE KEY-{5}',
485
+ 'hooks\\.slack\\.com/services/[A-Za-z0-9/]+',
486
+ 'open\\.feishu\\.cn/open-apis/bot/v2/hook/[a-f0-9-]{20,}',
487
+ ].join('|'));
488
+ const EMAIL_RE = /([A-Za-z0-9._%+-]+)@((?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})/g;
489
+ /** Domains reserved for examples (RFC 2606) and placeholder senders. */
490
+ const EXAMPLE_DOMAIN_RE = /(?:^|\.)(?:example\.(?:com|org|net)|example|invalid|test|localhost)$/i;
491
+ // `import x from 'sideby'`, `import { type X } from 'sideby/testing'`, `export { x } from 'sideby'`: anything but a
492
+ // whole-statement `import type` / `export type` keeps a runtime import of sideby, which a plugin cannot resolve.
493
+ const SIDEBY_FROM_RE = /^\s*(import|export)\s+(?!type\b)[^;]*?\bfrom\s*['"]sideby(?:\/[^'"]*)?['"]/gm;
494
+ const SIDEBY_CALL_RE = /\b(?:import|require)\s*\(\s*['"]sideby(?:\/[^'"]*)?['"]\s*\)/g;
495
+ const SIDE_EFFECT_RE = /^\s*import\s*['"]sideby(?:\/[^'"]*)?['"]/gm;
496
+ const lineOf = (text, index) => text.slice(0, index).split('\n').length;
497
+ /**
498
+ * The plugin's sources against the rules that need no loading: value imports from sideby, TODOs left from the
499
+ * skeleton, and the leak scan. Messages give the file and line only, never the matched value.
500
+ */
501
+ export async function scanSources(dir) {
502
+ const out = [];
503
+ for (const { rel, text } of await sourceFiles(dir)) {
504
+ if (isCode(rel) && !isTest(rel)) {
505
+ for (const re of [SIDEBY_FROM_RE, SIDEBY_CALL_RE, SIDE_EFFECT_RE])
506
+ for (const m of text.matchAll(re))
507
+ out.push({
508
+ code: 'import.value',
509
+ message: `${rel}:${lineOf(text, m.index)} imports a value from sideby`,
510
+ fix: "Use `import type` for sideby's types and take everything else from the `api` register() receives.",
511
+ });
512
+ const todo = text.indexOf(TODO_MARK);
513
+ if (todo !== -1)
514
+ out.push({
515
+ code: 'todo.left',
516
+ message: `${rel}:${lineOf(text, todo)} still has a ${TODO_MARK} from the skeleton`,
517
+ fix: 'Fill it in from the Host\'s docs or --help, note the source as "<tool> <version>, <date>", and delete the TODO line.',
518
+ });
519
+ }
520
+ text.split('\n').forEach((line, i) => {
521
+ const where = `${rel}:${i + 1}`;
522
+ if (HOME_PATH_RE.test(line))
523
+ out.push({
524
+ code: 'leak.home-path',
525
+ message: `${where} has a path inside someone's home folder`,
526
+ fix: 'Write it relative to HOME (`join(home, ...)`, `~/...`) or use a placeholder such as <home>.',
527
+ });
528
+ if (TOKEN_RE.test(line))
529
+ out.push({
530
+ code: 'leak.token',
531
+ message: `${where} has a string shaped like a key or token`,
532
+ fix: 'Delete it and revoke it if it was real; tests use a fake value that does not match a real key format.',
533
+ });
534
+ for (const m of line.matchAll(EMAIL_RE))
535
+ if (m[1] !== 'git' && !EXAMPLE_DOMAIN_RE.test(m[2]))
536
+ out.push({
537
+ code: 'leak.email',
538
+ message: `${where} has an email address`,
539
+ fix: 'Use a placeholder such as me@example.com.',
540
+ });
541
+ });
542
+ }
543
+ return out;
544
+ }
@@ -0,0 +1,21 @@
1
+ /** Same rule the loader applies to a Family id: it becomes part of account refs and shell function names. */
2
+ export declare const FAMILY_ID_RE: RegExp;
3
+ /** Marks each line of the skeleton the author still has to fill in; `checkPlugin` reports any that are left. */
4
+ export declare const TODO_MARK = "TODO(sideby)";
5
+ export interface ScaffoldResult {
6
+ /** The new plugin directory, `<dir>/<id>`. */
7
+ dir: string;
8
+ /** Every file written, in the order written. */
9
+ files: string[];
10
+ }
11
+ /** Why `id` cannot name a Family Plugin, or null when it can. */
12
+ export declare function pluginIdProblem(id: string): string | null;
13
+ /**
14
+ * Creates `<dir>/<id>/` with `plugin.json`, `index.ts` (an `api.family(def)` skeleton that only imports types from
15
+ * sideby) and `index.test.ts` (a starter on `sideby/testing`). The directory is mode 700 and the files 600, owned by
16
+ * the current user, so the loader's trust checks pass. Refuses a bad id or an existing directory before writing.
17
+ */
18
+ export declare function scaffoldPlugin(opts: {
19
+ id: string;
20
+ dir: string;
21
+ }): Promise<ScaffoldResult>;