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.
- package/LICENSE +21 -0
- package/README.md +453 -2
- package/README.zh-CN.md +455 -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 +37 -0
- package/dist/src/cli/commands.js +487 -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 +224 -0
- package/dist/src/cli/main.d.ts +2 -0
- package/dist/src/cli/main.js +186 -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 +18 -0
- package/dist/src/core/accounts.js +116 -0
- package/dist/src/core/config.d.ts +60 -0
- package/dist/src/core/config.js +181 -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 +35 -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/secret-file.d.ts +30 -0
- package/dist/src/core/secret-file.js +116 -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 +25 -0
- package/dist/src/core/shell-init.js +61 -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 +124 -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 +67 -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 +78 -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/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 +93 -0
- package/dist/src/panel/handler.js +411 -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 +149 -0
- package/dist/src/runtime.js +371 -0
- package/dist/src/types.d.ts +278 -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 +117 -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 +166 -0
- package/schemas/doctor.json +229 -0
- package/schemas/error.json +25 -0
- package/schemas/list.json +177 -0
- package/schemas/new.json +181 -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 +52 -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
|
@@ -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,25 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* The script for one shell, ending in a newline: exactly what `sideby shell-init <shell>` prints. zsh and bash
|
|
8
|
+
* read the same function syntax, so the shell only names the file it goes to.
|
|
9
|
+
*/
|
|
10
|
+
export declare function shellInitScript(accounts: readonly Account[], aliases: Readonly<Record<string, unknown>> | undefined): string;
|
|
11
|
+
export interface ShellInitWrite {
|
|
12
|
+
shell: Shell;
|
|
13
|
+
/** The configured path, as written in the config (`~/…` kept). */
|
|
14
|
+
path: string;
|
|
15
|
+
ok: boolean;
|
|
16
|
+
/** `written`, or `unchanged` when the file already had this content. */
|
|
17
|
+
action?: 'written' | 'unchanged';
|
|
18
|
+
message?: string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Writes `content` to the configured file atomically (temp file in the same directory, then rename), keeping its
|
|
22
|
+
* mode (0644 for a new file). Refuses a relative path and an existing file that sideby did not generate, so a
|
|
23
|
+
* mistyped `shellInitFile` can never replace a shell rc file.
|
|
24
|
+
*/
|
|
25
|
+
export declare function writeShellInitFile(shell: Shell, configured: string, home: string, content: string): Promise<ShellInitWrite>;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// The shell functions `sideby shell-init` prints, and the optional files (config `shellInitFile`) sideby keeps
|
|
2
|
+
// in sync with them whenever it adds an Account or an alias.
|
|
3
|
+
import { mkdir, readFile } from 'node:fs/promises';
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { aliasProblem } from './config.js';
|
|
6
|
+
import { realpathOrNull, statOrNull, writeFileAtomic } from './fs-safe.js';
|
|
7
|
+
import { expandUserPath } from './paths.js';
|
|
8
|
+
export const SHELLS = ['zsh', 'bash'];
|
|
9
|
+
/** First line of every generated script; an existing file without it is never overwritten. */
|
|
10
|
+
export const SHELL_INIT_HEADER = '# sideby shell-init: one function per account, plus aliases from the sideby config';
|
|
11
|
+
const quote = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
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 function shellInitScript(accounts, aliases) {
|
|
17
|
+
const lines = [SHELL_INIT_HEADER];
|
|
18
|
+
for (const acc of accounts)
|
|
19
|
+
lines.push(`sideby-${acc.family}-${acc.name}() { command sideby run ${quote(acc.ref)} -- "$@"; }`);
|
|
20
|
+
// An alias function runs the alias itself, so `sideby run` reads its Account and arguments from the config.
|
|
21
|
+
for (const alias of Object.keys(aliases ?? {}))
|
|
22
|
+
if (!aliasProblem(alias))
|
|
23
|
+
lines.push(`${alias}() { command sideby run ${quote(alias)} -- "$@"; }`);
|
|
24
|
+
return `${lines.join('\n')}\n`;
|
|
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 async function writeShellInitFile(shell, configured, home, content) {
|
|
32
|
+
const base = { shell, path: configured };
|
|
33
|
+
const expanded = expandUserPath(configured, home);
|
|
34
|
+
if (!expanded)
|
|
35
|
+
return { ...base, ok: false, message: `shellInitFile.${shell} must be an absolute or ~/ path` };
|
|
36
|
+
try {
|
|
37
|
+
// Follow a link (a dotfiles manager may link the file) and replace the real file.
|
|
38
|
+
const target = (await realpathOrNull(expanded)) ?? expanded;
|
|
39
|
+
const st = await statOrNull(target);
|
|
40
|
+
if (st && !st.isFile())
|
|
41
|
+
return { ...base, ok: false, message: `${configured} is not a regular file` };
|
|
42
|
+
if (st) {
|
|
43
|
+
const current = await readFile(target, 'utf8');
|
|
44
|
+
if (current === content)
|
|
45
|
+
return { ...base, ok: true, action: 'unchanged' };
|
|
46
|
+
if (current.trim() && !current.startsWith(SHELL_INIT_HEADER))
|
|
47
|
+
return {
|
|
48
|
+
...base,
|
|
49
|
+
ok: false,
|
|
50
|
+
message: `${configured} was not written by sideby shell-init; move it aside or point shellInitFile.${shell} at another file`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
else
|
|
54
|
+
await mkdir(dirname(target), { recursive: true });
|
|
55
|
+
await writeFileAtomic(target, content, st ? st.mode & 0o777 : 0o644);
|
|
56
|
+
return { ...base, ok: true, action: 'written' };
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
return { ...base, ok: false, message: `cannot write ${configured}: ${err.message}` };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FamilyDef, Finding } from '../types.ts';
|
|
2
|
+
export declare const SKILL_INSTALL = "npx skills add Adonis0123/sideby -g";
|
|
3
|
+
/** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
|
|
4
|
+
export declare function skillVersionOf(text: string): string | null;
|
|
5
|
+
/**
|
|
6
|
+
* Checks, for each given Family, the sideby skill its Host reads: `<main>/skills/sideby`, and for Codex also
|
|
7
|
+
* `~/.agents/skills/sideby` (where `npx skills add -g` installs and Codex reads user skills). A file is read
|
|
8
|
+
* once per Family, so the result for one Family never depends on which others are checked with it. Findings
|
|
9
|
+
* are general and belong to that Family's Main Account.
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkInstalledSkills(home: string, families: readonly FamilyDef[], version: string): Promise<Finding[]>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Doctor's look at an installed sideby Agent Skill (spec §3.11). Read-only: a skill written for another
|
|
2
|
+
// sideby version teaches commands this CLI may not have, so a version mismatch is a warning with the
|
|
3
|
+
// reinstall command. Not installed is not a problem.
|
|
4
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { mainDirOf } from './accounts.js';
|
|
7
|
+
import { tildify } from './paths.js';
|
|
8
|
+
export const SKILL_INSTALL = 'npx skills add Adonis0123/sideby -g';
|
|
9
|
+
/** `metadata.version` from a SKILL.md frontmatter, or null when there is none. */
|
|
10
|
+
export function skillVersionOf(text) {
|
|
11
|
+
const front = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text)?.[1];
|
|
12
|
+
const meta = front && /^metadata:[ \t]*\r?\n((?:[ \t]+.*(?:\r?\n|$))*)/m.exec(front)?.[1];
|
|
13
|
+
return (meta && /^[ \t]+version:[ \t]*['"]?([^'"\s]+)/m.exec(meta)?.[1]) || null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Checks, for each given Family, the sideby skill its Host reads: `<main>/skills/sideby`, and for Codex also
|
|
17
|
+
* `~/.agents/skills/sideby` (where `npx skills add -g` installs and Codex reads user skills). A file is read
|
|
18
|
+
* once per Family, so the result for one Family never depends on which others are checked with it. Findings
|
|
19
|
+
* are general and belong to that Family's Main Account.
|
|
20
|
+
*/
|
|
21
|
+
export async function checkInstalledSkills(home, families, version) {
|
|
22
|
+
const places = families.flatMap((f) => [
|
|
23
|
+
{ family: f, path: join(mainDirOf(f, home), 'skills', 'sideby', 'SKILL.md') },
|
|
24
|
+
...(f.id === 'codex' ? [{ family: f, path: join(home, '.agents', 'skills', 'sideby', 'SKILL.md') }] : []),
|
|
25
|
+
]);
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const out = [];
|
|
28
|
+
for (const { family, path } of places) {
|
|
29
|
+
let real;
|
|
30
|
+
let text;
|
|
31
|
+
try {
|
|
32
|
+
real = await realpath(path);
|
|
33
|
+
if (seen.has(`${family.id}\0${real}`))
|
|
34
|
+
continue;
|
|
35
|
+
text = await readFile(real, 'utf8');
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
seen.add(`${family.id}\0${real}`);
|
|
41
|
+
const found = skillVersionOf(text);
|
|
42
|
+
if (found === version)
|
|
43
|
+
continue;
|
|
44
|
+
const where = tildify(path, home);
|
|
45
|
+
out.push({
|
|
46
|
+
level: 'warn',
|
|
47
|
+
account: `${family.id}:main`,
|
|
48
|
+
item: where,
|
|
49
|
+
code: 'skill.outdated',
|
|
50
|
+
message: `the sideby skill at ${where} is ${found ? `for sideby ${found}` : 'from before skills had a version'}; this sideby is ${version}`,
|
|
51
|
+
hint: `update it with \`${SKILL_INSTALL}\`, so agents learn this version's commands`,
|
|
52
|
+
source: 'core',
|
|
53
|
+
fixable: false,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface DailyTokens {
|
|
2
|
+
/** Local calendar date, `YYYY-MM-DD`. */
|
|
3
|
+
date: string;
|
|
4
|
+
totalTokens: number;
|
|
5
|
+
}
|
|
6
|
+
/** Local calendar date of a time, `YYYY-MM-DD`. */
|
|
7
|
+
export declare function localDate(at: number | Date): string;
|
|
8
|
+
/**
|
|
9
|
+
* `days` buckets ending with the local day of `now`. `add` drops times outside those days, so the buckets can
|
|
10
|
+
* sum to less than a reader's rolling-window total (the oldest partial day falls outside them).
|
|
11
|
+
*/
|
|
12
|
+
export declare function dailyBuckets(now: Date, days?: number): {
|
|
13
|
+
daily: DailyTokens[];
|
|
14
|
+
add(at: number, tokens: number): void;
|
|
15
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Per-day Usage buckets shared by the built-in readers: local calendar dates, oldest first, zero-filled.
|
|
2
|
+
import { USAGE_DAYS } from './quota-levels.js';
|
|
3
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
4
|
+
/** Local calendar date of a time, `YYYY-MM-DD`. */
|
|
5
|
+
export function localDate(at) {
|
|
6
|
+
const d = at instanceof Date ? at : new Date(at);
|
|
7
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* `days` buckets ending with the local day of `now`. `add` drops times outside those days, so the buckets can
|
|
11
|
+
* sum to less than a reader's rolling-window total (the oldest partial day falls outside them).
|
|
12
|
+
*/
|
|
13
|
+
export function dailyBuckets(now, days = USAGE_DAYS) {
|
|
14
|
+
const daily = [];
|
|
15
|
+
const index = new Map();
|
|
16
|
+
for (let i = days - 1; i >= 0; i--) {
|
|
17
|
+
// Built from calendar parts, so a daylight-saving day of 23 or 25 hours still yields one entry per date.
|
|
18
|
+
const date = localDate(new Date(now.getFullYear(), now.getMonth(), now.getDate() - i));
|
|
19
|
+
const bucket = { date, totalTokens: 0 };
|
|
20
|
+
daily.push(bucket);
|
|
21
|
+
index.set(date, bucket);
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
daily,
|
|
25
|
+
add(at, tokens) {
|
|
26
|
+
if (!Number.isFinite(at) || tokens <= 0)
|
|
27
|
+
return;
|
|
28
|
+
const bucket = index.get(localDate(at));
|
|
29
|
+
if (bucket)
|
|
30
|
+
bucket.totalTokens += tokens;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** The sideby package version, read once from package.json (two levels up from src/, three from dist/src/). */
|
|
2
|
+
export declare function packageVersion(): string;
|
|
3
|
+
/**
|
|
4
|
+
* Identity of the code that is running: the version plus the id `pnpm build` writes to `dist/build.json`, so a
|
|
5
|
+
* rebuilt or reinstalled package differs even when its version does not. Long-running Panels compare it to
|
|
6
|
+
* notice new code. From source there is no build file (`<version>+source`). `SIDEBY_BUILD_ID` overrides the
|
|
7
|
+
* id for tests.
|
|
8
|
+
*/
|
|
9
|
+
export declare function buildId(env?: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
let cached;
|
|
3
|
+
/** The sideby package version, read once from package.json (two levels up from src/, three from dist/src/). */
|
|
4
|
+
export function packageVersion() {
|
|
5
|
+
if (cached)
|
|
6
|
+
return cached;
|
|
7
|
+
for (const rel of ['../../package.json', '../../../package.json']) {
|
|
8
|
+
try {
|
|
9
|
+
const pkg = JSON.parse(readFileSync(new URL(rel, import.meta.url), 'utf8'));
|
|
10
|
+
if (pkg.name === 'sideby' && pkg.version) {
|
|
11
|
+
cached = pkg.version;
|
|
12
|
+
return cached;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
// try the next layout
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
cached = '0.0.0';
|
|
20
|
+
return cached;
|
|
21
|
+
}
|
|
22
|
+
let cachedBuild;
|
|
23
|
+
/**
|
|
24
|
+
* Identity of the code that is running: the version plus the id `pnpm build` writes to `dist/build.json`, so a
|
|
25
|
+
* rebuilt or reinstalled package differs even when its version does not. Long-running Panels compare it to
|
|
26
|
+
* notice new code. From source there is no build file (`<version>+source`). `SIDEBY_BUILD_ID` overrides the
|
|
27
|
+
* id for tests.
|
|
28
|
+
*/
|
|
29
|
+
export function buildId(env = process.env) {
|
|
30
|
+
if (env.SIDEBY_BUILD_ID)
|
|
31
|
+
return `${packageVersion()}+${env.SIDEBY_BUILD_ID}`;
|
|
32
|
+
if (cachedBuild)
|
|
33
|
+
return cachedBuild;
|
|
34
|
+
let id = 'source';
|
|
35
|
+
try {
|
|
36
|
+
// dist/src/core/version.js → dist/build.json; from src/ the file does not exist.
|
|
37
|
+
const data = JSON.parse(readFileSync(new URL('../../build.json', import.meta.url), 'utf8'));
|
|
38
|
+
if (typeof data.id === 'string' && data.id)
|
|
39
|
+
id = data.id;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// running from source
|
|
43
|
+
}
|
|
44
|
+
cachedBuild = `${packageVersion()}+${id}`;
|
|
45
|
+
return cachedBuild;
|
|
46
|
+
}
|