@botdocs/cli 0.3.2 → 0.4.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/README.md +123 -37
- package/dist/commands/backups.d.ts +4 -0
- package/dist/commands/backups.js +291 -0
- package/dist/commands/edit.js +16 -8
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +21 -3
- package/dist/commands/login.d.ts +7 -0
- package/dist/commands/login.js +240 -75
- package/dist/commands/sync.d.ts +16 -0
- package/dist/commands/sync.js +337 -25
- package/dist/commands/team.d.ts +2 -0
- package/dist/commands/team.js +251 -0
- package/dist/commands/undo.d.ts +19 -0
- package/dist/commands/undo.js +88 -0
- package/dist/commands/views/conflict-prompt.d.ts +24 -0
- package/dist/commands/views/conflict-prompt.js +19 -0
- package/dist/commands/views/login-app.d.ts +30 -0
- package/dist/commands/views/login-app.js +57 -0
- package/dist/commands/views/sync-app.d.ts +27 -0
- package/dist/commands/views/sync-app.js +147 -0
- package/dist/commands/views/sync-state.d.ts +84 -0
- package/dist/commands/views/sync-state.js +93 -0
- package/dist/commands/views/theme.d.ts +16 -0
- package/dist/commands/views/theme.js +16 -0
- package/dist/commands/whoami.js +13 -13
- package/dist/index.js +44 -38
- package/dist/lib/api.d.ts +2 -3
- package/dist/lib/api.js +14 -7
- package/dist/lib/auto-detect.js +46 -0
- package/dist/lib/backup.d.ts +121 -0
- package/dist/lib/backup.js +387 -0
- package/dist/lib/canonical.d.ts +1 -1
- package/dist/lib/canonical.js +43 -1
- package/dist/lib/config.d.ts +8 -1
- package/dist/lib/config.js +18 -9
- package/dist/lib/lockfile.d.ts +9 -0
- package/dist/lib/prompts.d.ts +10 -0
- package/dist/lib/prompts.js +36 -12
- package/package.json +27 -7
- package/templates/agents.md +60 -47
- package/templates/ecosystem-prompts/compile-antigravity.md +14 -0
- package/templates/ecosystem-prompts/compile-copilot.md +14 -0
- package/templates/ecosystem-prompts/compile-gemini.md +14 -0
- package/templates/ecosystem-prompts/compile-opencode.md +13 -0
- package/templates/ecosystem-prompts/compile-windsurf.md +13 -0
- package/dist/commands/check-updates.test.d.ts +0 -1
- package/dist/commands/check-updates.test.js +0 -128
- package/dist/commands/clone.d.ts +0 -3
- package/dist/commands/clone.js +0 -70
- package/dist/commands/compile.test.d.ts +0 -1
- package/dist/commands/compile.test.js +0 -110
- package/dist/commands/diff.d.ts +0 -3
- package/dist/commands/diff.js +0 -65
- package/dist/commands/edit.test.d.ts +0 -1
- package/dist/commands/edit.test.js +0 -102
- package/dist/commands/endorse.d.ts +0 -7
- package/dist/commands/endorse.js +0 -70
- package/dist/commands/ingest.test.d.ts +0 -1
- package/dist/commands/ingest.test.js +0 -109
- package/dist/commands/install.test.d.ts +0 -1
- package/dist/commands/install.test.js +0 -253
- package/dist/commands/list.test.d.ts +0 -1
- package/dist/commands/list.test.js +0 -51
- package/dist/commands/publish.test.d.ts +0 -1
- package/dist/commands/publish.test.js +0 -138
- package/dist/commands/pull.d.ts +0 -3
- package/dist/commands/pull.js +0 -78
- package/dist/commands/sync.test.d.ts +0 -1
- package/dist/commands/sync.test.js +0 -263
- package/dist/commands/uninstall.test.d.ts +0 -1
- package/dist/commands/uninstall.test.js +0 -67
- package/dist/lib/auto-detect.test.d.ts +0 -1
- package/dist/lib/auto-detect.test.js +0 -58
- package/dist/lib/canonical.test.d.ts +0 -1
- package/dist/lib/canonical.test.js +0 -48
- package/dist/lib/diff.test.d.ts +0 -1
- package/dist/lib/diff.test.js +0 -28
- package/dist/lib/library-sync.test.d.ts +0 -1
- package/dist/lib/library-sync.test.js +0 -63
- package/dist/lib/llm.test.d.ts +0 -1
- package/dist/lib/llm.test.js +0 -72
- package/dist/lib/lockfile.test.d.ts +0 -1
- package/dist/lib/lockfile.test.js +0 -99
- package/dist/lib/manifest.test.d.ts +0 -1
- package/dist/lib/manifest.test.js +0 -72
- package/dist/lib/shell-hook.test.d.ts +0 -1
- package/dist/lib/shell-hook.test.js +0 -68
- package/dist/test-utils.d.ts +0 -43
- package/dist/test-utils.js +0 -101
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { ApiError, apiFetch } from '../lib/api.js';
|
|
2
|
+
function reportApiError(err) {
|
|
3
|
+
if (err instanceof ApiError) {
|
|
4
|
+
if (err.status === 401) {
|
|
5
|
+
console.error('\n ✗ Not authenticated. Run `botdocs login` first.\n');
|
|
6
|
+
process.exit(1);
|
|
7
|
+
}
|
|
8
|
+
console.error(`\n ✗ ${err.message || `HTTP ${err.status}`}\n`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
throw err;
|
|
12
|
+
}
|
|
13
|
+
function parseSkillRef(raw) {
|
|
14
|
+
const cleaned = raw.startsWith('@') ? raw.slice(1) : raw;
|
|
15
|
+
const parts = cleaned.split('/');
|
|
16
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) {
|
|
17
|
+
throw new Error(`Invalid ref: ${raw} (expected @user/slug)`);
|
|
18
|
+
}
|
|
19
|
+
return { username: parts[0], slug: parts[1] };
|
|
20
|
+
}
|
|
21
|
+
async function teamList(options) {
|
|
22
|
+
let resp;
|
|
23
|
+
try {
|
|
24
|
+
resp = await apiFetch('/api/teams', { auth: true });
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
reportApiError(err);
|
|
28
|
+
}
|
|
29
|
+
if (options.json) {
|
|
30
|
+
console.log(JSON.stringify(resp));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (resp.teams.length === 0) {
|
|
34
|
+
console.log('\n not a member of any teams yet — try `botdocs team create <slug> --name "..."`\n');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
console.log('');
|
|
38
|
+
for (const t of resp.teams) {
|
|
39
|
+
console.log(` ${t.slug} (${t.role}, ${t.memberCount} member${t.memberCount === 1 ? '' : 's'}, ${t.skillCount} skill${t.skillCount === 1 ? '' : 's'})`);
|
|
40
|
+
console.log(` ${t.name}`);
|
|
41
|
+
if (t.description)
|
|
42
|
+
console.log(` ${t.description}`);
|
|
43
|
+
}
|
|
44
|
+
console.log('');
|
|
45
|
+
}
|
|
46
|
+
async function teamCreate(slug, options) {
|
|
47
|
+
if (!options.name) {
|
|
48
|
+
console.error('\n ✗ --name is required\n');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
let resp;
|
|
52
|
+
try {
|
|
53
|
+
resp = await apiFetch('/api/teams', {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
auth: true,
|
|
56
|
+
body: { slug, name: options.name, description: options.description },
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
reportApiError(err);
|
|
61
|
+
}
|
|
62
|
+
if (options.json) {
|
|
63
|
+
console.log(JSON.stringify(resp));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
console.log(`\n ✓ Created team ${resp.team.slug}`);
|
|
67
|
+
console.log(` https://botdocs.ai/teams/${resp.team.slug}\n`);
|
|
68
|
+
}
|
|
69
|
+
async function teamShow(slug, options) {
|
|
70
|
+
let team;
|
|
71
|
+
let members;
|
|
72
|
+
let skills;
|
|
73
|
+
try {
|
|
74
|
+
team = await apiFetch(`/api/teams/${slug}`, { auth: true });
|
|
75
|
+
members = await apiFetch(`/api/teams/${slug}/members`, { auth: true });
|
|
76
|
+
skills = await apiFetch(`/api/teams/${slug}/skills`, { auth: true });
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
reportApiError(err);
|
|
80
|
+
}
|
|
81
|
+
if (options.json) {
|
|
82
|
+
console.log(JSON.stringify({ team: team.team, role: team.role, members: members.members, skills: skills.skills }));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
console.log(`\n ${team.team.slug} (you are ${team.role})`);
|
|
86
|
+
console.log(` ${team.team.name}`);
|
|
87
|
+
if (team.team.description)
|
|
88
|
+
console.log(` ${team.team.description}`);
|
|
89
|
+
console.log(`\n Members (${members.members.length}):`);
|
|
90
|
+
for (const m of members.members) {
|
|
91
|
+
console.log(` @${m.username} (${m.role}) — ${m.displayName}`);
|
|
92
|
+
}
|
|
93
|
+
console.log(`\n Pinned skills (${skills.skills.length}):`);
|
|
94
|
+
if (skills.skills.length === 0) {
|
|
95
|
+
console.log(' (none yet — run `botdocs team push <slug> @user/skill` to pin)');
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
for (const s of skills.skills) {
|
|
99
|
+
const ver = s.versionPin ? `v${s.versionPin} (pinned)` : s.currentVersion ? `v${s.currentVersion}` : 'unpublished';
|
|
100
|
+
const title = s.title ?? '(unpublished)';
|
|
101
|
+
console.log(` @${s.username}/${s.slug} ${ver}`);
|
|
102
|
+
console.log(` ${title}`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
console.log('');
|
|
106
|
+
}
|
|
107
|
+
async function teamAdd(slug, username, options) {
|
|
108
|
+
const role = options.role ? options.role.toUpperCase() : 'WRITE';
|
|
109
|
+
if (!['ADMIN', 'WRITE', 'READ'].includes(role)) {
|
|
110
|
+
console.error('\n ✗ Role must be one of: admin, write, read\n');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
let resp;
|
|
114
|
+
try {
|
|
115
|
+
resp = await apiFetch(`/api/teams/${slug}/members`, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
auth: true,
|
|
118
|
+
body: { username, role },
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
reportApiError(err);
|
|
123
|
+
}
|
|
124
|
+
if (options.json) {
|
|
125
|
+
console.log(JSON.stringify(resp));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
console.log(`\n ✓ Added @${resp.member.username} to ${slug} as ${resp.member.role}\n`);
|
|
129
|
+
}
|
|
130
|
+
async function teamRemove(slug, username, options) {
|
|
131
|
+
// Resolve username → userId via members list (avoids needing a separate
|
|
132
|
+
// /api/users/[username] route).
|
|
133
|
+
let members;
|
|
134
|
+
try {
|
|
135
|
+
members = await apiFetch(`/api/teams/${slug}/members`, { auth: true });
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
reportApiError(err);
|
|
139
|
+
}
|
|
140
|
+
const target = members.members.find((m) => m.username === username);
|
|
141
|
+
if (!target) {
|
|
142
|
+
console.error(`\n ✗ @${username} is not a member of ${slug}\n`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
await apiFetch(`/api/teams/${slug}/members/${target.userId}`, {
|
|
147
|
+
method: 'DELETE',
|
|
148
|
+
auth: true,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
reportApiError(err);
|
|
153
|
+
}
|
|
154
|
+
if (options.json) {
|
|
155
|
+
console.log(JSON.stringify({ removed: { slug, username } }));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
console.log(`\n ✓ Removed @${username} from ${slug}\n`);
|
|
159
|
+
}
|
|
160
|
+
async function teamPush(slug, ref, options) {
|
|
161
|
+
const parsed = parseSkillRef(ref);
|
|
162
|
+
let resp;
|
|
163
|
+
try {
|
|
164
|
+
resp = await apiFetch(`/api/teams/${slug}/skills`, {
|
|
165
|
+
method: 'POST',
|
|
166
|
+
auth: true,
|
|
167
|
+
body: {
|
|
168
|
+
username: parsed.username,
|
|
169
|
+
slug: parsed.slug,
|
|
170
|
+
versionPin: options.version ?? null,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
reportApiError(err);
|
|
176
|
+
}
|
|
177
|
+
if (options.json) {
|
|
178
|
+
console.log(JSON.stringify(resp));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const verNote = resp.skill.versionPin ? ` (pinned to v${resp.skill.versionPin})` : '';
|
|
182
|
+
console.log(`\n ✓ Pinned @${parsed.username}/${parsed.slug} to ${slug}${verNote}\n`);
|
|
183
|
+
}
|
|
184
|
+
async function teamUnpush(slug, ref, options) {
|
|
185
|
+
const parsed = parseSkillRef(ref);
|
|
186
|
+
try {
|
|
187
|
+
await apiFetch(`/api/teams/${slug}/skills/${parsed.username}/${parsed.slug}`, {
|
|
188
|
+
method: 'DELETE',
|
|
189
|
+
auth: true,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
catch (err) {
|
|
193
|
+
reportApiError(err);
|
|
194
|
+
}
|
|
195
|
+
if (options.json) {
|
|
196
|
+
console.log(JSON.stringify({ unpinned: { slug, ref } }));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
console.log(`\n ✓ Unpinned @${parsed.username}/${parsed.slug} from ${slug}\n`);
|
|
200
|
+
}
|
|
201
|
+
export function registerTeamCommands(program) {
|
|
202
|
+
const team = program
|
|
203
|
+
.command('team')
|
|
204
|
+
.description('Manage teams: shared skill libraries for your org');
|
|
205
|
+
team
|
|
206
|
+
.command('list')
|
|
207
|
+
.description('List teams you are a member of')
|
|
208
|
+
.action(async () => {
|
|
209
|
+
await teamList({ json: program.opts().json });
|
|
210
|
+
});
|
|
211
|
+
team
|
|
212
|
+
.command('create <slug>')
|
|
213
|
+
.description('Create a new team (you become its first ADMIN)')
|
|
214
|
+
.requiredOption('--name <name>', 'Display name for the team')
|
|
215
|
+
.option('--description <description>', 'Optional description')
|
|
216
|
+
.action(async (slug, opts) => {
|
|
217
|
+
await teamCreate(slug, { ...opts, json: program.opts().json });
|
|
218
|
+
});
|
|
219
|
+
team
|
|
220
|
+
.command('show <slug>')
|
|
221
|
+
.description('Show a team — info, members, and pinned skills')
|
|
222
|
+
.action(async (slug) => {
|
|
223
|
+
await teamShow(slug, { json: program.opts().json });
|
|
224
|
+
});
|
|
225
|
+
team
|
|
226
|
+
.command('add <slug> <username>')
|
|
227
|
+
.description('Add a user to a team by username (ADMIN only)')
|
|
228
|
+
.option('--role <role>', 'Role: admin, write, or read', 'write')
|
|
229
|
+
.action(async (slug, username, opts) => {
|
|
230
|
+
await teamAdd(slug, username, { ...opts, json: program.opts().json });
|
|
231
|
+
});
|
|
232
|
+
team
|
|
233
|
+
.command('remove <slug> <username>')
|
|
234
|
+
.description('Remove a user from a team (ADMIN only; or self-leave)')
|
|
235
|
+
.action(async (slug, username) => {
|
|
236
|
+
await teamRemove(slug, username, { json: program.opts().json });
|
|
237
|
+
});
|
|
238
|
+
team
|
|
239
|
+
.command('push <slug> <ref>')
|
|
240
|
+
.description('Pin a published skill to a team (WRITE+ only)')
|
|
241
|
+
.option('--version <v>', 'Pin to a specific version (omit to float to latest)')
|
|
242
|
+
.action(async (slug, ref, opts) => {
|
|
243
|
+
await teamPush(slug, ref, { ...opts, json: program.opts().json });
|
|
244
|
+
});
|
|
245
|
+
team
|
|
246
|
+
.command('unpush <slug> <ref>')
|
|
247
|
+
.description('Unpin a skill from a team (WRITE+ only)')
|
|
248
|
+
.action(async (slug, ref) => {
|
|
249
|
+
await teamUnpush(slug, ref, { json: program.opts().json });
|
|
250
|
+
});
|
|
251
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface UndoOptions {
|
|
2
|
+
/** Show the plan but don't write. */
|
|
3
|
+
dryRun?: boolean;
|
|
4
|
+
/** Skip the confirmation prompt. */
|
|
5
|
+
yes?: boolean;
|
|
6
|
+
/** Don't back up the current state before restoring (advanced). */
|
|
7
|
+
noBackup?: boolean;
|
|
8
|
+
/** Output JSON instead of human-readable text. */
|
|
9
|
+
json?: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Restore the most recent backup run.
|
|
12
|
+
*
|
|
13
|
+
* - Finds the newest run via `listBackupRuns`. If there are none, prints a
|
|
14
|
+
* friendly "nothing to undo" and exits.
|
|
15
|
+
* - Prints a summary of what will be restored, including the note that the
|
|
16
|
+
* current state will itself be backed up first (so undo is reversible).
|
|
17
|
+
* - Prompts y/N unless `--yes`. On cancel, exits gracefully.
|
|
18
|
+
* - On confirm, calls `restoreBackup` and prints the result. */
|
|
19
|
+
export declare function undo(options?: UndoOptions): Promise<void>;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import * as p from '@clack/prompts';
|
|
3
|
+
import { listBackupRuns, listBackupFiles, restoreBackup, } from '../lib/backup.js';
|
|
4
|
+
/** Restore the most recent backup run.
|
|
5
|
+
*
|
|
6
|
+
* - Finds the newest run via `listBackupRuns`. If there are none, prints a
|
|
7
|
+
* friendly "nothing to undo" and exits.
|
|
8
|
+
* - Prints a summary of what will be restored, including the note that the
|
|
9
|
+
* current state will itself be backed up first (so undo is reversible).
|
|
10
|
+
* - Prompts y/N unless `--yes`. On cancel, exits gracefully.
|
|
11
|
+
* - On confirm, calls `restoreBackup` and prints the result. */
|
|
12
|
+
export async function undo(options = {}) {
|
|
13
|
+
const projectRoot = process.cwd();
|
|
14
|
+
const runs = listBackupRuns(projectRoot);
|
|
15
|
+
if (runs.length === 0) {
|
|
16
|
+
if (options.json) {
|
|
17
|
+
console.log(JSON.stringify({ undone: null, message: 'No backups to undo.' }));
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
console.log('\n No backups to undo.\n');
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const latest = runs[0];
|
|
25
|
+
const entries = listBackupFiles(latest.runTimestamp, projectRoot);
|
|
26
|
+
if (!options.json) {
|
|
27
|
+
const verb = options.dryRun ? 'Would restore' : 'Restore';
|
|
28
|
+
console.log(`\n ${verb} backup run: ${latest.runTimestamp}`);
|
|
29
|
+
console.log(` Files: ${entries.length}\n`);
|
|
30
|
+
for (const e of entries) {
|
|
31
|
+
const rel = path.relative(projectRoot, e.originalPath) || e.originalPath;
|
|
32
|
+
console.log(` ${rel}`);
|
|
33
|
+
}
|
|
34
|
+
if (!options.noBackup && !options.dryRun) {
|
|
35
|
+
console.log('\n The current files at these paths will themselves be backed up first');
|
|
36
|
+
console.log(' (you can `botdocs undo` again to swap back).');
|
|
37
|
+
}
|
|
38
|
+
console.log('');
|
|
39
|
+
}
|
|
40
|
+
if (!options.dryRun && !options.yes && !options.json) {
|
|
41
|
+
const confirmed = await p.confirm({
|
|
42
|
+
message: `Restore ${entries.length} file(s) from ${latest.runTimestamp}?`,
|
|
43
|
+
initialValue: false,
|
|
44
|
+
});
|
|
45
|
+
// Clack returns a cancel sentinel on Ctrl-C; treat that as "cancelled" too.
|
|
46
|
+
if (p.isCancel(confirmed) || !confirmed) {
|
|
47
|
+
console.log(' Cancelled.\n');
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const result = restoreBackup(latest.runTimestamp, projectRoot, {
|
|
52
|
+
dryRun: options.dryRun,
|
|
53
|
+
noBackup: options.noBackup,
|
|
54
|
+
});
|
|
55
|
+
if (options.json) {
|
|
56
|
+
console.log(JSON.stringify({
|
|
57
|
+
runTimestamp: latest.runTimestamp,
|
|
58
|
+
dryRun: options.dryRun ?? false,
|
|
59
|
+
restored: result.restored.map((e) => e.originalPath),
|
|
60
|
+
failed: result.failed.map((f) => ({
|
|
61
|
+
originalPath: f.entry.originalPath,
|
|
62
|
+
error: f.error,
|
|
63
|
+
})),
|
|
64
|
+
preBackedUp: result.preBackedUp,
|
|
65
|
+
}));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
reportRestoreResult(result, projectRoot, options.dryRun ?? false);
|
|
69
|
+
}
|
|
70
|
+
function reportRestoreResult(result, projectRoot, dryRun) {
|
|
71
|
+
const verb = dryRun ? 'Would restore' : 'Restored';
|
|
72
|
+
console.log(` ✓ ${verb} ${result.restored.length} file(s)`);
|
|
73
|
+
for (const e of result.restored) {
|
|
74
|
+
const rel = path.relative(projectRoot, e.originalPath) || e.originalPath;
|
|
75
|
+
console.log(` ${rel}`);
|
|
76
|
+
}
|
|
77
|
+
if (result.failed.length > 0) {
|
|
78
|
+
console.log(`\n ⚠ ${result.failed.length} file(s) failed:`);
|
|
79
|
+
for (const f of result.failed) {
|
|
80
|
+
const rel = path.relative(projectRoot, f.entry.originalPath) || f.entry.originalPath;
|
|
81
|
+
console.log(` ${rel} (${f.error})`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!dryRun && result.preBackedUp.length > 0) {
|
|
85
|
+
console.log(`\n Pre-backed-up ${result.preBackedUp.length} file(s) — run \`botdocs undo\` again to swap back.`);
|
|
86
|
+
}
|
|
87
|
+
console.log('');
|
|
88
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { SyncFileRow } from './sync-state.js';
|
|
3
|
+
/** The three resolutions available on a conflict. Mirrors `ConflictChoice` in
|
|
4
|
+
* `commands/sync.ts` — kept in sync by hand because pulling that type would
|
|
5
|
+
* create a circular dep between the views directory and its parent. */
|
|
6
|
+
export type ConflictChoiceValue = 'keep' | 'overwrite' | 'skip';
|
|
7
|
+
export interface ConflictPromptProps {
|
|
8
|
+
/** The row whose conflict is being resolved. We render the ref + details
|
|
9
|
+
* line above the select so the user keeps context for what they're deciding. */
|
|
10
|
+
row: SyncFileRow;
|
|
11
|
+
/** Fired when the user picks one of the three options. The caller is
|
|
12
|
+
* responsible for closing the prompt (typically by dispatching the row to
|
|
13
|
+
* its post-choice state and clearing the conflict resolver). */
|
|
14
|
+
onChoice: (choice: ConflictChoiceValue) => void;
|
|
15
|
+
}
|
|
16
|
+
/** Inline conflict prompt rendered "under" a conflict row in the live sync
|
|
17
|
+
* view. Three options match the runner's `ConflictChoice` union — `keep` and
|
|
18
|
+
* `skip` are semantically identical today but kept distinct for forward
|
|
19
|
+
* compatibility (and so the UI can label them differently).
|
|
20
|
+
*
|
|
21
|
+
* The component is intentionally dumb: it owns no state, no I/O. The Ink view
|
|
22
|
+
* mounts it when a row enters `conflict` with a resolver attached, and the
|
|
23
|
+
* resolver fires when the user picks. */
|
|
24
|
+
export declare function ConflictPrompt({ row, onChoice }: ConflictPromptProps): React.ReactElement;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import SelectInput from 'ink-select-input';
|
|
4
|
+
import { theme } from './theme.js';
|
|
5
|
+
/** Inline conflict prompt rendered "under" a conflict row in the live sync
|
|
6
|
+
* view. Three options match the runner's `ConflictChoice` union — `keep` and
|
|
7
|
+
* `skip` are semantically identical today but kept distinct for forward
|
|
8
|
+
* compatibility (and so the UI can label them differently).
|
|
9
|
+
*
|
|
10
|
+
* The component is intentionally dumb: it owns no state, no I/O. The Ink view
|
|
11
|
+
* mounts it when a row enters `conflict` with a resolver attached, and the
|
|
12
|
+
* resolver fires when the user picks. */
|
|
13
|
+
export function ConflictPrompt({ row, onChoice }) {
|
|
14
|
+
return (_jsxs(Box, { flexDirection: "column", marginLeft: 2, marginTop: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: theme.amber, children: "\u26A0 " }), _jsx(Text, { children: row.ref }), row.details ? (_jsxs(_Fragment, { children: [_jsx(Text, { color: theme.zinc, children: " \u2014 " }), _jsx(Text, { children: row.details })] })) : null] }), _jsx(Text, { color: theme.zinc, children: " Local edits detected. What should we do?" }), _jsx(Box, { marginLeft: 2, marginTop: 1, children: _jsx(SelectInput, { items: [
|
|
15
|
+
{ key: 'k', label: 'Keep my local version', value: 'keep' },
|
|
16
|
+
{ key: 'o', label: 'Overwrite with new version', value: 'overwrite' },
|
|
17
|
+
{ key: 's', label: 'Skip and continue', value: 'skip' },
|
|
18
|
+
], onSelect: (item) => onChoice(item.value) }) })] }));
|
|
19
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
/** All visible states the login flow can be in. The component is a thin
|
|
3
|
+
* renderer over this status — the actual work (init, polling, save) is driven
|
|
4
|
+
* by callbacks from the parent so the Ink view stays I/O-free for testing. */
|
|
5
|
+
export type LoginStatus = 'initializing' | 'browser-opening' | 'polling' | 'success' | 'expired' | 'error';
|
|
6
|
+
export interface LoginAppProps {
|
|
7
|
+
/** Current visible state. Parent updates this as the auth flow progresses. */
|
|
8
|
+
status: LoginStatus;
|
|
9
|
+
/** Full URL to display (only relevant during 'polling'). */
|
|
10
|
+
authUrl?: string;
|
|
11
|
+
/** Last 6 hex chars of the state — printed as a "Confirm the suffix matches
|
|
12
|
+
* …xxxxxx" hint so the user can verify the right page in their browser. */
|
|
13
|
+
stateTail?: string;
|
|
14
|
+
/** Resolved username after success. Used for the celebratory "Signed in as
|
|
15
|
+
* @username" line. */
|
|
16
|
+
username?: string;
|
|
17
|
+
/** Free-form error message for `status === 'error'`. */
|
|
18
|
+
errorMessage?: string;
|
|
19
|
+
/** When sync-library was requested, surface a follow-up hint after success. */
|
|
20
|
+
syncLibrary?: boolean;
|
|
21
|
+
/** Terminal width — used to decide whether the BigText wordmark fits. Inject
|
|
22
|
+
* here (instead of reading at module load) so tests can render at a fixed
|
|
23
|
+
* width. */
|
|
24
|
+
columns?: number;
|
|
25
|
+
}
|
|
26
|
+
/** Live Ink view for `botdocs login`. The component is a pure function of
|
|
27
|
+
* `status`; the parent owns the polling timer and dispatches status changes
|
|
28
|
+
* via props. On `success`, `expired`, or `error` the parent should call
|
|
29
|
+
* `unmount()` from `render(...)` shortly after the user has seen the result. */
|
|
30
|
+
export declare function LoginApp(props: LoginAppProps): React.ReactElement;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Box, Text, useApp } from 'ink';
|
|
4
|
+
import Spinner from 'ink-spinner';
|
|
5
|
+
import Gradient from 'ink-gradient';
|
|
6
|
+
import BigText from 'ink-big-text';
|
|
7
|
+
import { theme, BIG_TEXT_MIN_COLS } from './theme.js';
|
|
8
|
+
/** Brand wordmark. Falls back to a bold plain "botdocs" on narrow terminals
|
|
9
|
+
* where ASCII art would wrap. The `tiny` font keeps the cap height low so the
|
|
10
|
+
* screen stays scannable. */
|
|
11
|
+
function BrandMark({ columns }) {
|
|
12
|
+
if (columns < BIG_TEXT_MIN_COLS) {
|
|
13
|
+
return (_jsx(Text, { bold: true, color: theme.cyan, children: "botdocs" }));
|
|
14
|
+
}
|
|
15
|
+
return (_jsx(Gradient, { colors: [theme.cyan, theme.violet], children: _jsx(BigText, { text: "botdocs", font: "tiny" }) }));
|
|
16
|
+
}
|
|
17
|
+
/** Single source of truth for active-state lines. Returns a label + spinner
|
|
18
|
+
* pair the renderer can drop into the layout — keeps the state-string mapping
|
|
19
|
+
* in one place. */
|
|
20
|
+
function ActiveLine({ status }) {
|
|
21
|
+
switch (status) {
|
|
22
|
+
case 'initializing':
|
|
23
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.cyan, children: _jsx(Spinner, { type: "dots" }) }), ' ', _jsx(Text, { children: "Generating session..." })] }));
|
|
24
|
+
case 'browser-opening':
|
|
25
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.cyan, children: _jsx(Spinner, { type: "dots" }) }), ' ', _jsx(Text, { children: "Opening your browser..." })] }));
|
|
26
|
+
case 'polling':
|
|
27
|
+
return (_jsxs(Text, { children: [_jsx(Text, { color: theme.cyan, children: _jsx(Spinner, { type: "dots" }) }), ' ', _jsx(Text, { children: "Waiting for authorization..." })] }));
|
|
28
|
+
default:
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** Live Ink view for `botdocs login`. The component is a pure function of
|
|
33
|
+
* `status`; the parent owns the polling timer and dispatches status changes
|
|
34
|
+
* via props. On `success`, `expired`, or `error` the parent should call
|
|
35
|
+
* `unmount()` from `render(...)` shortly after the user has seen the result. */
|
|
36
|
+
export function LoginApp(props) {
|
|
37
|
+
const { status, authUrl, stateTail, username, errorMessage, syncLibrary, columns } = props;
|
|
38
|
+
const { exit } = useApp();
|
|
39
|
+
// Terminal width — fall back to a sensible default for non-TTY paths or
|
|
40
|
+
// when stdout doesn't expose `columns` yet. The component only branches on
|
|
41
|
+
// this to choose between the BigText wordmark and the plain fallback.
|
|
42
|
+
const [cols] = useState(columns ?? process.stdout.columns ?? 80);
|
|
43
|
+
// Auto-exit after a terminal state. The small delay on `success` gives the
|
|
44
|
+
// user a moment to read the celebratory line before the screen unmounts.
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (status === 'success') {
|
|
47
|
+
const timer = setTimeout(() => exit(), 800);
|
|
48
|
+
return () => clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
if (status === 'expired' || status === 'error') {
|
|
51
|
+
const timer = setTimeout(() => exit(), 50);
|
|
52
|
+
return () => clearTimeout(timer);
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}, [status, exit]);
|
|
56
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsx(BrandMark, { columns: cols }), _jsx(Box, { marginTop: 1, children: _jsx(ActiveLine, { status: status }) }), status === 'polling' && authUrl ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: theme.zinc, children: "Authorize this session at:" }), _jsx(Text, { children: authUrl }), stateTail ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.zinc, children: ["Confirm the suffix matches: \u2026", stateTail] }) })) : null, _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.zinc, children: "State expires in 10 minutes \u2014 press Ctrl-C to abort." }) })] })) : null, status === 'success' && username ? (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.green, children: ["\u2713 Signed in as @", username] }), syncLibrary ? (_jsx(Text, { color: theme.zinc, children: "Library sync enabled \u2014 your installed-refs will appear at https://botdocs.ai/library." })) : (_jsx(Text, { color: theme.zinc, children: "Library sync is OFF. Re-run with --sync-library to enable the personalized Library page." }))] })) : null, status === 'expired' ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.amber, children: "Authorization expired \u2014 re-run `botdocs login`." }) })) : null, status === 'error' ? (_jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.red, children: ["\u2717 ", errorMessage ?? 'Login failed.'] }) })) : null] }));
|
|
57
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { type SyncAction } from './sync-state.js';
|
|
3
|
+
import { type ConflictChoiceValue } from './conflict-prompt.js';
|
|
4
|
+
/** The dependencies the runner receives from the Ink view. `dispatch` updates
|
|
5
|
+
* the reducer state; `awaitConflictChoice` returns a Promise that resolves
|
|
6
|
+
* when the inline prompt fires. */
|
|
7
|
+
export interface SyncDeps {
|
|
8
|
+
dispatch: (action: SyncAction) => void;
|
|
9
|
+
awaitConflictChoice: (ref: string, file: string) => Promise<ConflictChoiceValue>;
|
|
10
|
+
}
|
|
11
|
+
export interface SyncAppProps {
|
|
12
|
+
/** Pre-known rows to seed the queued state. Team rows are added later via
|
|
13
|
+
* `ADD_ROW` once `/api/cli/teams` returns — we don't have them at mount time. */
|
|
14
|
+
initialRows: Array<{
|
|
15
|
+
ref: string;
|
|
16
|
+
team?: string;
|
|
17
|
+
}>;
|
|
18
|
+
/** The async work to run on mount. Receives the dispatch/awaiter pair and
|
|
19
|
+
* is expected to dispatch FINISH when done. The view auto-unmounts ~600ms
|
|
20
|
+
* after FINISH so the user can read the summary. */
|
|
21
|
+
runSync: (deps: SyncDeps) => Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
/** Live Ink view for `botdocs sync`. Owns the reducer state and the conflict
|
|
24
|
+
* resolver Map. On mount it kicks off `runSync` with a dispatch/awaiter pair —
|
|
25
|
+
* the awaiter parks a Promise resolver keyed by `ref|file` in a ref-held Map,
|
|
26
|
+
* and the inline `<ConflictPrompt />` resolves it when the user picks. */
|
|
27
|
+
export declare function SyncApp({ initialRows, runSync }: SyncAppProps): React.ReactElement;
|