sideby 0.0.0-stage → 0.2.1
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/LICENSE +21 -0
- package/README.md +111 -2
- package/README.zh-CN.md +112 -0
- package/dist/build.json +1 -0
- package/dist/src/cli/args.d.ts +13 -0
- package/dist/src/cli/args.js +38 -0
- package/dist/src/cli/commands.d.ts +50 -0
- package/dist/src/cli/commands.js +566 -0
- package/dist/src/cli/format.d.ts +16 -0
- package/dist/src/cli/format.js +80 -0
- package/dist/src/cli/json-schemas.d.ts +2 -0
- package/dist/src/cli/json-schemas.js +259 -0
- package/dist/src/cli/main.d.ts +2 -0
- package/dist/src/cli/main.js +276 -0
- package/dist/src/cli/schemas.d.ts +1 -0
- package/dist/src/cli/schemas.js +16 -0
- package/dist/src/core/account-name.d.ts +2 -0
- package/dist/src/core/account-name.js +2 -0
- package/dist/src/core/accounts.d.ts +20 -0
- package/dist/src/core/accounts.js +131 -0
- package/dist/src/core/config.d.ts +61 -0
- package/dist/src/core/config.js +184 -0
- package/dist/src/core/create.d.ts +30 -0
- package/dist/src/core/create.js +126 -0
- package/dist/src/core/doctor.d.ts +41 -0
- package/dist/src/core/doctor.js +171 -0
- package/dist/src/core/errors.d.ts +12 -0
- package/dist/src/core/errors.js +14 -0
- package/dist/src/core/file-memo.d.ts +24 -0
- package/dist/src/core/file-memo.js +41 -0
- package/dist/src/core/fs-safe.d.ts +38 -0
- package/dist/src/core/fs-safe.js +205 -0
- package/dist/src/core/handoff.d.ts +41 -0
- package/dist/src/core/handoff.js +54 -0
- package/dist/src/core/identity.d.ts +23 -0
- package/dist/src/core/identity.js +79 -0
- package/dist/src/core/last-doctor.d.ts +36 -0
- package/dist/src/core/last-doctor.js +69 -0
- package/dist/src/core/launch.d.ts +37 -0
- package/dist/src/core/launch.js +108 -0
- package/dist/src/core/login-path.d.ts +23 -0
- package/dist/src/core/login-path.js +96 -0
- package/dist/src/core/paths.d.ts +13 -0
- package/dist/src/core/paths.js +32 -0
- package/dist/src/core/quota-levels.d.ts +19 -0
- package/dist/src/core/quota-levels.js +34 -0
- package/dist/src/core/resume.d.ts +9 -0
- package/dist/src/core/resume.js +15 -0
- package/dist/src/core/secret-file.d.ts +30 -0
- package/dist/src/core/secret-file.js +116 -0
- package/dist/src/core/share-meaning.d.ts +6 -0
- package/dist/src/core/share-meaning.js +32 -0
- package/dist/src/core/share-mode-names.d.ts +1 -0
- package/dist/src/core/share-mode-names.js +10 -0
- package/dist/src/core/share-modes.d.ts +24 -0
- package/dist/src/core/share-modes.js +405 -0
- package/dist/src/core/shell-init.d.ts +31 -0
- package/dist/src/core/shell-init.js +84 -0
- package/dist/src/core/skill-check.d.ts +11 -0
- package/dist/src/core/skill-check.js +57 -0
- package/dist/src/core/usage-days.d.ts +15 -0
- package/dist/src/core/usage-days.js +33 -0
- package/dist/src/core/version.d.ts +9 -0
- package/dist/src/core/version.js +46 -0
- package/dist/src/desktop/app.d.ts +60 -0
- package/dist/src/desktop/app.js +316 -0
- package/dist/src/desktop/icon.d.ts +16 -0
- package/dist/src/desktop/icon.js +174 -0
- package/dist/src/families/claude/index.d.ts +10 -0
- package/dist/src/families/claude/index.js +127 -0
- package/dist/src/families/claude/layout.d.ts +6 -0
- package/dist/src/families/claude/layout.js +5 -0
- package/dist/src/families/claude/quota-cache.d.ts +31 -0
- package/dist/src/families/claude/quota-cache.js +111 -0
- package/dist/src/families/claude/quota.d.ts +3 -0
- package/dist/src/families/claude/quota.js +53 -0
- package/dist/src/families/claude/usage.d.ts +22 -0
- package/dist/src/families/claude/usage.js +146 -0
- package/dist/src/families/codex/index.d.ts +5 -0
- package/dist/src/families/codex/index.js +70 -0
- package/dist/src/families/codex/rollout.d.ts +67 -0
- package/dist/src/families/codex/rollout.js +298 -0
- package/dist/src/families/grok/index.d.ts +8 -0
- package/dist/src/families/grok/index.js +82 -0
- package/dist/src/families/index.d.ts +3 -0
- package/dist/src/families/index.js +40 -0
- package/dist/src/families/logos.d.ts +21 -0
- package/dist/src/families/logos.js +26 -0
- package/dist/src/families/pi/index.d.ts +3 -0
- package/dist/src/families/pi/index.js +54 -0
- package/dist/src/families/shared/sessions.d.ts +9 -0
- package/dist/src/families/shared/sessions.js +70 -0
- package/dist/src/families/shared/toml.d.ts +5 -0
- package/dist/src/families/shared/toml.js +16 -0
- package/dist/src/index.d.ts +5 -0
- package/dist/src/index.js +4 -0
- package/dist/src/panel/background.d.ts +62 -0
- package/dist/src/panel/background.js +274 -0
- package/dist/src/panel/handler.d.ts +91 -0
- package/dist/src/panel/handler.js +396 -0
- package/dist/src/panel/i18n.d.ts +287 -0
- package/dist/src/panel/i18n.js +570 -0
- package/dist/src/panel/page-logic.d.ts +154 -0
- package/dist/src/panel/page-logic.js +326 -0
- package/dist/src/panel/page-script.d.ts +2 -0
- package/dist/src/panel/page-script.js +1633 -0
- package/dist/src/panel/page-styles.d.ts +7 -0
- package/dist/src/panel/page-styles.js +514 -0
- package/dist/src/panel/page.d.ts +20 -0
- package/dist/src/panel/page.js +96 -0
- package/dist/src/panel/server.d.ts +32 -0
- package/dist/src/panel/server.js +133 -0
- package/dist/src/panel/theme.d.ts +116 -0
- package/dist/src/panel/theme.js +132 -0
- package/dist/src/plugins/account-script.d.ts +4 -0
- package/dist/src/plugins/account-script.js +66 -0
- package/dist/src/plugins/bus.d.ts +33 -0
- package/dist/src/plugins/bus.js +84 -0
- package/dist/src/plugins/loader.d.ts +58 -0
- package/dist/src/plugins/loader.js +250 -0
- package/dist/src/quota/claude-statusline.d.ts +28 -0
- package/dist/src/quota/claude-statusline.js +301 -0
- package/dist/src/quota/statusline-tap.d.ts +11 -0
- package/dist/src/quota/statusline-tap.js +104 -0
- package/dist/src/runtime.d.ts +195 -0
- package/dist/src/runtime.js +424 -0
- package/dist/src/types.d.ts +285 -0
- package/dist/src/types.js +3 -0
- package/dist/testing/demo.d.ts +8 -0
- package/dist/testing/demo.js +50 -0
- package/dist/testing/index.d.ts +36 -0
- package/dist/testing/index.js +118 -0
- package/package.json +66 -4
- package/schemas/alias.json +102 -0
- package/schemas/app-install.json +74 -0
- package/schemas/app-uninstall.json +54 -0
- package/schemas/config.json +170 -0
- package/schemas/doctor.json +229 -0
- package/schemas/error.json +25 -0
- package/schemas/families.json +205 -0
- package/schemas/list.json +177 -0
- package/schemas/new.json +199 -0
- package/schemas/next.json +127 -0
- package/schemas/plugins.json +74 -0
- package/schemas/quota-setup.json +60 -0
- package/schemas/quota-teardown.json +30 -0
- package/schemas/quota.json +203 -0
- package/skills/sideby/SKILL.md +61 -0
- package/skills/sideby/references/accounts.md +31 -0
- package/skills/sideby/references/diagnose.md +47 -0
- package/skills/sideby/references/handoff.md +41 -0
- package/skills/sideby/references/quota.md +14 -0
- package/skills/sideby/references/setup.md +31 -0
- package/skills/sideby/references/sharing.md +17 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { readdir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { ACCOUNT_NAME } from './account-name.js';
|
|
4
|
+
import { UserError } from './errors.js';
|
|
5
|
+
import { lstatOrNull, statOrNull } from './fs-safe.js';
|
|
6
|
+
export { ACCOUNT_NAME };
|
|
7
|
+
const BACKUP_LIKE = /(bak|backup|old|tmp)/;
|
|
8
|
+
export function looksLikeBackup(name) {
|
|
9
|
+
return BACKUP_LIKE.test(name);
|
|
10
|
+
}
|
|
11
|
+
export function mainDirOf(family, home) {
|
|
12
|
+
return join(home, family.layout.main);
|
|
13
|
+
}
|
|
14
|
+
export function accountDirOf(family, home, name) {
|
|
15
|
+
return name === 'main' ? mainDirOf(family, home) : join(home, family.layout.account.replace('<name>', name));
|
|
16
|
+
}
|
|
17
|
+
function splitLayout(family) {
|
|
18
|
+
const [first, ...rest] = family.layout.account.split('/');
|
|
19
|
+
const at = first.indexOf('<name>');
|
|
20
|
+
if (at === -1)
|
|
21
|
+
throw new Error(`family ${family.id}: layout.account must contain <name> in its first segment`);
|
|
22
|
+
return { prefix: first.slice(0, at), suffix: first.slice(at + '<name>'.length), rest: rest.join('/') };
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The Account for this Family and name, read from its directory: the one place an Account is built.
|
|
26
|
+
* Whether it is an API Account follows the Secret File on disk (and the Family's secretFileMeansApi).
|
|
27
|
+
*/
|
|
28
|
+
export async function accountAt(family, home, name) {
|
|
29
|
+
const dir = accountDirOf(family, home, name);
|
|
30
|
+
const secretFile = join(dir, 'proxy.env');
|
|
31
|
+
const hasSecret = (await lstatOrNull(secretFile)) !== null;
|
|
32
|
+
return {
|
|
33
|
+
family: family.id,
|
|
34
|
+
name,
|
|
35
|
+
ref: `${family.id}:${name}`,
|
|
36
|
+
dir,
|
|
37
|
+
isMain: name === 'main',
|
|
38
|
+
kind: hasSecret && family.secretFileMeansApi !== false ? 'api' : 'subscription',
|
|
39
|
+
...(hasSecret ? { secretFile } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* A directory that merely matches the name pattern (for example `~/.claude-code-router`, another tool's
|
|
44
|
+
* config) is not an Account: it must hold a Secret File or one of the Family's Shared Items, or be empty.
|
|
45
|
+
*/
|
|
46
|
+
async function looksLikeAccount(family, dir) {
|
|
47
|
+
for (const p of ['proxy.env', ...(family.markers ?? family.sharedItems.map((i) => i.path))])
|
|
48
|
+
if (await lstatOrNull(join(dir, p)))
|
|
49
|
+
return true;
|
|
50
|
+
// A freshly created Account may have nothing to share yet; another tool's directory is never empty.
|
|
51
|
+
try {
|
|
52
|
+
return (await readdir(dir)).length === 0;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Discovers the Main Account (if its directory exists) and every `<prefix><name><suffix>` directory in HOME. */
|
|
59
|
+
export async function discoverAccounts(family, home, ignore = new Set()) {
|
|
60
|
+
const out = [];
|
|
61
|
+
const main = mainDirOf(family, home);
|
|
62
|
+
if ((await statOrNull(main))?.isDirectory())
|
|
63
|
+
out.push(await accountAt(family, home, 'main'));
|
|
64
|
+
const { prefix, suffix, rest } = splitLayout(family);
|
|
65
|
+
let entries = [];
|
|
66
|
+
try {
|
|
67
|
+
entries = await readdir(home);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
const names = [];
|
|
73
|
+
for (const e of entries) {
|
|
74
|
+
if (!e.startsWith(prefix) || !e.endsWith(suffix))
|
|
75
|
+
continue;
|
|
76
|
+
const name = e.slice(prefix.length, e.length - suffix.length);
|
|
77
|
+
if (!ACCOUNT_NAME.test(name) || name === 'main')
|
|
78
|
+
continue;
|
|
79
|
+
if (ignore.has(`${family.id}:${name}`))
|
|
80
|
+
continue;
|
|
81
|
+
// A non-main Account must be a real directory at every level: a symlinked `~/.claude-work` pointing at
|
|
82
|
+
// `~/.claude` would make every repair write into the Main Account. Only the Main Account may be a link.
|
|
83
|
+
const top = join(home, e);
|
|
84
|
+
const dir = rest ? join(top, rest) : top;
|
|
85
|
+
if (!(await lstatOrNull(top))?.isDirectory())
|
|
86
|
+
continue;
|
|
87
|
+
if (rest && !(await lstatOrNull(dir))?.isDirectory())
|
|
88
|
+
continue;
|
|
89
|
+
if (!(await looksLikeAccount(family, dir)))
|
|
90
|
+
continue;
|
|
91
|
+
names.push(name);
|
|
92
|
+
}
|
|
93
|
+
names.sort();
|
|
94
|
+
for (const name of names)
|
|
95
|
+
out.push(await accountAt(family, home, name));
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
export class AccountRefError extends UserError {
|
|
99
|
+
}
|
|
100
|
+
/** Resolves `<family>:<name>`, an alias from the config, or a name unique across Families. */
|
|
101
|
+
export function resolveRef(ref, accounts, aliases = {}) {
|
|
102
|
+
if (!ref.includes(':') && Object.hasOwn(aliases, ref))
|
|
103
|
+
return resolveRef(aliases[ref], accounts);
|
|
104
|
+
if (ref.includes(':')) {
|
|
105
|
+
const hit = accounts.find((a) => a.ref === ref);
|
|
106
|
+
if (hit)
|
|
107
|
+
return hit;
|
|
108
|
+
throw new AccountRefError(`no account ${ref}; run \`sideby list\` to see accounts or \`sideby new\` to create one`);
|
|
109
|
+
}
|
|
110
|
+
const hits = accounts.filter((a) => a.name === ref);
|
|
111
|
+
if (hits.length === 1)
|
|
112
|
+
return hits[0];
|
|
113
|
+
if (hits.length === 0)
|
|
114
|
+
throw new AccountRefError(`no account named ${ref}; run \`sideby list\` to see accounts`);
|
|
115
|
+
throw new AccountRefError(`${ref} is ambiguous; use one of: ${hits.map((a) => a.ref).join(', ')}`);
|
|
116
|
+
}
|
|
117
|
+
/** Alias names per Account ref, sorted. An alias whose target matches no Account is left out. */
|
|
118
|
+
export function aliasesByAccount(aliases, accounts) {
|
|
119
|
+
const out = new Map();
|
|
120
|
+
for (const [alias, target] of Object.entries(aliases ?? {})) {
|
|
121
|
+
let ref;
|
|
122
|
+
try {
|
|
123
|
+
ref = resolveRef(target, accounts).ref;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
out.set(ref, [...(out.get(ref) ?? []), alias].sort());
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type Static, Type } from 'typebox';
|
|
2
|
+
import { UserError } from './errors.ts';
|
|
3
|
+
export declare const ALIAS_NAME: RegExp;
|
|
4
|
+
/** Shell keywords and builtins an alias must not shadow (`command() { command sideby … }` would recurse). */
|
|
5
|
+
export declare const RESERVED_ALIASES: Set<string>;
|
|
6
|
+
export declare function aliasProblem(alias: string): string | null;
|
|
7
|
+
/** One `aliases` value: an Account ref, or a ref plus Host arguments that only `sideby run` adds (ADR-0005). */
|
|
8
|
+
declare const AliasSchema: Type.TUnion<[Type.TString, Type.TObject<{
|
|
9
|
+
account: Type.TString;
|
|
10
|
+
args: Type.TOptional<Type.TArray<Type.TString>>;
|
|
11
|
+
}>]>;
|
|
12
|
+
export type AliasValue = Static<typeof AliasSchema>;
|
|
13
|
+
/** The Account ref an Alias starts. */
|
|
14
|
+
export declare const aliasAccount: (value: AliasValue) => string;
|
|
15
|
+
/** The Host arguments an Alias adds to `sideby run`; none for a plain ref. */
|
|
16
|
+
export declare const aliasArgs: (value: AliasValue | undefined) => string[];
|
|
17
|
+
/** `aliases` as alias → Account ref, for lookups that ignore the arguments. */
|
|
18
|
+
export declare function aliasRefs(aliases: Readonly<Record<string, AliasValue>> | undefined): Record<string, string>;
|
|
19
|
+
export declare const ConfigSchema: Type.TObject<{
|
|
20
|
+
$schema: Type.TOptional<Type.TString>;
|
|
21
|
+
accounts: Type.TOptional<Type.TRecord<"^.*$", Type.TObject<{
|
|
22
|
+
args: Type.TOptional<Type.TArray<Type.TString>>;
|
|
23
|
+
}>>>;
|
|
24
|
+
aliases: Type.TOptional<Type.TRecord<"^.*$", Type.TUnion<[Type.TString, Type.TObject<{
|
|
25
|
+
account: Type.TString;
|
|
26
|
+
args: Type.TOptional<Type.TArray<Type.TString>>;
|
|
27
|
+
}>]>>>;
|
|
28
|
+
ignore: Type.TOptional<Type.TArray<Type.TString>>;
|
|
29
|
+
pluginDirs: Type.TOptional<Type.TArray<Type.TString>>;
|
|
30
|
+
plugins: Type.TOptional<Type.TRecord<"^.*$", Type.TRecord<"^.*$", Type.TUnknown>>>;
|
|
31
|
+
extraSharedItems: Type.TOptional<Type.TRecord<"^.*$", Type.TArray<Type.TObject<{
|
|
32
|
+
path: Type.TString;
|
|
33
|
+
mode: Type.TUnion<Type.TLiteral<"copy" | "info" | "link" | "link-or-copy" | "link-or-local" | "local" | "local-if-api">[]>;
|
|
34
|
+
noSymlink: Type.TOptional<Type.TBoolean>;
|
|
35
|
+
credential: Type.TOptional<Type.TBoolean>;
|
|
36
|
+
}>>>>;
|
|
37
|
+
resumeRouting: Type.TOptional<Type.TBoolean>;
|
|
38
|
+
shellInitFile: Type.TOptional<Type.TObject<{
|
|
39
|
+
zsh: Type.TOptional<Type.TString>;
|
|
40
|
+
bash: Type.TOptional<Type.TString>;
|
|
41
|
+
}>>;
|
|
42
|
+
}>;
|
|
43
|
+
export type Config = Static<typeof ConfigSchema>;
|
|
44
|
+
export declare class ConfigError extends UserError {
|
|
45
|
+
}
|
|
46
|
+
export declare function loadConfig(file: string): Promise<Config>;
|
|
47
|
+
/**
|
|
48
|
+
* Adds `alias → ref` to the config file's `aliases`, as `{ account, args }` when `args` is not empty (see
|
|
49
|
+
* `editConfigAliases` for how the file is written). Refuses an invalid alias, or one that already starts another
|
|
50
|
+
* Account or the same Account with other arguments. Returns the aliases now in the file.
|
|
51
|
+
*/
|
|
52
|
+
export declare function addConfigAlias(file: string, alias: string, ref: string, args?: readonly string[]): Promise<{
|
|
53
|
+
status: 'added' | 'exists';
|
|
54
|
+
aliases: Record<string, AliasValue>;
|
|
55
|
+
}>;
|
|
56
|
+
/** Removes `alias` from the config file's `aliases`; `absent` when it was not there (nothing is written then). */
|
|
57
|
+
export declare function removeConfigAlias(file: string, alias: string): Promise<{
|
|
58
|
+
status: 'removed' | 'absent';
|
|
59
|
+
aliases: Record<string, AliasValue>;
|
|
60
|
+
}>;
|
|
61
|
+
export {};
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
resumeRouting: Type.Optional(Type.Boolean({
|
|
61
|
+
description: 'Make `sideby shell-init` also define Host-named functions (`claude`, `codex`, `grok`) that resume a session by id in the Account that holds it. Off by default.',
|
|
62
|
+
})),
|
|
63
|
+
shellInitFile: Type.Optional(Type.Object({
|
|
64
|
+
zsh: Type.Optional(Type.String({ minLength: 1 })),
|
|
65
|
+
bash: Type.Optional(Type.String({ minLength: 1 })),
|
|
66
|
+
}, {
|
|
67
|
+
additionalProperties: false,
|
|
68
|
+
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.',
|
|
69
|
+
})),
|
|
70
|
+
}, { additionalProperties: false, title: 'sideby config' });
|
|
71
|
+
export class ConfigError extends UserError {
|
|
72
|
+
}
|
|
73
|
+
export async function loadConfig(file) {
|
|
74
|
+
let raw;
|
|
75
|
+
try {
|
|
76
|
+
raw = await readFile(file, 'utf8');
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err.code === 'ENOENT')
|
|
80
|
+
return {};
|
|
81
|
+
throw new ConfigError(`cannot read ${file}: ${err.message}`);
|
|
82
|
+
}
|
|
83
|
+
let data;
|
|
84
|
+
try {
|
|
85
|
+
data = parseJsonSafely(raw, file);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
throw new ConfigError(`${file} is not valid JSON; fix the syntax or delete the file`);
|
|
89
|
+
}
|
|
90
|
+
if (!Value.Check(ConfigSchema, data)) {
|
|
91
|
+
const first = [...Value.Errors(ConfigSchema, data)][0];
|
|
92
|
+
const where = first?.instancePath || '/';
|
|
93
|
+
throw new ConfigError(`${file}: ${where} ${first?.message ?? 'is invalid'}; see schemas/config.json`);
|
|
94
|
+
}
|
|
95
|
+
// TypeBox does not enforce key patterns on records; alias names become shell function names.
|
|
96
|
+
for (const alias of Object.keys(data.aliases ?? {})) {
|
|
97
|
+
const problem = aliasProblem(alias);
|
|
98
|
+
if (problem)
|
|
99
|
+
throw new ConfigError(`${file}: alias "${alias}" ${problem} (it becomes a shell function name)`);
|
|
100
|
+
}
|
|
101
|
+
return data;
|
|
102
|
+
}
|
|
103
|
+
/** Where the config file is read and written: the target of a link (a dotfiles manager may link it). */
|
|
104
|
+
async function configTarget(file) {
|
|
105
|
+
return (await lstatOrNull(file))?.isSymbolicLink() ? ((await realpathOrNull(file)) ?? file) : file;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Re-reads the config file, lets `edit` change its `aliases`, and writes the result: every other key and their
|
|
109
|
+
* order kept (with `$schema`), 2-space JSON with a trailing newline, atomically, keeping the file's mode (0600 for
|
|
110
|
+
* a new file). Refuses, without writing, a file that is not valid JSON or not a valid config, or one that changed
|
|
111
|
+
* while sideby wrote it. `edit` returns null to leave the file alone. `retry` names what to do again after a fix.
|
|
112
|
+
*/
|
|
113
|
+
async function editConfigAliases(file, retry, edit) {
|
|
114
|
+
const target = await configTarget(file);
|
|
115
|
+
let raw = null;
|
|
116
|
+
try {
|
|
117
|
+
raw = await readFile(target, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
if (err.code !== 'ENOENT')
|
|
121
|
+
throw new ConfigError(`cannot read ${file}: ${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
let data = {};
|
|
124
|
+
if (raw !== null) {
|
|
125
|
+
let parsed;
|
|
126
|
+
try {
|
|
127
|
+
parsed = parseJsonSafely(raw, file);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new ConfigError(`${file} is not valid JSON; nothing was changed. Fix the syntax and ${retry} again`);
|
|
131
|
+
}
|
|
132
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
|
|
133
|
+
throw new ConfigError(`${file} is not a JSON object; nothing was changed. Fix it and ${retry} again`);
|
|
134
|
+
if (!Value.Check(ConfigSchema, parsed))
|
|
135
|
+
throw new ConfigError(`${file} does not match schemas/config.json; nothing was changed. Run \`sideby list\` to see why`);
|
|
136
|
+
data = parsed;
|
|
137
|
+
}
|
|
138
|
+
const current = (data.aliases ?? {});
|
|
139
|
+
const aliases = edit(current);
|
|
140
|
+
if (!aliases)
|
|
141
|
+
return current;
|
|
142
|
+
// A new file gets the schema link editors use for completion; an existing file keeps its keys in order.
|
|
143
|
+
const next = raw === null ? { $schema: 'https://unpkg.com/sideby/schemas/config.json', aliases } : { ...data, aliases };
|
|
144
|
+
const st = raw === null ? null : await lstatOrNull(target);
|
|
145
|
+
if (raw === null)
|
|
146
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
147
|
+
const written = await writeIfUnchanged(target, raw === null ? null : sha256(raw), `${JSON.stringify(next, null, 2)}\n`, st ? st.mode & 0o777 : 0o600);
|
|
148
|
+
if (!written)
|
|
149
|
+
throw new ConfigError(`${file} changed while sideby was writing it; nothing was changed. Try again`);
|
|
150
|
+
return aliases;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Adds `alias → ref` to the config file's `aliases`, as `{ account, args }` when `args` is not empty (see
|
|
154
|
+
* `editConfigAliases` for how the file is written). Refuses an invalid alias, or one that already starts another
|
|
155
|
+
* Account or the same Account with other arguments. Returns the aliases now in the file.
|
|
156
|
+
*/
|
|
157
|
+
export async function addConfigAlias(file, alias, ref, args = []) {
|
|
158
|
+
const problem = aliasProblem(alias);
|
|
159
|
+
if (problem)
|
|
160
|
+
throw new ConfigError(`alias "${alias}" ${problem} (it becomes a shell function name)`);
|
|
161
|
+
const value = args.length ? { account: ref, args: [...args] } : ref;
|
|
162
|
+
let status = 'added';
|
|
163
|
+
const aliases = await editConfigAliases(file, 'add the alias', (current) => {
|
|
164
|
+
if (!Object.hasOwn(current, alias))
|
|
165
|
+
return { ...current, [alias]: value };
|
|
166
|
+
if (!sameAlias(current[alias], value))
|
|
167
|
+
throw new ConfigError(`alias "${alias}" already starts ${describeAlias(current[alias])}; pick another name, or remove it first with \`sideby alias rm ${alias}\``);
|
|
168
|
+
status = 'exists';
|
|
169
|
+
return null;
|
|
170
|
+
});
|
|
171
|
+
return { status, aliases };
|
|
172
|
+
}
|
|
173
|
+
/** Removes `alias` from the config file's `aliases`; `absent` when it was not there (nothing is written then). */
|
|
174
|
+
export async function removeConfigAlias(file, alias) {
|
|
175
|
+
let status = 'absent';
|
|
176
|
+
const aliases = await editConfigAliases(file, 'remove the alias', (current) => {
|
|
177
|
+
if (!Object.hasOwn(current, alias))
|
|
178
|
+
return null;
|
|
179
|
+
status = 'removed';
|
|
180
|
+
const { [alias]: _gone, ...rest } = current;
|
|
181
|
+
return rest;
|
|
182
|
+
});
|
|
183
|
+
return { status, aliases };
|
|
184
|
+
}
|
|
@@ -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[]>;
|