sideby 0.0.0-stage → 0.1.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 (145) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +453 -2
  3. package/README.zh-CN.md +455 -0
  4. package/dist/build.json +1 -0
  5. package/dist/src/cli/args.d.ts +13 -0
  6. package/dist/src/cli/args.js +38 -0
  7. package/dist/src/cli/commands.d.ts +37 -0
  8. package/dist/src/cli/commands.js +487 -0
  9. package/dist/src/cli/format.d.ts +16 -0
  10. package/dist/src/cli/format.js +80 -0
  11. package/dist/src/cli/json-schemas.d.ts +2 -0
  12. package/dist/src/cli/json-schemas.js +224 -0
  13. package/dist/src/cli/main.d.ts +2 -0
  14. package/dist/src/cli/main.js +186 -0
  15. package/dist/src/cli/schemas.d.ts +1 -0
  16. package/dist/src/cli/schemas.js +16 -0
  17. package/dist/src/core/account-name.d.ts +2 -0
  18. package/dist/src/core/account-name.js +2 -0
  19. package/dist/src/core/accounts.d.ts +18 -0
  20. package/dist/src/core/accounts.js +116 -0
  21. package/dist/src/core/config.d.ts +60 -0
  22. package/dist/src/core/config.js +181 -0
  23. package/dist/src/core/create.d.ts +30 -0
  24. package/dist/src/core/create.js +126 -0
  25. package/dist/src/core/doctor.d.ts +41 -0
  26. package/dist/src/core/doctor.js +171 -0
  27. package/dist/src/core/errors.d.ts +12 -0
  28. package/dist/src/core/errors.js +14 -0
  29. package/dist/src/core/file-memo.d.ts +24 -0
  30. package/dist/src/core/file-memo.js +41 -0
  31. package/dist/src/core/fs-safe.d.ts +38 -0
  32. package/dist/src/core/fs-safe.js +205 -0
  33. package/dist/src/core/handoff.d.ts +41 -0
  34. package/dist/src/core/handoff.js +54 -0
  35. package/dist/src/core/identity.d.ts +23 -0
  36. package/dist/src/core/identity.js +79 -0
  37. package/dist/src/core/last-doctor.d.ts +36 -0
  38. package/dist/src/core/last-doctor.js +69 -0
  39. package/dist/src/core/launch.d.ts +35 -0
  40. package/dist/src/core/launch.js +108 -0
  41. package/dist/src/core/login-path.d.ts +23 -0
  42. package/dist/src/core/login-path.js +96 -0
  43. package/dist/src/core/paths.d.ts +13 -0
  44. package/dist/src/core/paths.js +32 -0
  45. package/dist/src/core/quota-levels.d.ts +19 -0
  46. package/dist/src/core/quota-levels.js +34 -0
  47. package/dist/src/core/secret-file.d.ts +30 -0
  48. package/dist/src/core/secret-file.js +116 -0
  49. package/dist/src/core/share-mode-names.d.ts +1 -0
  50. package/dist/src/core/share-mode-names.js +10 -0
  51. package/dist/src/core/share-modes.d.ts +24 -0
  52. package/dist/src/core/share-modes.js +405 -0
  53. package/dist/src/core/shell-init.d.ts +25 -0
  54. package/dist/src/core/shell-init.js +61 -0
  55. package/dist/src/core/skill-check.d.ts +11 -0
  56. package/dist/src/core/skill-check.js +57 -0
  57. package/dist/src/core/usage-days.d.ts +15 -0
  58. package/dist/src/core/usage-days.js +33 -0
  59. package/dist/src/core/version.d.ts +9 -0
  60. package/dist/src/core/version.js +46 -0
  61. package/dist/src/desktop/app.d.ts +60 -0
  62. package/dist/src/desktop/app.js +316 -0
  63. package/dist/src/desktop/icon.d.ts +16 -0
  64. package/dist/src/desktop/icon.js +174 -0
  65. package/dist/src/families/claude/index.d.ts +10 -0
  66. package/dist/src/families/claude/index.js +124 -0
  67. package/dist/src/families/claude/layout.d.ts +6 -0
  68. package/dist/src/families/claude/layout.js +5 -0
  69. package/dist/src/families/claude/quota-cache.d.ts +31 -0
  70. package/dist/src/families/claude/quota-cache.js +111 -0
  71. package/dist/src/families/claude/quota.d.ts +3 -0
  72. package/dist/src/families/claude/quota.js +53 -0
  73. package/dist/src/families/claude/usage.d.ts +22 -0
  74. package/dist/src/families/claude/usage.js +146 -0
  75. package/dist/src/families/codex/index.d.ts +5 -0
  76. package/dist/src/families/codex/index.js +67 -0
  77. package/dist/src/families/codex/rollout.d.ts +67 -0
  78. package/dist/src/families/codex/rollout.js +298 -0
  79. package/dist/src/families/grok/index.d.ts +8 -0
  80. package/dist/src/families/grok/index.js +78 -0
  81. package/dist/src/families/index.d.ts +3 -0
  82. package/dist/src/families/index.js +40 -0
  83. package/dist/src/families/logos.d.ts +21 -0
  84. package/dist/src/families/logos.js +26 -0
  85. package/dist/src/families/pi/index.d.ts +3 -0
  86. package/dist/src/families/pi/index.js +54 -0
  87. package/dist/src/families/shared/toml.d.ts +5 -0
  88. package/dist/src/families/shared/toml.js +16 -0
  89. package/dist/src/index.d.ts +5 -0
  90. package/dist/src/index.js +4 -0
  91. package/dist/src/panel/background.d.ts +62 -0
  92. package/dist/src/panel/background.js +274 -0
  93. package/dist/src/panel/handler.d.ts +93 -0
  94. package/dist/src/panel/handler.js +411 -0
  95. package/dist/src/panel/i18n.d.ts +287 -0
  96. package/dist/src/panel/i18n.js +570 -0
  97. package/dist/src/panel/page-logic.d.ts +154 -0
  98. package/dist/src/panel/page-logic.js +326 -0
  99. package/dist/src/panel/page-script.d.ts +2 -0
  100. package/dist/src/panel/page-script.js +1633 -0
  101. package/dist/src/panel/page-styles.d.ts +7 -0
  102. package/dist/src/panel/page-styles.js +514 -0
  103. package/dist/src/panel/page.d.ts +20 -0
  104. package/dist/src/panel/page.js +96 -0
  105. package/dist/src/panel/server.d.ts +32 -0
  106. package/dist/src/panel/server.js +133 -0
  107. package/dist/src/panel/theme.d.ts +116 -0
  108. package/dist/src/panel/theme.js +132 -0
  109. package/dist/src/plugins/account-script.d.ts +4 -0
  110. package/dist/src/plugins/account-script.js +66 -0
  111. package/dist/src/plugins/bus.d.ts +33 -0
  112. package/dist/src/plugins/bus.js +84 -0
  113. package/dist/src/plugins/loader.d.ts +58 -0
  114. package/dist/src/plugins/loader.js +250 -0
  115. package/dist/src/quota/claude-statusline.d.ts +28 -0
  116. package/dist/src/quota/claude-statusline.js +301 -0
  117. package/dist/src/quota/statusline-tap.d.ts +11 -0
  118. package/dist/src/quota/statusline-tap.js +104 -0
  119. package/dist/src/runtime.d.ts +149 -0
  120. package/dist/src/runtime.js +371 -0
  121. package/dist/src/types.d.ts +278 -0
  122. package/dist/src/types.js +3 -0
  123. package/dist/testing/demo.d.ts +8 -0
  124. package/dist/testing/demo.js +50 -0
  125. package/dist/testing/index.d.ts +36 -0
  126. package/dist/testing/index.js +117 -0
  127. package/package.json +66 -4
  128. package/schemas/alias.json +102 -0
  129. package/schemas/app-install.json +74 -0
  130. package/schemas/app-uninstall.json +54 -0
  131. package/schemas/config.json +166 -0
  132. package/schemas/doctor.json +229 -0
  133. package/schemas/error.json +25 -0
  134. package/schemas/list.json +177 -0
  135. package/schemas/new.json +181 -0
  136. package/schemas/next.json +127 -0
  137. package/schemas/plugins.json +74 -0
  138. package/schemas/quota-setup.json +60 -0
  139. package/schemas/quota-teardown.json +30 -0
  140. package/schemas/quota.json +203 -0
  141. package/skills/sideby/SKILL.md +52 -0
  142. package/skills/sideby/references/accounts.md +31 -0
  143. package/skills/sideby/references/diagnose.md +47 -0
  144. package/skills/sideby/references/handoff.md +41 -0
  145. package/skills/sideby/references/quota.md +14 -0
@@ -0,0 +1,181 @@
1
+ import { mkdir, readFile } from 'node:fs/promises';
2
+ import { dirname } from 'node:path';
3
+ import { Type } from 'typebox';
4
+ import { Value } from 'typebox/value';
5
+ import { UserError } from './errors.js';
6
+ import { lstatOrNull, parseJsonSafely, realpathOrNull, sha256, writeIfUnchanged } from './fs-safe.js';
7
+ import { SHARE_MODES } from './share-mode-names.js';
8
+ const SharedItemSchema = Type.Object({
9
+ path: Type.String({ minLength: 1, pattern: '^(?!/)(?!.*(^|/)\\.\\.(/|$)).+$' }),
10
+ // json-key needs a key and a main-side path, which the config does not offer.
11
+ mode: Type.Union(SHARE_MODES.filter((m) => m !== 'json-key').map((m) => Type.Literal(m))),
12
+ noSymlink: Type.Optional(Type.Boolean()),
13
+ credential: Type.Optional(Type.Boolean()),
14
+ }, { additionalProperties: false });
15
+ export const ALIAS_NAME = /^[A-Za-z_][A-Za-z0-9_-]*$/;
16
+ /** Shell keywords and builtins an alias must not shadow (`command() { command sideby … }` would recurse). */
17
+ export const RESERVED_ALIASES = new Set('alias bg bind break builtin case cd command compgen complete continue declare dirs disown do done echo elif else enable esac eval exec exit export false fc fg fi for function getopts hash help history if in jobs kill let local logout popd printf pushd pwd read readonly return select set shift shopt source suspend test then time times trap true type typeset ulimit umask unalias unset until wait while sideby'.split(' '));
18
+ export function aliasProblem(alias) {
19
+ if (!ALIAS_NAME.test(alias))
20
+ return `must match ${ALIAS_NAME.source}`;
21
+ if (RESERVED_ALIASES.has(alias))
22
+ return 'is a shell keyword, builtin or sideby itself';
23
+ return null;
24
+ }
25
+ /** One `aliases` value: an Account ref, or a ref plus Host arguments that only `sideby run` adds (ADR-0005). */
26
+ const AliasSchema = Type.Union([
27
+ Type.String(),
28
+ Type.Object({
29
+ account: Type.String({ pattern: '^[^:]+:.+$', description: 'The Account ref, `<family>:<name>`.' }),
30
+ args: Type.Optional(Type.Array(Type.String(), {
31
+ description: 'Host arguments `sideby run <alias>` adds after the Account `args`, before the user’s.',
32
+ })),
33
+ }, { additionalProperties: false }),
34
+ ]);
35
+ /** The Account ref an Alias starts. */
36
+ export const aliasAccount = (value) => (typeof value === 'string' ? value : value.account);
37
+ /** The Host arguments an Alias adds to `sideby run`; none for a plain ref. */
38
+ export const aliasArgs = (value) => typeof value === 'object' ? [...(value.args ?? [])] : [];
39
+ /** `aliases` as alias → Account ref, for lookups that ignore the arguments. */
40
+ export function aliasRefs(aliases) {
41
+ return Object.fromEntries(Object.entries(aliases ?? {}).map(([name, v]) => [name, aliasAccount(v)]));
42
+ }
43
+ const sameAlias = (a, b) => aliasAccount(a) === aliasAccount(b) && JSON.stringify(aliasArgs(a)) === JSON.stringify(aliasArgs(b));
44
+ const describeAlias = (v) => {
45
+ const args = aliasArgs(v);
46
+ return args.length ? `${aliasAccount(v)} with ${args.join(' ')}` : aliasAccount(v);
47
+ };
48
+ export const ConfigSchema = Type.Object({
49
+ $schema: Type.Optional(Type.String()),
50
+ accounts: Type.Optional(Type.Record(Type.String(), Type.Object({ args: Type.Optional(Type.Array(Type.String())) }, { additionalProperties: false }), { description: 'Per-Account settings keyed by `<family>:<name>`.' })),
51
+ aliases: Type.Optional(Type.Record(Type.String({ pattern: '^[A-Za-z_][A-Za-z0-9_-]*$' }), AliasSchema, {
52
+ description: 'Short shell function names created by `sideby shell-init`, mapped to an Account ref or to `{ account, args }`.',
53
+ })),
54
+ ignore: Type.Optional(Type.Array(Type.String(), { description: 'Account refs that are not Accounts.' })),
55
+ pluginDirs: Type.Optional(Type.Array(Type.String(), { description: 'Absolute or `~/` paths of Plugin directories.' })),
56
+ plugins: Type.Optional(Type.Record(Type.String(), Type.Record(Type.String(), Type.Unknown()), {
57
+ description: 'Per-Plugin settings; `enabled: false` turns a Plugin off.',
58
+ })),
59
+ extraSharedItems: Type.Optional(Type.Record(Type.String(), Type.Array(SharedItemSchema))),
60
+ shellInitFile: Type.Optional(Type.Object({
61
+ zsh: Type.Optional(Type.String({ minLength: 1 })),
62
+ bash: Type.Optional(Type.String({ minLength: 1 })),
63
+ }, {
64
+ additionalProperties: false,
65
+ description: 'Absolute or `~/` paths of files sideby rewrites with the `sideby shell-init <shell>` output whenever it adds an Account or an alias; source the file from your shell rc.',
66
+ })),
67
+ }, { additionalProperties: false, title: 'sideby config' });
68
+ export class ConfigError extends UserError {
69
+ }
70
+ export async function loadConfig(file) {
71
+ let raw;
72
+ try {
73
+ raw = await readFile(file, 'utf8');
74
+ }
75
+ catch (err) {
76
+ if (err.code === 'ENOENT')
77
+ return {};
78
+ throw new ConfigError(`cannot read ${file}: ${err.message}`);
79
+ }
80
+ let data;
81
+ try {
82
+ data = parseJsonSafely(raw, file);
83
+ }
84
+ catch {
85
+ throw new ConfigError(`${file} is not valid JSON; fix the syntax or delete the file`);
86
+ }
87
+ if (!Value.Check(ConfigSchema, data)) {
88
+ const first = [...Value.Errors(ConfigSchema, data)][0];
89
+ const where = first?.instancePath || '/';
90
+ throw new ConfigError(`${file}: ${where} ${first?.message ?? 'is invalid'}; see schemas/config.json`);
91
+ }
92
+ // TypeBox does not enforce key patterns on records; alias names become shell function names.
93
+ for (const alias of Object.keys(data.aliases ?? {})) {
94
+ const problem = aliasProblem(alias);
95
+ if (problem)
96
+ throw new ConfigError(`${file}: alias "${alias}" ${problem} (it becomes a shell function name)`);
97
+ }
98
+ return data;
99
+ }
100
+ /** Where the config file is read and written: the target of a link (a dotfiles manager may link it). */
101
+ async function configTarget(file) {
102
+ return (await lstatOrNull(file))?.isSymbolicLink() ? ((await realpathOrNull(file)) ?? file) : file;
103
+ }
104
+ /**
105
+ * Re-reads the config file, lets `edit` change its `aliases`, and writes the result: every other key and their
106
+ * order kept (with `$schema`), 2-space JSON with a trailing newline, atomically, keeping the file's mode (0600 for
107
+ * a new file). Refuses, without writing, a file that is not valid JSON or not a valid config, or one that changed
108
+ * while sideby wrote it. `edit` returns null to leave the file alone. `retry` names what to do again after a fix.
109
+ */
110
+ async function editConfigAliases(file, retry, edit) {
111
+ const target = await configTarget(file);
112
+ let raw = null;
113
+ try {
114
+ raw = await readFile(target, 'utf8');
115
+ }
116
+ catch (err) {
117
+ if (err.code !== 'ENOENT')
118
+ throw new ConfigError(`cannot read ${file}: ${err.message}`);
119
+ }
120
+ let data = {};
121
+ if (raw !== null) {
122
+ let parsed;
123
+ try {
124
+ parsed = parseJsonSafely(raw, file);
125
+ }
126
+ catch {
127
+ throw new ConfigError(`${file} is not valid JSON; nothing was changed. Fix the syntax and ${retry} again`);
128
+ }
129
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
130
+ throw new ConfigError(`${file} is not a JSON object; nothing was changed. Fix it and ${retry} again`);
131
+ if (!Value.Check(ConfigSchema, parsed))
132
+ throw new ConfigError(`${file} does not match schemas/config.json; nothing was changed. Run \`sideby list\` to see why`);
133
+ data = parsed;
134
+ }
135
+ const current = (data.aliases ?? {});
136
+ const aliases = edit(current);
137
+ if (!aliases)
138
+ return current;
139
+ // A new file gets the schema link editors use for completion; an existing file keeps its keys in order.
140
+ const next = raw === null ? { $schema: 'https://unpkg.com/sideby/schemas/config.json', aliases } : { ...data, aliases };
141
+ const st = raw === null ? null : await lstatOrNull(target);
142
+ if (raw === null)
143
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
144
+ const written = await writeIfUnchanged(target, raw === null ? null : sha256(raw), `${JSON.stringify(next, null, 2)}\n`, st ? st.mode & 0o777 : 0o600);
145
+ if (!written)
146
+ throw new ConfigError(`${file} changed while sideby was writing it; nothing was changed. Try again`);
147
+ return aliases;
148
+ }
149
+ /**
150
+ * Adds `alias → ref` to the config file's `aliases`, as `{ account, args }` when `args` is not empty (see
151
+ * `editConfigAliases` for how the file is written). Refuses an invalid alias, or one that already starts another
152
+ * Account or the same Account with other arguments. Returns the aliases now in the file.
153
+ */
154
+ export async function addConfigAlias(file, alias, ref, args = []) {
155
+ const problem = aliasProblem(alias);
156
+ if (problem)
157
+ throw new ConfigError(`alias "${alias}" ${problem} (it becomes a shell function name)`);
158
+ const value = args.length ? { account: ref, args: [...args] } : ref;
159
+ let status = 'added';
160
+ const aliases = await editConfigAliases(file, 'add the alias', (current) => {
161
+ if (!Object.hasOwn(current, alias))
162
+ return { ...current, [alias]: value };
163
+ if (!sameAlias(current[alias], value))
164
+ throw new ConfigError(`alias "${alias}" already starts ${describeAlias(current[alias])}; pick another name, or remove it first with \`sideby alias rm ${alias}\``);
165
+ status = 'exists';
166
+ return null;
167
+ });
168
+ return { status, aliases };
169
+ }
170
+ /** Removes `alias` from the config file's `aliases`; `absent` when it was not there (nothing is written then). */
171
+ export async function removeConfigAlias(file, alias) {
172
+ let status = 'absent';
173
+ const aliases = await editConfigAliases(file, 'remove the alias', (current) => {
174
+ if (!Object.hasOwn(current, alias))
175
+ return null;
176
+ status = 'removed';
177
+ const { [alias]: _gone, ...rest } = current;
178
+ return rest;
179
+ });
180
+ return { status, aliases };
181
+ }
@@ -0,0 +1,30 @@
1
+ import { type HookBus } from '../plugins/bus.ts';
2
+ import type { Account, FamilyDef } from '../types.ts';
3
+ import { UserError } from './errors.ts';
4
+ export interface CreateStep {
5
+ item: string;
6
+ action: 'linked' | 'copied' | 'written' | 'skipped';
7
+ ok: boolean;
8
+ message?: string;
9
+ }
10
+ export interface CreateResult {
11
+ account: Account;
12
+ steps: CreateStep[];
13
+ hookErrors: {
14
+ plugin: string;
15
+ message: string;
16
+ }[];
17
+ nextSteps: string[];
18
+ ok: boolean;
19
+ }
20
+ export declare class CreateError extends UserError {
21
+ }
22
+ export declare function createAccount(opts: {
23
+ family: FamilyDef;
24
+ name: string;
25
+ api: boolean;
26
+ home: string;
27
+ bus: HookBus;
28
+ /** The Family's existing Accounts, for `account.create.before`. */
29
+ accounts?: Account[];
30
+ }): Promise<CreateResult>;
@@ -0,0 +1,126 @@
1
+ import { mkdir, readFile, symlink } from 'node:fs/promises';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
+ import { HookError } from '../plugins/bus.js';
4
+ import { ACCOUNT_NAME, accountAt, accountDirOf, mainDirOf } from './accounts.js';
5
+ import { UserError } from './errors.js';
6
+ import { assertInside, lstatOrNull, parseJsonSafely, replaceWithCopy, statOrNull, writeFileAtomic, } from './fs-safe.js';
7
+ import { secretFileTemplate } from './secret-file.js';
8
+ import { itemPaths } from './share-modes.js';
9
+ export class CreateError extends UserError {
10
+ }
11
+ function actionFor(item, api) {
12
+ switch (item.mode) {
13
+ case 'info':
14
+ return 'skip';
15
+ case 'json-key':
16
+ return 'json';
17
+ case 'link':
18
+ case 'link-or-local':
19
+ return 'link';
20
+ case 'copy':
21
+ case 'local':
22
+ return 'copy';
23
+ case 'link-or-copy':
24
+ return item.noSymlink ? 'copy' : 'link';
25
+ case 'local-if-api':
26
+ // An API Account gets its own copy to edit; a subscription Account shares the main one.
27
+ return api || item.noSymlink ? 'copy' : 'link';
28
+ }
29
+ }
30
+ export async function createAccount(opts) {
31
+ const { family, name, api, home } = opts;
32
+ if (!ACCOUNT_NAME.test(name) || name === 'main')
33
+ throw new CreateError(`"${name}" is not a valid account name: use 1–32 lowercase letters, digits or "-", not "main"`);
34
+ const mainDir = mainDirOf(family, home);
35
+ if (!(await statOrNull(mainDir))?.isDirectory())
36
+ throw new CreateError(`${family.title} has no main account at ${mainDir}; install ${family.bin} (${family.installUrl}) and run it once first`);
37
+ const dir = accountDirOf(family, home, name);
38
+ if (await lstatOrNull(dir))
39
+ throw new CreateError(`${dir} already exists; pick another name or run \`sideby doctor\``);
40
+ // Every level of the layout that already exists must be a real directory (as discovery requires), so a
41
+ // symlinked `~/.pi-<name>` can never make creation write into its target.
42
+ const segments = relative(home, dir).split(sep);
43
+ for (let i = 1; i < segments.length; i++) {
44
+ const prefix = join(home, ...segments.slice(0, i));
45
+ const st = await lstatOrNull(prefix);
46
+ if (st && !st.isDirectory())
47
+ throw new CreateError(`${prefix} exists and is not a real directory (a link or file); move it aside first`);
48
+ }
49
+ // Plugins may refuse before anything is written (Cursor keeps one subscription login besides main).
50
+ try {
51
+ await opts.bus.run('account.create.before', family.id, {
52
+ family,
53
+ name,
54
+ api,
55
+ accounts: opts.accounts ?? [],
56
+ config: {},
57
+ });
58
+ }
59
+ catch (err) {
60
+ if (!(err instanceof HookError))
61
+ throw err;
62
+ if (err.aborted)
63
+ throw new CreateError(err.message, { code: 'create-refused' });
64
+ throw new CreateError(`${err.message}; nothing was created. Fix or disable plugin ${err.plugin} (see \`sideby plugins\`)`);
65
+ }
66
+ await mkdir(dir, { recursive: true, mode: 0o700 });
67
+ const steps = [];
68
+ for (const item of family.sharedItems) {
69
+ const { mainPath, accountPath } = itemPaths(home, mainDir, dir, item);
70
+ const action = actionFor(item, api);
71
+ if (action === 'skip')
72
+ continue;
73
+ if (!(await statOrNull(mainPath))) {
74
+ steps.push({ item: item.path, action: 'skipped', ok: true, message: 'not in the main account' });
75
+ continue;
76
+ }
77
+ try {
78
+ await assertInside(dir, accountPath);
79
+ await mkdir(dirname(accountPath), { recursive: true });
80
+ if (action === 'link') {
81
+ await symlink(mainPath, accountPath);
82
+ steps.push({ item: item.path, action: 'linked', ok: true });
83
+ }
84
+ else if (action === 'copy') {
85
+ await replaceWithCopy(mainPath, accountPath);
86
+ steps.push({ item: item.path, action: 'copied', ok: true });
87
+ }
88
+ else {
89
+ // Never echo parse errors: this file holds credentials.
90
+ const parsed = parseJsonSafely(await readFile(mainPath, 'utf8'), item.mainPath ?? item.path);
91
+ const value = parsed?.[item.key];
92
+ await writeFileAtomic(accountPath, `${JSON.stringify(value === undefined ? {} : { [item.key]: value }, null, 2)}\n`);
93
+ steps.push({ item: `${item.path}#${item.key}`, action: 'written', ok: true });
94
+ }
95
+ }
96
+ catch (err) {
97
+ steps.push({ item: item.path, action: 'skipped', ok: false, message: err.message });
98
+ }
99
+ }
100
+ let secretFile;
101
+ if (api) {
102
+ secretFile = join(dir, 'proxy.env');
103
+ await writeFileAtomic(secretFile, secretFileTemplate(family.apiVars, family.title), 0o600);
104
+ steps.push({ item: 'proxy.env', action: 'written', ok: true });
105
+ }
106
+ const account = await accountAt(family, home, name);
107
+ const logs = [];
108
+ const hookErrors = (await opts.bus.runIsolated('account.created', family.id, {
109
+ account,
110
+ family,
111
+ config: {},
112
+ log: (m) => logs.push(m),
113
+ }))
114
+ .filter((r) => r.error)
115
+ .map((r) => ({ plugin: r.plugin, message: r.error.message }));
116
+ const nextSteps = api
117
+ ? [`fill in ${secretFile}`, `sideby run ${account.ref}`]
118
+ : [`sideby login ${account.ref}`, `sideby run ${account.ref}`];
119
+ return {
120
+ account,
121
+ steps,
122
+ hookErrors,
123
+ nextSteps: [...nextSteps, ...logs],
124
+ ok: steps.every((s) => s.ok) && hookErrors.length === 0,
125
+ };
126
+ }
@@ -0,0 +1,41 @@
1
+ import type { HookBus } from '../plugins/bus.ts';
2
+ import type { Account, FamilyDef, Finding } from '../types.ts';
3
+ export interface AccountReport {
4
+ ref: string;
5
+ family: string;
6
+ name: string;
7
+ kind: Account['kind'];
8
+ dir: string;
9
+ shared: {
10
+ ok: number;
11
+ total: number;
12
+ };
13
+ backups: number;
14
+ findings: Finding[];
15
+ }
16
+ export interface FixOutcome {
17
+ account: string;
18
+ item: string;
19
+ code: string;
20
+ ok: boolean;
21
+ message: string;
22
+ }
23
+ export interface DoctorReport {
24
+ status: 'ok' | 'issues';
25
+ accounts: AccountReport[];
26
+ /** Findings that are not about one Account (for example a missing Main Account). */
27
+ general: Finding[];
28
+ fixes: FixOutcome[];
29
+ }
30
+ export interface DoctorInput {
31
+ home: string;
32
+ families: ReadonlyMap<string, FamilyDef>;
33
+ accounts: readonly Account[];
34
+ bus: HookBus;
35
+ force?: boolean;
36
+ /** This sideby's version: when set, installed sideby skills of the checked Families are compared with it. */
37
+ skillVersion?: string;
38
+ }
39
+ export declare function runDoctor(input: DoctorInput): Promise<DoctorReport>;
40
+ /** Applies every fixable Finding once, in order; one failing fix never stops the others. */
41
+ export declare function applyFixes(report: DoctorReport): Promise<FixOutcome[]>;
@@ -0,0 +1,171 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { looksLikeBackup, mainDirOf } from './accounts.js';
3
+ import { lstatOrNull, permBits, statOrNull } from './fs-safe.js';
4
+ import { inspectSecretFile } from './secret-file.js';
5
+ import { checkItem, credentialModeFinding, itemPaths } from './share-modes.js';
6
+ import { checkInstalledSkills } from './skill-check.js';
7
+ const BACKUP_ENTRY = /(\.bak|backup|\.tmp)/i;
8
+ const BACKUP_DIRS = new Set(['backups', 'tmp', '.tmp']);
9
+ async function countBackups(dir) {
10
+ try {
11
+ return (await readdir(dir)).filter((e) => BACKUP_ENTRY.test(e) && !BACKUP_DIRS.has(e)).length;
12
+ }
13
+ catch {
14
+ return 0;
15
+ }
16
+ }
17
+ function toFinding(r, account, source) {
18
+ return { ...r, account, source, fixable: Boolean(r.fixable && r.fix) };
19
+ }
20
+ async function checkAccount(input, family, account) {
21
+ const findings = [];
22
+ let okCount = 0;
23
+ let total = 0;
24
+ const mainDir = mainDirOf(family, input.home);
25
+ const items = [...family.sharedItems];
26
+ if (!account.isMain) {
27
+ for (const item of items) {
28
+ const { mainPath, accountPath } = itemPaths(input.home, mainDir, account.dir, item);
29
+ const res = await checkItem({
30
+ account,
31
+ item,
32
+ mainPath,
33
+ accountPath,
34
+ accountDir: account.dir,
35
+ force: Boolean(input.force),
36
+ });
37
+ if (res.counted) {
38
+ total++;
39
+ if (res.results[0]?.level === 'ok')
40
+ okCount++;
41
+ }
42
+ for (const r of res.results)
43
+ if (r.level !== 'ok')
44
+ findings.push(toFinding(r, account.ref, 'core'));
45
+ }
46
+ if (looksLikeBackup(account.name))
47
+ findings.push({
48
+ level: 'warn',
49
+ account: account.ref,
50
+ item: 'name',
51
+ code: 'name.backup-like',
52
+ message: `${account.ref} looks like a backup, not an account`,
53
+ hint: `if it is a backup, add "${account.ref}" to "ignore" in the sideby config`,
54
+ source: 'core',
55
+ fixable: false,
56
+ });
57
+ }
58
+ else {
59
+ // The Main Account is the source of sharing; only its credential files are checked.
60
+ for (const item of items.filter((i) => i.credential)) {
61
+ const { mainPath } = itemPaths(input.home, mainDir, account.dir, item);
62
+ const st = await lstatOrNull(mainPath);
63
+ if (st?.isFile() && permBits(st) !== 0o600)
64
+ findings.push(toFinding(credentialModeFinding(item.mainPath ?? item.path, mainPath, permBits(st)), account.ref, 'core'));
65
+ }
66
+ }
67
+ if (account.secretFile) {
68
+ const state = await inspectSecretFile(account.secretFile);
69
+ if (state.kind === 'not-file')
70
+ findings.push({
71
+ level: 'fail',
72
+ account: account.ref,
73
+ item: 'proxy.env',
74
+ code: 'secret.not-file',
75
+ message: 'proxy.env must be a regular file, not a link or directory',
76
+ source: 'core',
77
+ fixable: false,
78
+ });
79
+ else if (state.kind === 'mode')
80
+ findings.push(toFinding(credentialModeFinding('proxy.env', account.secretFile, state.mode), account.ref, 'core'));
81
+ }
82
+ for (const r of await input.bus.runIsolated('doctor.check', family.id, {
83
+ account,
84
+ family,
85
+ config: {},
86
+ })) {
87
+ if (r.error) {
88
+ findings.push({
89
+ level: 'fail',
90
+ account: account.ref,
91
+ item: 'plugin',
92
+ code: 'plugin.error',
93
+ message: r.error.message,
94
+ source: r.plugin,
95
+ fixable: false,
96
+ });
97
+ continue;
98
+ }
99
+ const results = r.result ?? [];
100
+ if (!Array.isArray(results)) {
101
+ findings.push({
102
+ level: 'fail',
103
+ account: account.ref,
104
+ item: 'plugin',
105
+ code: 'plugin.error',
106
+ message: `[${r.plugin}] doctor.check must return an array of findings`,
107
+ source: r.plugin,
108
+ fixable: false,
109
+ });
110
+ continue;
111
+ }
112
+ for (const c of results)
113
+ if (c?.level !== 'ok')
114
+ findings.push(toFinding(c, account.ref, r.plugin));
115
+ }
116
+ return {
117
+ ref: account.ref,
118
+ family: account.family,
119
+ name: account.name,
120
+ kind: account.kind,
121
+ dir: account.dir,
122
+ shared: { ok: okCount, total },
123
+ backups: await countBackups(account.dir),
124
+ findings,
125
+ };
126
+ }
127
+ export async function runDoctor(input) {
128
+ const accounts = [];
129
+ const general = [];
130
+ for (const family of input.families.values()) {
131
+ const list = input.accounts.filter((a) => a.family === family.id);
132
+ if (list.length === 0)
133
+ continue;
134
+ if (!list.some((a) => a.isMain) && !(await statOrNull(mainDirOf(family, input.home)))?.isDirectory())
135
+ general.push({
136
+ level: 'fail',
137
+ account: `${family.id}:main`,
138
+ item: 'main',
139
+ code: 'main.missing',
140
+ message: `${family.title} has accounts but no main account directory to share from`,
141
+ hint: `run ${family.bin} once so it creates its default directory`,
142
+ source: 'core',
143
+ fixable: false,
144
+ });
145
+ for (const account of list)
146
+ accounts.push(await checkAccount(input, family, account));
147
+ }
148
+ if (input.skillVersion) {
149
+ const checked = [...input.families.values()].filter((f) => input.accounts.some((a) => a.family === f.id));
150
+ general.push(...(await checkInstalledSkills(input.home, checked, input.skillVersion)));
151
+ }
152
+ const failed = [...general, ...accounts.flatMap((a) => a.findings)].some((f) => f.level === 'fail');
153
+ return { status: failed ? 'issues' : 'ok', accounts, general, fixes: [] };
154
+ }
155
+ /** Applies every fixable Finding once, in order; one failing fix never stops the others. */
156
+ export async function applyFixes(report) {
157
+ const out = [];
158
+ for (const f of report.accounts.flatMap((a) => a.findings)) {
159
+ if (!f.fixable || !f.fix)
160
+ continue;
161
+ let res;
162
+ try {
163
+ res = await f.fix();
164
+ }
165
+ catch (err) {
166
+ res = { ok: false, message: err.message };
167
+ }
168
+ out.push({ account: f.account, item: f.item, code: f.code, ...res });
169
+ }
170
+ return out;
171
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * An error caused by what the user asked for or has on disk (unknown account, broken config, invalid name),
3
+ * as opposed to a bug. Adapters map it without a stack: the CLI exits 1, the Panel answers 400.
4
+ * Command-line shape errors (`UsageError`, exit 2) are deliberately not UserErrors.
5
+ */
6
+ export declare class UserError extends Error {
7
+ /** Machine-readable reason, such as `create-refused`; the Panel passes it to the page. */
8
+ readonly code?: string;
9
+ constructor(message: string, opts?: {
10
+ code?: string;
11
+ });
12
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * An error caused by what the user asked for or has on disk (unknown account, broken config, invalid name),
3
+ * as opposed to a bug. Adapters map it without a stack: the CLI exits 1, the Panel answers 400.
4
+ * Command-line shape errors (`UsageError`, exit 2) are deliberately not UserErrors.
5
+ */
6
+ export class UserError extends Error {
7
+ /** Machine-readable reason, such as `create-refused`; the Panel passes it to the page. */
8
+ code;
9
+ constructor(message, opts = {}) {
10
+ super(message);
11
+ if (opts.code)
12
+ this.code = opts.code;
13
+ }
14
+ }
@@ -0,0 +1,24 @@
1
+ import type { Stats } from 'node:fs';
2
+ /** The parts of `fs.Stats` that tell whether a file changed. */
3
+ export type FileStats = Pick<Stats, 'size' | 'mtimeMs' | 'ctimeMs' | 'ino'>;
4
+ export declare function fileSignature(st: FileStats): string;
5
+ /** One pass over a scope's files. Files the pass does not ask for are forgotten when it ends. */
6
+ export interface MemoPass<T> {
7
+ /** The remembered value for this file version, or `parse()` (remembered unless `keep` says no). */
8
+ get(path: string, st: FileStats, parse: () => Promise<T>, keep?: (value: T) => boolean): Promise<T>;
9
+ /** Ends the pass: the scope now holds only the files this pass asked for. */
10
+ done(): void;
11
+ }
12
+ export declare class FileMemo<T> {
13
+ #private;
14
+ /** Counters for tests and timing. */
15
+ readonly stats: {
16
+ hits: number;
17
+ misses: number;
18
+ };
19
+ /** Starts a pass over `scope`, such as one Account's directory. */
20
+ pass(scope: string): MemoPass<T>;
21
+ /** Files remembered for a scope (for tests). */
22
+ size(scope: string): number;
23
+ clear(): void;
24
+ }
@@ -0,0 +1,41 @@
1
+ export function fileSignature(st) {
2
+ return `${st.size}:${st.mtimeMs}:${st.ctimeMs}:${st.ino}`;
3
+ }
4
+ export class FileMemo {
5
+ #scopes = new Map();
6
+ /** Counters for tests and timing. */
7
+ stats = { hits: 0, misses: 0 };
8
+ /** Starts a pass over `scope`, such as one Account's directory. */
9
+ pass(scope) {
10
+ const before = this.#scopes.get(scope);
11
+ const seen = new Map();
12
+ return {
13
+ get: async (path, st, parse, keep) => {
14
+ const sig = fileSignature(st);
15
+ const hit = seen.get(path) ?? before?.get(path);
16
+ if (hit && hit.sig === sig) {
17
+ this.stats.hits++;
18
+ seen.set(path, hit);
19
+ return hit.value;
20
+ }
21
+ this.stats.misses++;
22
+ const value = await parse();
23
+ if (!keep || keep(value))
24
+ seen.set(path, { sig, value });
25
+ return value;
26
+ },
27
+ done: () => {
28
+ this.#scopes.set(scope, seen);
29
+ },
30
+ };
31
+ }
32
+ /** Files remembered for a scope (for tests). */
33
+ size(scope) {
34
+ return this.#scopes.get(scope)?.size ?? 0;
35
+ }
36
+ clear() {
37
+ this.#scopes.clear();
38
+ this.stats.hits = 0;
39
+ this.stats.misses = 0;
40
+ }
41
+ }