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,116 @@
|
|
|
1
|
+
import { lstat, readFile } from 'node:fs/promises';
|
|
2
|
+
import { UserError } from './errors.js';
|
|
3
|
+
export class SecretFileError extends UserError {
|
|
4
|
+
}
|
|
5
|
+
const LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
|
|
6
|
+
/** Non-secret locations a Secret File may reference from the environment, such as `$HOME/bin/notify`. */
|
|
7
|
+
export const SECRET_FILE_ENV_REFS = [
|
|
8
|
+
'HOME',
|
|
9
|
+
'USER',
|
|
10
|
+
'LOGNAME',
|
|
11
|
+
'TMPDIR',
|
|
12
|
+
'XDG_CONFIG_HOME',
|
|
13
|
+
'XDG_DATA_HOME',
|
|
14
|
+
'XDG_STATE_HOME',
|
|
15
|
+
'XDG_CACHE_HOME',
|
|
16
|
+
];
|
|
17
|
+
const REF = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)|\$\{/g;
|
|
18
|
+
/**
|
|
19
|
+
* Parses the dotenv subset sideby supports: KEY=VALUE, optional `export `, quotes and comments.
|
|
20
|
+
* `$NAME` and `${NAME}` in unquoted or double-quoted values expand to a key defined earlier in the
|
|
21
|
+
* same file, else to one of SECRET_FILE_ENV_REFS from `env`; no other variable is read and nothing is
|
|
22
|
+
* executed. Errors name the line, never its content.
|
|
23
|
+
*/
|
|
24
|
+
export function parseSecretFile(text, file, env = {}) {
|
|
25
|
+
const out = {};
|
|
26
|
+
const lines = text.split(/\r?\n/);
|
|
27
|
+
const expand = (s, line) => s.replace(REF, (whole, braced, bare) => {
|
|
28
|
+
const name = braced ?? bare;
|
|
29
|
+
if (!name)
|
|
30
|
+
throw new SecretFileError(`${file}:${line}: unterminated \${`);
|
|
31
|
+
if (Object.hasOwn(out, name))
|
|
32
|
+
return out[name];
|
|
33
|
+
const fromEnv = SECRET_FILE_ENV_REFS.includes(name) ? env[name] : undefined;
|
|
34
|
+
if (fromEnv !== undefined)
|
|
35
|
+
return fromEnv;
|
|
36
|
+
throw new SecretFileError(`${file}:${line}: ${whole} is not defined earlier in this file; from the environment only ${SECRET_FILE_ENV_REFS.join(', ')} are read`);
|
|
37
|
+
});
|
|
38
|
+
lines.forEach((raw, i) => {
|
|
39
|
+
const line = raw.trim();
|
|
40
|
+
if (!line || line.startsWith('#'))
|
|
41
|
+
return;
|
|
42
|
+
const m = LINE.exec(raw);
|
|
43
|
+
if (!m)
|
|
44
|
+
throw new SecretFileError(`${file}:${i + 1}: expected KEY=VALUE`);
|
|
45
|
+
const key = m[1];
|
|
46
|
+
let value = m[2];
|
|
47
|
+
const q = value[0];
|
|
48
|
+
if (q === '"' || q === "'") {
|
|
49
|
+
let end = -1;
|
|
50
|
+
for (let j = 1; j < value.length; j++) {
|
|
51
|
+
if (q === '"' && value[j] === '\\')
|
|
52
|
+
j++;
|
|
53
|
+
else if (value[j] === q) {
|
|
54
|
+
end = j;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (end === -1)
|
|
59
|
+
throw new SecretFileError(`${file}:${i + 1}: unterminated quote`);
|
|
60
|
+
const rest = value.slice(end + 1).trim();
|
|
61
|
+
if (rest && !rest.startsWith('#'))
|
|
62
|
+
throw new SecretFileError(`${file}:${i + 1}: text after closing quote`);
|
|
63
|
+
value = value.slice(1, end);
|
|
64
|
+
if (q === '"')
|
|
65
|
+
// Escapes and references in one pass, so an escaped `\$` stays a literal dollar sign.
|
|
66
|
+
value = value.replace(/\\(n|"|\\|\$)|\$[^$\\]*/g, (part, c) => c === undefined ? expand(part, i + 1) : c === 'n' ? '\n' : c);
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const hash = value.search(/\s#/);
|
|
70
|
+
if (hash !== -1)
|
|
71
|
+
value = value.slice(0, hash).trimEnd();
|
|
72
|
+
if (/\$\(|`/.test(value))
|
|
73
|
+
throw new SecretFileError(`${file}:${i + 1}: command substitution is not supported; write the value itself`);
|
|
74
|
+
value = expand(value, i + 1);
|
|
75
|
+
}
|
|
76
|
+
out[key] = value;
|
|
77
|
+
});
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The one Secret File rule, used by Launch and by Doctor: the Account's own regular file (never a link)
|
|
82
|
+
* with mode 600. Only the file's type and mode are inspected, never its contents.
|
|
83
|
+
*/
|
|
84
|
+
export async function inspectSecretFile(file) {
|
|
85
|
+
let st;
|
|
86
|
+
try {
|
|
87
|
+
st = await lstat(file);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { kind: 'missing' };
|
|
91
|
+
}
|
|
92
|
+
if (!st.isFile())
|
|
93
|
+
return { kind: 'not-file' };
|
|
94
|
+
const mode = st.mode & 0o777;
|
|
95
|
+
return mode === 0o600 ? { kind: 'ok' } : { kind: 'mode', mode };
|
|
96
|
+
}
|
|
97
|
+
/** Reads a Secret File after inspectSecretFile accepts it. */
|
|
98
|
+
export async function readSecretFile(file, env = {}) {
|
|
99
|
+
const state = await inspectSecretFile(file);
|
|
100
|
+
if (state.kind === 'missing')
|
|
101
|
+
throw new SecretFileError(`${file} is missing`);
|
|
102
|
+
if (state.kind === 'not-file')
|
|
103
|
+
throw new SecretFileError(`${file} must be a regular file, not a link or directory`);
|
|
104
|
+
if (state.kind === 'mode')
|
|
105
|
+
throw new SecretFileError(`${file} has mode ${state.mode.toString(8)}; run \`chmod 600 ${file}\` so only you can read it`);
|
|
106
|
+
return parseSecretFile(await readFile(file, 'utf8'), file, env);
|
|
107
|
+
}
|
|
108
|
+
export function secretFileTemplate(vars, familyTitle) {
|
|
109
|
+
const lines = [
|
|
110
|
+
`# ${familyTitle} API account. sideby loads these variables only into this account's process.`,
|
|
111
|
+
'# Uncomment and fill in. Keep this file private (mode 600).',
|
|
112
|
+
...vars.map((v) => `# ${v}=`),
|
|
113
|
+
'',
|
|
114
|
+
];
|
|
115
|
+
return lines.join('\n');
|
|
116
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { SharedItemDef } from '../types.ts';
|
|
2
|
+
/**
|
|
3
|
+
* What one Shared Item looks like in an Account, in one fixed English sentence for `sideby families` (spec §3.16).
|
|
4
|
+
* Agents quote it to users, so it follows the Share Mode rules of spec §3.3 and never promises more.
|
|
5
|
+
*/
|
|
6
|
+
export declare function shareMeaning(item: SharedItemDef): string;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one Shared Item looks like in an Account, in one fixed English sentence for `sideby families` (spec §3.16).
|
|
3
|
+
* Agents quote it to users, so it follows the Share Mode rules of spec §3.3 and never promises more.
|
|
4
|
+
*/
|
|
5
|
+
export function shareMeaning(item) {
|
|
6
|
+
const main = item.mainPath ? `~/${item.mainPath}` : "the Main Account's";
|
|
7
|
+
const base = (() => {
|
|
8
|
+
switch (item.mode) {
|
|
9
|
+
case 'link':
|
|
10
|
+
return "A link to the Main Account's: one copy for every Account.";
|
|
11
|
+
case 'copy':
|
|
12
|
+
return "A copy of the Main Account's, kept equal by `doctor --fix`; the Host does not accept a link here.";
|
|
13
|
+
case 'link-or-copy':
|
|
14
|
+
return "A link to the Main Account's, or a copy kept equal by `doctor --fix`.";
|
|
15
|
+
case 'link-or-local':
|
|
16
|
+
return "A link to the Main Account's, unless the Account keeps its own file.";
|
|
17
|
+
case 'local':
|
|
18
|
+
return "The Account's own file, first copied from the Main Account and never synced.";
|
|
19
|
+
case 'local-if-api':
|
|
20
|
+
return item.noSymlink
|
|
21
|
+
? "Subscription Accounts: a copy of the Main Account's, kept equal by `doctor --fix`. API Accounts: their own file."
|
|
22
|
+
: "Subscription Accounts: a link to the Main Account's, or a copy kept equal. API Accounts: their own file.";
|
|
23
|
+
case 'info':
|
|
24
|
+
return item.credential
|
|
25
|
+
? "The Account's own sign-in: never shared and never read; sideby checks only that it is mode 600."
|
|
26
|
+
: "The Account's own; sideby only reports it.";
|
|
27
|
+
case 'json-key':
|
|
28
|
+
return `The Account's own file; only its \`${item.key}\` entry is kept equal to ${main}${item.credential ? ', and sideby reads no other part of it' : ''}.`;
|
|
29
|
+
}
|
|
30
|
+
})();
|
|
31
|
+
return item.noSymlink && item.mode !== 'copy' ? `${base} Never a link: the Host refuses one.` : base;
|
|
32
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SHARE_MODES: readonly ["link", "copy", "link-or-copy", "link-or-local", "local", "local-if-api", "info", "json-key"];
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Account, CheckResult, SharedItemDef } from '../types.ts';
|
|
2
|
+
export interface ItemContext {
|
|
3
|
+
account: Account;
|
|
4
|
+
item: SharedItemDef;
|
|
5
|
+
/** Resolved main-side path. */
|
|
6
|
+
mainPath: string;
|
|
7
|
+
/** Path inside the Account directory. */
|
|
8
|
+
accountPath: string;
|
|
9
|
+
/** The Account directory; every repair must stay inside it. */
|
|
10
|
+
accountDir: string;
|
|
11
|
+
/** Allows `json-key` to remove entries the Account has but the main file does not. */
|
|
12
|
+
force: boolean;
|
|
13
|
+
}
|
|
14
|
+
/** `counted` is false for `info` items, which never count toward the shared total. */
|
|
15
|
+
export interface ItemCheck {
|
|
16
|
+
counted: boolean;
|
|
17
|
+
results: CheckResult[];
|
|
18
|
+
}
|
|
19
|
+
export declare function credentialModeFinding(item: string, path: string, mode: number): CheckResult;
|
|
20
|
+
export declare function checkItem(ctx: ItemContext): Promise<ItemCheck>;
|
|
21
|
+
export declare function itemPaths(home: string, mainDir: string, accountDir: string, item: SharedItemDef): {
|
|
22
|
+
mainPath: string;
|
|
23
|
+
accountPath: string;
|
|
24
|
+
};
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, symlink, unlink } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
3
|
+
import { assertInside, fileHash, isEmptyDir, linksTo, lstatOrNull, permBits, realpathOrNull, replaceWithCopy, sameContent, statOrNull, writeIfUnchanged, } from './fs-safe.js';
|
|
4
|
+
function kindOf(st) {
|
|
5
|
+
if (!st)
|
|
6
|
+
return 'missing';
|
|
7
|
+
if (st.isSymbolicLink())
|
|
8
|
+
return 'link';
|
|
9
|
+
if (st.isFile())
|
|
10
|
+
return 'file';
|
|
11
|
+
if (st.isDirectory())
|
|
12
|
+
return 'dir';
|
|
13
|
+
return 'other';
|
|
14
|
+
}
|
|
15
|
+
async function classify(ctx) {
|
|
16
|
+
const st = await lstatOrNull(ctx.accountPath);
|
|
17
|
+
const k = kindOf(st);
|
|
18
|
+
if (k !== 'link')
|
|
19
|
+
return { kind: k, st };
|
|
20
|
+
return { kind: (await linksTo(ctx.accountPath, ctx.mainPath)) ? 'link-ok' : 'link-other', st };
|
|
21
|
+
}
|
|
22
|
+
const ok = (item) => ({ level: 'ok', item, code: 'ok', message: 'ok' });
|
|
23
|
+
function hostNote(item) {
|
|
24
|
+
return item.hostMessage ? ` (host says: "${item.hostMessage}")` : '';
|
|
25
|
+
}
|
|
26
|
+
async function makeLink(ctx) {
|
|
27
|
+
await mkdir(dirname(ctx.accountPath), { recursive: true });
|
|
28
|
+
await symlink(ctx.mainPath, ctx.accountPath);
|
|
29
|
+
return { ok: true, message: `linked ${ctx.item.path}` };
|
|
30
|
+
}
|
|
31
|
+
async function makeCopy(ctx) {
|
|
32
|
+
await mkdir(dirname(ctx.accountPath), { recursive: true });
|
|
33
|
+
await replaceWithCopy(ctx.mainPath, ctx.accountPath);
|
|
34
|
+
return { ok: true, message: `copied ${ctx.item.path} from the main account` };
|
|
35
|
+
}
|
|
36
|
+
/** Replaces a symlink that points at the main item with a real copy; refuses any other link. */
|
|
37
|
+
async function linkToCopy(ctx) {
|
|
38
|
+
if (!(await linksTo(ctx.accountPath, ctx.mainPath)))
|
|
39
|
+
return { ok: false, message: `${ctx.item.path} no longer points at the main account; left unchanged` };
|
|
40
|
+
await unlink(ctx.accountPath);
|
|
41
|
+
return makeCopy(ctx);
|
|
42
|
+
}
|
|
43
|
+
function realConflict(ctx, what) {
|
|
44
|
+
const p = ctx.accountPath;
|
|
45
|
+
return {
|
|
46
|
+
level: 'fail',
|
|
47
|
+
item: ctx.item.path,
|
|
48
|
+
code: 'link.real-file',
|
|
49
|
+
message: `${ctx.item.path} is a real ${what}; expected a link to the main account`,
|
|
50
|
+
hint: `sideby never replaces it. Keep it with: mv "${p}" "${p}.local" && ln -s "${ctx.mainPath}" "${p}"`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Whether this item may legitimately be a link in this Account. */
|
|
54
|
+
function acceptsLink(ctx) {
|
|
55
|
+
const { item } = ctx;
|
|
56
|
+
if (item.noSymlink)
|
|
57
|
+
return false;
|
|
58
|
+
if (item.mode === 'local-if-api')
|
|
59
|
+
return ctx.account.kind !== 'api';
|
|
60
|
+
return item.mode === 'link' || item.mode === 'link-or-copy' || item.mode === 'link-or-local';
|
|
61
|
+
}
|
|
62
|
+
/** A link that does not resolve to the main item: dangling is a failure, a deliberate other target a warning. */
|
|
63
|
+
async function wrongLink(ctx) {
|
|
64
|
+
const target = await realpathOrNull(ctx.accountPath);
|
|
65
|
+
if (target && !acceptsLink(ctx))
|
|
66
|
+
return {
|
|
67
|
+
level: 'fail',
|
|
68
|
+
item: ctx.item.path,
|
|
69
|
+
code: 'symlink-forbidden',
|
|
70
|
+
message: `${ctx.item.path} must not be a link (it points to ${target})${hostNote(ctx.item)}`,
|
|
71
|
+
hint: `sideby never re-points or replaces links it did not create. Replace it yourself: rm "${ctx.accountPath}" && cp -R "${ctx.mainPath}" "${ctx.accountPath}"`,
|
|
72
|
+
};
|
|
73
|
+
if (target)
|
|
74
|
+
return {
|
|
75
|
+
level: 'warn',
|
|
76
|
+
item: ctx.item.path,
|
|
77
|
+
code: 'link.other-target',
|
|
78
|
+
message: `${ctx.item.path} links to ${target} instead of the main account's ${ctx.item.path}`,
|
|
79
|
+
hint: 'fine if intended; sideby never re-points links',
|
|
80
|
+
};
|
|
81
|
+
return {
|
|
82
|
+
level: 'fail',
|
|
83
|
+
item: ctx.item.path,
|
|
84
|
+
code: 'link.dangling',
|
|
85
|
+
message: `${ctx.item.path} is a dangling link`,
|
|
86
|
+
hint: `sideby never re-points links. Inspect it with: ls -l "${ctx.accountPath}"`,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
function missingFix(ctx, useCopy) {
|
|
90
|
+
return {
|
|
91
|
+
level: 'fail',
|
|
92
|
+
item: ctx.item.path,
|
|
93
|
+
code: 'missing',
|
|
94
|
+
message: `${ctx.item.path} is missing`,
|
|
95
|
+
hint: 'run `sideby doctor --fix`',
|
|
96
|
+
fixable: true,
|
|
97
|
+
fix: () => (useCopy ? makeCopy(ctx) : makeLink(ctx)),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
async function compareCopy(ctx, mainSt, kind) {
|
|
101
|
+
const item = ctx.item.path;
|
|
102
|
+
const mainKind = mainSt.isDirectory() ? 'dir' : 'file';
|
|
103
|
+
if (kind !== mainKind)
|
|
104
|
+
return {
|
|
105
|
+
level: 'fail',
|
|
106
|
+
item,
|
|
107
|
+
code: 'copy.type-mismatch',
|
|
108
|
+
message: `${item} is a ${kind} but the main account has a ${mainKind}`,
|
|
109
|
+
hint: 'sideby does not change the type of an existing item; move it aside and run `sideby doctor --fix`',
|
|
110
|
+
};
|
|
111
|
+
if (await sameContent(ctx.accountPath, ctx.mainPath))
|
|
112
|
+
return ok(item);
|
|
113
|
+
if (mainKind === 'dir' && (await isEmptyDir(ctx.mainPath)))
|
|
114
|
+
return {
|
|
115
|
+
level: 'warn',
|
|
116
|
+
item,
|
|
117
|
+
code: 'copy.main-empty',
|
|
118
|
+
message: `${item} differs, but the main account's copy is empty; not mirroring an empty directory`,
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
level: 'fail',
|
|
122
|
+
item,
|
|
123
|
+
code: 'copy.stale',
|
|
124
|
+
message: `${item} is an outdated copy of the main account`,
|
|
125
|
+
hint: 'run `sideby doctor --fix`',
|
|
126
|
+
fixable: true,
|
|
127
|
+
fix: () => makeCopy(ctx),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
async function checkCopyLike(ctx, mainSt, allowLink) {
|
|
131
|
+
const { kind } = await classify(ctx);
|
|
132
|
+
const item = ctx.item.path;
|
|
133
|
+
switch (kind) {
|
|
134
|
+
case 'link-ok':
|
|
135
|
+
if (allowLink)
|
|
136
|
+
return ok(item);
|
|
137
|
+
return {
|
|
138
|
+
level: 'fail',
|
|
139
|
+
item,
|
|
140
|
+
code: 'symlink-forbidden',
|
|
141
|
+
message: `${item} must be a real copy, not a link${hostNote(ctx.item)}`,
|
|
142
|
+
hint: 'run `sideby doctor --fix` to replace the link with a copy',
|
|
143
|
+
fixable: true,
|
|
144
|
+
fix: () => linkToCopy(ctx),
|
|
145
|
+
};
|
|
146
|
+
case 'link-other':
|
|
147
|
+
return await wrongLink(ctx);
|
|
148
|
+
case 'missing':
|
|
149
|
+
return missingFix(ctx, !allowLink);
|
|
150
|
+
case 'file':
|
|
151
|
+
case 'dir':
|
|
152
|
+
return compareCopy(ctx, mainSt, kind);
|
|
153
|
+
default:
|
|
154
|
+
return {
|
|
155
|
+
level: 'fail',
|
|
156
|
+
item,
|
|
157
|
+
code: 'unsupported-type',
|
|
158
|
+
message: `${item} is not a file, directory or link`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function checkJsonKey(ctx) {
|
|
163
|
+
const item = `${ctx.item.path}#${ctx.item.key}`;
|
|
164
|
+
const key = ctx.item.key;
|
|
165
|
+
const accSt = await lstatOrNull(ctx.accountPath);
|
|
166
|
+
if (accSt?.isSymbolicLink())
|
|
167
|
+
return {
|
|
168
|
+
level: 'fail',
|
|
169
|
+
item,
|
|
170
|
+
code: 'json-key.symlink',
|
|
171
|
+
message: `${ctx.item.path} is a symlink; sharing it would mix logins between accounts`,
|
|
172
|
+
hint: `replace it with the account's own file; sideby will not write through it even with --force`,
|
|
173
|
+
};
|
|
174
|
+
let mainVal;
|
|
175
|
+
let mainText;
|
|
176
|
+
try {
|
|
177
|
+
mainText = await readFile(ctx.mainPath, 'utf8');
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return { level: 'ok', item, code: 'ok', message: 'main file absent; nothing to share' };
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
mainVal = JSON.parse(mainText)[key];
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return {
|
|
187
|
+
level: 'warn',
|
|
188
|
+
item,
|
|
189
|
+
code: 'json-key.main-unreadable',
|
|
190
|
+
message: `cannot read ${key} from the main file`,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (mainVal === undefined)
|
|
194
|
+
return { level: 'ok', item, code: 'ok', message: `main file has no ${key}` };
|
|
195
|
+
if (!isPlainObject(mainVal))
|
|
196
|
+
return { level: 'fail', item, code: 'json-key.main-not-object', message: `main ${key} is not an object` };
|
|
197
|
+
const beforeHash = await fileHash(ctx.accountPath);
|
|
198
|
+
let acc = {};
|
|
199
|
+
if (beforeHash !== null) {
|
|
200
|
+
try {
|
|
201
|
+
const parsed = JSON.parse(await readFile(ctx.accountPath, 'utf8'));
|
|
202
|
+
if (!isPlainObject(parsed))
|
|
203
|
+
throw new Error('not an object');
|
|
204
|
+
acc = parsed;
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
return {
|
|
208
|
+
level: 'fail',
|
|
209
|
+
item,
|
|
210
|
+
code: 'json-key.not-object',
|
|
211
|
+
message: `${ctx.item.path} is not a JSON object; sideby will not rewrite it`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const accVal = key in acc ? acc[key] : {};
|
|
216
|
+
if (!isPlainObject(accVal))
|
|
217
|
+
return {
|
|
218
|
+
level: 'fail',
|
|
219
|
+
item,
|
|
220
|
+
code: 'json-key.not-object',
|
|
221
|
+
message: `${key} in ${ctx.item.path} is not an object`,
|
|
222
|
+
};
|
|
223
|
+
if (canonical(accVal) === canonical(mainVal))
|
|
224
|
+
return ok(item);
|
|
225
|
+
const removed = Object.keys(accVal).filter((k) => !(k in mainVal));
|
|
226
|
+
const added = Object.keys(mainVal).filter((k) => !(k in accVal));
|
|
227
|
+
const detail = [
|
|
228
|
+
added.length ? `adds ${added.join(', ')}` : '',
|
|
229
|
+
removed.length ? `removes ${removed.join(', ')}` : '',
|
|
230
|
+
]
|
|
231
|
+
.filter(Boolean)
|
|
232
|
+
.join('; ');
|
|
233
|
+
const write = async () => {
|
|
234
|
+
const next = `${JSON.stringify({ ...acc, [key]: mainVal }, null, 2)}\n`;
|
|
235
|
+
const done = await writeIfUnchanged(ctx.accountPath, beforeHash, next, 0o600);
|
|
236
|
+
return done
|
|
237
|
+
? { ok: true, message: `synced ${key} (${detail || 'values changed'})` }
|
|
238
|
+
: { ok: false, message: `${ctx.item.path} changed while syncing; run doctor again` };
|
|
239
|
+
};
|
|
240
|
+
if (removed.length && !ctx.force)
|
|
241
|
+
return {
|
|
242
|
+
level: 'fail',
|
|
243
|
+
item,
|
|
244
|
+
code: 'json-key.would-remove',
|
|
245
|
+
message: `${key} differs from the main account and syncing would remove: ${removed.join(', ')}`,
|
|
246
|
+
hint: 'add them to the main account first, or run `sideby doctor --fix --force` to remove them',
|
|
247
|
+
};
|
|
248
|
+
return {
|
|
249
|
+
level: 'fail',
|
|
250
|
+
item,
|
|
251
|
+
code: 'json-key.drift',
|
|
252
|
+
message: `${key} differs from the main account (${detail || 'values changed'})`,
|
|
253
|
+
hint: 'run `sideby doctor --fix`',
|
|
254
|
+
fixable: true,
|
|
255
|
+
fix: write,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
async function checkCredentialMode(ctx) {
|
|
259
|
+
const st = await lstatOrNull(ctx.accountPath);
|
|
260
|
+
if (!st?.isFile() || permBits(st) === 0o600)
|
|
261
|
+
return null;
|
|
262
|
+
return credentialModeFinding(ctx.item.path, ctx.accountPath, permBits(st));
|
|
263
|
+
}
|
|
264
|
+
export function credentialModeFinding(item, path, mode) {
|
|
265
|
+
return {
|
|
266
|
+
level: 'fail',
|
|
267
|
+
item,
|
|
268
|
+
code: 'credential.mode',
|
|
269
|
+
message: `${item} has mode ${mode.toString(8)}; credentials must be 600`,
|
|
270
|
+
hint: 'run `sideby doctor --fix`',
|
|
271
|
+
fixable: true,
|
|
272
|
+
fix: async () => {
|
|
273
|
+
await chmod(path, 0o600);
|
|
274
|
+
return { ok: true, message: `set ${item} to 600` };
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
export async function checkItem(ctx) {
|
|
279
|
+
const { item } = ctx;
|
|
280
|
+
const out = [];
|
|
281
|
+
if (isAbsolute(item.path) || item.path.split(/[\\/]/).includes('..'))
|
|
282
|
+
return {
|
|
283
|
+
counted: false,
|
|
284
|
+
results: [
|
|
285
|
+
{
|
|
286
|
+
level: 'fail',
|
|
287
|
+
item: item.path,
|
|
288
|
+
code: 'path.invalid',
|
|
289
|
+
message: `shared item path ${item.path} must be relative and stay inside the account directory`,
|
|
290
|
+
hint: 'fix the path in the sideby config or the plugin that defines it',
|
|
291
|
+
},
|
|
292
|
+
],
|
|
293
|
+
};
|
|
294
|
+
// Every repair re-checks, at the moment it runs, that it writes inside this Account only.
|
|
295
|
+
const guard = (r) => {
|
|
296
|
+
const fix = r.fix;
|
|
297
|
+
if (!fix)
|
|
298
|
+
return r;
|
|
299
|
+
return {
|
|
300
|
+
...r,
|
|
301
|
+
fix: async () => {
|
|
302
|
+
await assertInside(ctx.accountDir, ctx.accountPath);
|
|
303
|
+
return fix();
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
};
|
|
307
|
+
const done = async (r, counted = true) => {
|
|
308
|
+
out.push(guard(r));
|
|
309
|
+
if (item.credential) {
|
|
310
|
+
const c = await checkCredentialMode(ctx);
|
|
311
|
+
if (c)
|
|
312
|
+
out.push(guard(c));
|
|
313
|
+
}
|
|
314
|
+
return { counted, results: out };
|
|
315
|
+
};
|
|
316
|
+
if (item.mode === 'info') {
|
|
317
|
+
const st = await lstatOrNull(ctx.accountPath);
|
|
318
|
+
if (item.noSymlink && st?.isSymbolicLink())
|
|
319
|
+
return done({
|
|
320
|
+
level: 'fail',
|
|
321
|
+
item: item.path,
|
|
322
|
+
code: 'symlink-forbidden',
|
|
323
|
+
message: `${item.path} must not be a link${hostNote(item)}`,
|
|
324
|
+
hint: `sideby does not copy credentials. Replace the link with the account's own file, e.g. log in again with \`sideby login ${ctx.account.ref}\``,
|
|
325
|
+
}, false);
|
|
326
|
+
return done(ok(item.path), false);
|
|
327
|
+
}
|
|
328
|
+
if (item.mode === 'json-key')
|
|
329
|
+
return done(await checkJsonKey(ctx));
|
|
330
|
+
// The main account has nothing to share here yet: not counted, but the Account's own entry must still
|
|
331
|
+
// respect Host rules (a dangling link, or a link where the Host refuses links, is still a problem).
|
|
332
|
+
const mainSt = await statOrNull(ctx.mainPath);
|
|
333
|
+
if (!mainSt) {
|
|
334
|
+
const st = await lstatOrNull(ctx.accountPath);
|
|
335
|
+
if (st?.isSymbolicLink() && (!(await realpathOrNull(ctx.accountPath)) || !acceptsLink(ctx)))
|
|
336
|
+
return done(await wrongLink(ctx), false);
|
|
337
|
+
return done(ok(item.path), false);
|
|
338
|
+
}
|
|
339
|
+
const isApi = ctx.account.kind === 'api';
|
|
340
|
+
const { kind } = await classify(ctx);
|
|
341
|
+
switch (item.mode) {
|
|
342
|
+
case 'link':
|
|
343
|
+
if (kind === 'link-ok')
|
|
344
|
+
return done(ok(item.path));
|
|
345
|
+
if (kind === 'missing')
|
|
346
|
+
return done(missingFix(ctx, false));
|
|
347
|
+
if (kind === 'link-other')
|
|
348
|
+
return done(await wrongLink(ctx));
|
|
349
|
+
return done(realConflict(ctx, kind === 'dir' ? 'directory' : 'file'));
|
|
350
|
+
case 'copy':
|
|
351
|
+
return done(await checkCopyLike(ctx, mainSt, false));
|
|
352
|
+
case 'link-or-copy':
|
|
353
|
+
return done(await checkCopyLike(ctx, mainSt, !item.noSymlink));
|
|
354
|
+
case 'link-or-local':
|
|
355
|
+
if (kind === 'link-ok' || kind === 'file' || kind === 'dir')
|
|
356
|
+
return done(ok(item.path));
|
|
357
|
+
if (kind === 'missing')
|
|
358
|
+
return done(missingFix(ctx, false));
|
|
359
|
+
return done(await wrongLink(ctx));
|
|
360
|
+
case 'local':
|
|
361
|
+
if (kind === 'file' || kind === 'dir')
|
|
362
|
+
return done(ok(item.path));
|
|
363
|
+
if (kind === 'missing')
|
|
364
|
+
return done(missingFix(ctx, true));
|
|
365
|
+
return done({
|
|
366
|
+
level: 'fail',
|
|
367
|
+
item: item.path,
|
|
368
|
+
code: 'local.is-link',
|
|
369
|
+
message: `${item.path} must be the account's own file, not a link${hostNote(item)}`,
|
|
370
|
+
hint: `sideby never replaces links here. Replace it yourself: rm "${ctx.accountPath}" && cp -R "${ctx.mainPath}" "${ctx.accountPath}"`,
|
|
371
|
+
});
|
|
372
|
+
case 'local-if-api':
|
|
373
|
+
if (!isApi)
|
|
374
|
+
return done(await checkCopyLike(ctx, mainSt, !item.noSymlink));
|
|
375
|
+
if (kind === 'file')
|
|
376
|
+
return done(ok(item.path));
|
|
377
|
+
if (kind === 'missing')
|
|
378
|
+
return done({
|
|
379
|
+
level: 'fail',
|
|
380
|
+
item: item.path,
|
|
381
|
+
code: 'api.needs-own',
|
|
382
|
+
message: `${item.path} is missing; an API account needs its own copy`,
|
|
383
|
+
hint: `cp "${ctx.mainPath}" "${ctx.accountPath}" and edit it for this account's endpoint`,
|
|
384
|
+
});
|
|
385
|
+
return done({
|
|
386
|
+
level: 'fail',
|
|
387
|
+
item: item.path,
|
|
388
|
+
code: 'api.needs-own',
|
|
389
|
+
message: `${item.path} must be this API account's own file (found ${kind === 'link-ok' || kind === 'link-other' ? 'a link' : `a ${kind}`})`,
|
|
390
|
+
hint: 'an API account changes its endpoint here, so sharing it would change the main account too',
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function isPlainObject(v) {
|
|
395
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
396
|
+
}
|
|
397
|
+
function canonical(v) {
|
|
398
|
+
return JSON.stringify(v, (_k, val) => isPlainObject(val) ? Object.fromEntries(Object.entries(val).sort(([a], [b]) => a.localeCompare(b))) : val);
|
|
399
|
+
}
|
|
400
|
+
export function itemPaths(home, mainDir, accountDir, item) {
|
|
401
|
+
return {
|
|
402
|
+
mainPath: item.mainPath ? join(home, item.mainPath) : join(mainDir, item.path),
|
|
403
|
+
accountPath: join(accountDir, item.path),
|
|
404
|
+
};
|
|
405
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Account } from '../types.ts';
|
|
2
|
+
export declare const SHELLS: readonly ['zsh', 'bash'];
|
|
3
|
+
export type Shell = (typeof SHELLS)[number];
|
|
4
|
+
/** First line of every generated script; an existing file without it is never overwritten. */
|
|
5
|
+
export declare const SHELL_INIT_HEADER = "# sideby shell-init: one function per account, plus aliases from the sideby config";
|
|
6
|
+
/** A Family whose bare Host command `shell-init` routes through `sideby resume` (config `resumeRouting`). */
|
|
7
|
+
export interface ResumeRouteFunction {
|
|
8
|
+
family: string;
|
|
9
|
+
bin: string;
|
|
10
|
+
selectVar: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The script for one shell, ending in a newline: exactly what `sideby shell-init <shell>` prints. zsh and bash
|
|
14
|
+
* read the same function syntax, so the shell only names the file it goes to.
|
|
15
|
+
*/
|
|
16
|
+
export declare function shellInitScript(accounts: readonly Account[], aliases: Readonly<Record<string, unknown>> | undefined, routes?: readonly ResumeRouteFunction[]): string;
|
|
17
|
+
export interface ShellInitWrite {
|
|
18
|
+
shell: Shell;
|
|
19
|
+
/** The configured path, as written in the config (`~/…` kept). */
|
|
20
|
+
path: string;
|
|
21
|
+
ok: boolean;
|
|
22
|
+
/** `written`, or `unchanged` when the file already had this content. */
|
|
23
|
+
action?: 'written' | 'unchanged';
|
|
24
|
+
message?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Writes `content` to the configured file atomically (temp file in the same directory, then rename), keeping its
|
|
28
|
+
* mode (0644 for a new file). Refuses a relative path and an existing file that sideby did not generate, so a
|
|
29
|
+
* mistyped `shellInitFile` can never replace a shell rc file.
|
|
30
|
+
*/
|
|
31
|
+
export declare function writeShellInitFile(shell: Shell, configured: string, home: string, content: string): Promise<ShellInitWrite>;
|