@botdocs/cli 0.3.2 → 0.5.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 +145 -36
- 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/ingest.d.ts +2 -0
- package/dist/commands/ingest.js +162 -28
- package/dist/commands/install.d.ts +4 -0
- package/dist/commands/install.js +40 -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 +46 -39
- 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
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { loadLockfile, saveLockfile, upsertInstall, removeInstall, fingerprintFile, fingerprintContent, } from './lockfile.js';
|
|
6
|
-
describe('lockfile', () => {
|
|
7
|
-
const origHome = os.homedir;
|
|
8
|
-
let tmp;
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lockfile-test-'));
|
|
11
|
-
os.homedir = () => tmp;
|
|
12
|
-
});
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
os.homedir = origHome;
|
|
15
|
-
fs.rmSync(tmp, { recursive: true, force: true });
|
|
16
|
-
});
|
|
17
|
-
it('loadLockfile returns empty when file does not exist', () => {
|
|
18
|
-
const lf = loadLockfile();
|
|
19
|
-
expect(lf).toEqual({ version: 1, installs: [] });
|
|
20
|
-
});
|
|
21
|
-
it('saveLockfile writes a valid JSON file', () => {
|
|
22
|
-
const lf = { version: 1, installs: [{ ref: '@a/b', type: 'SKILL', version: '1.0.0', installedAt: '2026-04-29T00:00:00Z', files: [] }] };
|
|
23
|
-
saveLockfile(lf);
|
|
24
|
-
const read = loadLockfile();
|
|
25
|
-
expect(read.installs).toHaveLength(1);
|
|
26
|
-
expect(read.installs[0].ref).toBe('@a/b');
|
|
27
|
-
});
|
|
28
|
-
it('upsertInstall replaces an existing entry with the same ref', () => {
|
|
29
|
-
saveLockfile({
|
|
30
|
-
version: 1,
|
|
31
|
-
installs: [{ ref: '@a/b', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] }],
|
|
32
|
-
});
|
|
33
|
-
upsertInstall({
|
|
34
|
-
ref: '@a/b',
|
|
35
|
-
type: 'SKILL',
|
|
36
|
-
version: '2.0.0',
|
|
37
|
-
installedAt: 't2',
|
|
38
|
-
files: [],
|
|
39
|
-
});
|
|
40
|
-
const lf = loadLockfile();
|
|
41
|
-
expect(lf.installs).toHaveLength(1);
|
|
42
|
-
expect(lf.installs[0].version).toBe('2.0.0');
|
|
43
|
-
});
|
|
44
|
-
it('removeInstall removes the entry by ref', () => {
|
|
45
|
-
saveLockfile({
|
|
46
|
-
version: 1,
|
|
47
|
-
installs: [
|
|
48
|
-
{ ref: '@a/b', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
|
|
49
|
-
{ ref: '@a/c', type: 'SKILL', version: '1.0.0', installedAt: 't', files: [] },
|
|
50
|
-
],
|
|
51
|
-
});
|
|
52
|
-
removeInstall('@a/b');
|
|
53
|
-
const lf = loadLockfile();
|
|
54
|
-
expect(lf.installs).toHaveLength(1);
|
|
55
|
-
expect(lf.installs[0].ref).toBe('@a/c');
|
|
56
|
-
});
|
|
57
|
-
it('fingerprintFile normalizes CRLF to LF before hashing', () => {
|
|
58
|
-
const lf = path.join(tmp, 'lf.txt');
|
|
59
|
-
const crlf = path.join(tmp, 'crlf.txt');
|
|
60
|
-
fs.writeFileSync(lf, 'one\ntwo\nthree\n');
|
|
61
|
-
fs.writeFileSync(crlf, 'one\r\ntwo\r\nthree\r\n');
|
|
62
|
-
expect(fingerprintFile(lf)).toBe(fingerprintFile(crlf));
|
|
63
|
-
});
|
|
64
|
-
it('fingerprintFile hashes binary files (NUL bytes) as raw bytes', () => {
|
|
65
|
-
const binPath = path.join(tmp, 'bin.dat');
|
|
66
|
-
// A buffer with a NUL byte — looks binary
|
|
67
|
-
fs.writeFileSync(binPath, Buffer.from([0x00, 0x01, 0xff, 0xfe, 0x80]));
|
|
68
|
-
// Same buffer twice should give the same fingerprint
|
|
69
|
-
expect(fingerprintFile(binPath)).toBe(fingerprintFile(binPath));
|
|
70
|
-
});
|
|
71
|
-
it('fingerprintFile treats files with U+FFFD as text (no false binary classification)', () => {
|
|
72
|
-
const txtPath = path.join(tmp, 'with-replacement.md');
|
|
73
|
-
// Text content that legitimately contains U+FFFD — should still be text-hashed
|
|
74
|
-
fs.writeFileSync(txtPath, '# Header\n\nReplacement: �\n');
|
|
75
|
-
const fp1 = fingerprintFile(txtPath);
|
|
76
|
-
// Same content with CRLF — should fingerprint identically (text path normalized)
|
|
77
|
-
const txtCrlfPath = path.join(tmp, 'with-replacement-crlf.md');
|
|
78
|
-
fs.writeFileSync(txtCrlfPath, '# Header\r\n\r\nReplacement: �\r\n');
|
|
79
|
-
expect(fingerprintFile(txtCrlfPath)).toBe(fp1);
|
|
80
|
-
});
|
|
81
|
-
it('fingerprintContent agrees with fingerprintFile for the same text content', () => {
|
|
82
|
-
const filePath = path.join(tmp, 'agree.txt');
|
|
83
|
-
const content = 'hello\nworld\n';
|
|
84
|
-
fs.writeFileSync(filePath, content);
|
|
85
|
-
expect(fingerprintContent(content)).toBe(fingerprintFile(filePath));
|
|
86
|
-
});
|
|
87
|
-
it('fingerprintContent normalizes CRLF the same way as fingerprintFile', () => {
|
|
88
|
-
const filePath = path.join(tmp, 'crlf.txt');
|
|
89
|
-
fs.writeFileSync(filePath, 'a\r\nb\r\n');
|
|
90
|
-
expect(fingerprintContent('a\nb\n')).toBe(fingerprintFile(filePath));
|
|
91
|
-
});
|
|
92
|
-
it('loadLockfile returns empty when JSON is corrupt (does not throw)', () => {
|
|
93
|
-
const lf = path.join(tmp, '.botdocs', 'installed.json');
|
|
94
|
-
fs.mkdirSync(path.dirname(lf), { recursive: true });
|
|
95
|
-
fs.writeFileSync(lf, '{ this is not valid JSON');
|
|
96
|
-
const result = loadLockfile();
|
|
97
|
-
expect(result).toEqual({ version: 1, installs: [] });
|
|
98
|
-
});
|
|
99
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { parseManifest, ManifestError } from './manifest.js';
|
|
3
|
-
describe('parseManifest', () => {
|
|
4
|
-
it('parses a SKILL manifest', () => {
|
|
5
|
-
const m = parseManifest({
|
|
6
|
-
type: 'skill',
|
|
7
|
-
version: '1.0.0',
|
|
8
|
-
title: 'Code Review',
|
|
9
|
-
description: 'Reviews PRs',
|
|
10
|
-
sourceEcosystem: 'claude-code',
|
|
11
|
-
});
|
|
12
|
-
expect(m.type).toBe('SKILL');
|
|
13
|
-
expect(m.version).toBe('1.0.0');
|
|
14
|
-
expect(m.sourceEcosystem).toBe('claude-code');
|
|
15
|
-
});
|
|
16
|
-
it('parses a BUNDLE manifest with refs', () => {
|
|
17
|
-
const m = parseManifest({
|
|
18
|
-
type: 'bundle',
|
|
19
|
-
version: '1.0.0',
|
|
20
|
-
title: 'Eng Skills',
|
|
21
|
-
description: 'All engineering skills',
|
|
22
|
-
skills: ['@alice/code-review', '@alice/pr-summary'],
|
|
23
|
-
});
|
|
24
|
-
expect(m.type).toBe('BUNDLE');
|
|
25
|
-
expect(m.skills).toEqual([
|
|
26
|
-
{ username: 'alice', slug: 'code-review' },
|
|
27
|
-
{ username: 'alice', slug: 'pr-summary' },
|
|
28
|
-
]);
|
|
29
|
-
});
|
|
30
|
-
it('defaults type to SPEC when missing', () => {
|
|
31
|
-
const m = parseManifest({ title: 't', description: 'd' });
|
|
32
|
-
expect(m.type).toBe('SPEC');
|
|
33
|
-
});
|
|
34
|
-
it('throws on non-semver version', () => {
|
|
35
|
-
expect(() => parseManifest({ type: 'skill', version: 'latest', title: 't', description: 'd' })).toThrow(ManifestError);
|
|
36
|
-
});
|
|
37
|
-
it('throws when bundle skills entry is malformed', () => {
|
|
38
|
-
expect(() => parseManifest({ type: 'bundle', version: '1.0.0', title: 't', description: 'd', skills: ['not-a-ref'] })).toThrow(ManifestError);
|
|
39
|
-
});
|
|
40
|
-
it('accepts skills with leading @', () => {
|
|
41
|
-
const m = parseManifest({
|
|
42
|
-
type: 'bundle',
|
|
43
|
-
version: '1.0.0',
|
|
44
|
-
title: 't',
|
|
45
|
-
description: 'd',
|
|
46
|
-
skills: ['@alice/x', 'bob/y'],
|
|
47
|
-
});
|
|
48
|
-
expect(m.skills).toHaveLength(2);
|
|
49
|
-
});
|
|
50
|
-
it('throws on non-object input', () => {
|
|
51
|
-
expect(() => parseManifest(null)).toThrow(ManifestError);
|
|
52
|
-
expect(() => parseManifest('string')).toThrow(ManifestError);
|
|
53
|
-
expect(() => parseManifest(42)).toThrow(ManifestError);
|
|
54
|
-
});
|
|
55
|
-
it('throws on non-string type field', () => {
|
|
56
|
-
expect(() => parseManifest({ type: 42, title: 't', description: 'd' })).toThrow(ManifestError);
|
|
57
|
-
});
|
|
58
|
-
it('throws when bundle is missing skills array', () => {
|
|
59
|
-
expect(() => parseManifest({ type: 'bundle', version: '1.0.0', title: 't', description: 'd' })).toThrow(ManifestError);
|
|
60
|
-
});
|
|
61
|
-
it('parses ecosystems[] field on a SKILL manifest', () => {
|
|
62
|
-
const m = parseManifest({
|
|
63
|
-
type: 'skill',
|
|
64
|
-
version: '1.0.0',
|
|
65
|
-
title: 'X',
|
|
66
|
-
description: 'Y',
|
|
67
|
-
sourceEcosystem: 'claude-code',
|
|
68
|
-
ecosystems: ['claude', 'claude-code', 'cursor'],
|
|
69
|
-
});
|
|
70
|
-
expect(m.ecosystems).toEqual(['claude', 'claude-code', 'cursor']);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
import { detectShell, installShellHook, uninstallShellHook, BEGIN, END } from './shell-hook.js';
|
|
6
|
-
describe('shell-hook', () => {
|
|
7
|
-
const origHome = os.homedir;
|
|
8
|
-
let homeTmp;
|
|
9
|
-
beforeEach(() => {
|
|
10
|
-
homeTmp = fs.mkdtempSync(path.join(os.tmpdir(), 'sh-'));
|
|
11
|
-
os.homedir = () => homeTmp;
|
|
12
|
-
});
|
|
13
|
-
afterEach(() => {
|
|
14
|
-
fs.rmSync(homeTmp, { recursive: true, force: true });
|
|
15
|
-
os.homedir = origHome;
|
|
16
|
-
});
|
|
17
|
-
it('detectShell returns zsh for ZSH_VERSION env', () => {
|
|
18
|
-
expect(detectShell({ ZSH_VERSION: '5.9' })).toBe('zsh');
|
|
19
|
-
});
|
|
20
|
-
it('detectShell returns bash from BASH env', () => {
|
|
21
|
-
expect(detectShell({ BASH: '/bin/bash' })).toBe('bash');
|
|
22
|
-
});
|
|
23
|
-
it('detectShell falls back to SHELL env', () => {
|
|
24
|
-
expect(detectShell({ SHELL: '/usr/bin/fish' })).toBe('fish');
|
|
25
|
-
expect(detectShell({ SHELL: '/bin/zsh' })).toBe('zsh');
|
|
26
|
-
});
|
|
27
|
-
it('detectShell returns null for unknown shells', () => {
|
|
28
|
-
expect(detectShell({ SHELL: '/bin/csh' })).toBeNull();
|
|
29
|
-
});
|
|
30
|
-
it('detectShell prefers $SHELL over BASH env var (handles bash -c from zsh)', () => {
|
|
31
|
-
// User's login shell is zsh; they ran the command via `bash -c botdocs ...`
|
|
32
|
-
// which exports BASH=/bin/bash. We want zsh, not bash.
|
|
33
|
-
expect(detectShell({ SHELL: '/bin/zsh', BASH: '/bin/bash' })).toBe('zsh');
|
|
34
|
-
});
|
|
35
|
-
it('installShellHook creates a delimited block in .zshrc', () => {
|
|
36
|
-
const result = installShellHook('zsh');
|
|
37
|
-
const rc = fs.readFileSync(path.join(homeTmp, '.zshrc'), 'utf-8');
|
|
38
|
-
expect(rc).toContain(BEGIN);
|
|
39
|
-
expect(rc).toContain(END);
|
|
40
|
-
expect(rc).toContain('botdocs check-updates --quiet');
|
|
41
|
-
expect(result.action).toBe('created');
|
|
42
|
-
});
|
|
43
|
-
it('installShellHook is idempotent on re-run', () => {
|
|
44
|
-
installShellHook('zsh');
|
|
45
|
-
const r2 = installShellHook('zsh');
|
|
46
|
-
const rc = fs.readFileSync(path.join(homeTmp, '.zshrc'), 'utf-8');
|
|
47
|
-
expect(rc.match(new RegExp(BEGIN.replace(/[/\\$.*+?()[\]{}|]/g, '\\$&'), 'g'))).toHaveLength(1);
|
|
48
|
-
expect(r2.action).toBe('updated');
|
|
49
|
-
});
|
|
50
|
-
it('installShellHook preserves user content outside the markers', () => {
|
|
51
|
-
fs.writeFileSync(path.join(homeTmp, '.bashrc'), 'export EDITOR=vim\n', 'utf-8');
|
|
52
|
-
installShellHook('bash');
|
|
53
|
-
const rc = fs.readFileSync(path.join(homeTmp, '.bashrc'), 'utf-8');
|
|
54
|
-
expect(rc).toContain('export EDITOR=vim');
|
|
55
|
-
expect(rc).toContain(BEGIN);
|
|
56
|
-
});
|
|
57
|
-
it('uninstallShellHook removes the block', () => {
|
|
58
|
-
installShellHook('zsh');
|
|
59
|
-
uninstallShellHook('zsh');
|
|
60
|
-
const rc = fs.readFileSync(path.join(homeTmp, '.zshrc'), 'utf-8');
|
|
61
|
-
expect(rc).not.toContain(BEGIN);
|
|
62
|
-
});
|
|
63
|
-
it('writes the fish-syntax variant for fish', () => {
|
|
64
|
-
installShellHook('fish');
|
|
65
|
-
const rc = fs.readFileSync(path.join(homeTmp, '.config', 'fish', 'config.fish'), 'utf-8');
|
|
66
|
-
expect(rc).toContain('botdocs check-updates --quiet');
|
|
67
|
-
});
|
|
68
|
-
});
|
package/dist/test-utils.d.ts
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
/** Thrown by the mocked process.exit so tests can assert on the exit code
|
|
2
|
-
* without actually killing the test runner. */
|
|
3
|
-
export declare class ProcessExitError extends Error {
|
|
4
|
-
readonly code: number;
|
|
5
|
-
constructor(code: number);
|
|
6
|
-
}
|
|
7
|
-
export interface CapturedConsole {
|
|
8
|
-
stdout: string[];
|
|
9
|
-
stderr: string[];
|
|
10
|
-
restore: () => void;
|
|
11
|
-
}
|
|
12
|
-
/** Patches console.log/console.error and process.exit so commands can be
|
|
13
|
-
* driven from tests without writing to the real terminal or exiting. */
|
|
14
|
-
export declare function captureConsole(): CapturedConsole;
|
|
15
|
-
export interface MockFetchCall {
|
|
16
|
-
url: string;
|
|
17
|
-
method: string;
|
|
18
|
-
headers: Record<string, string>;
|
|
19
|
-
body: unknown;
|
|
20
|
-
}
|
|
21
|
-
export interface MockFetchResponse {
|
|
22
|
-
status?: number;
|
|
23
|
-
body?: unknown;
|
|
24
|
-
contentType?: string;
|
|
25
|
-
}
|
|
26
|
-
/** Installs a fetch stub that matches requests by (method, urlSubstring) and
|
|
27
|
-
* returns the configured response. Each handler can fire only once unless
|
|
28
|
-
* `repeat: true` is set. */
|
|
29
|
-
export declare function mockFetch(handlers: Array<{
|
|
30
|
-
method?: string;
|
|
31
|
-
url: string | RegExp;
|
|
32
|
-
response: MockFetchResponse | ((call: MockFetchCall) => MockFetchResponse);
|
|
33
|
-
repeat?: boolean;
|
|
34
|
-
}>): {
|
|
35
|
-
calls: MockFetchCall[];
|
|
36
|
-
restore: () => void;
|
|
37
|
-
};
|
|
38
|
-
/** Creates a unique temporary directory and changes into it. The returned
|
|
39
|
-
* cleanup function restores the prior cwd and removes the dir. */
|
|
40
|
-
export declare function withTempDir(): {
|
|
41
|
-
dir: string;
|
|
42
|
-
cleanup: () => void;
|
|
43
|
-
};
|
package/dist/test-utils.js
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { vi } from 'vitest';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
/** Thrown by the mocked process.exit so tests can assert on the exit code
|
|
6
|
-
* without actually killing the test runner. */
|
|
7
|
-
export class ProcessExitError extends Error {
|
|
8
|
-
code;
|
|
9
|
-
constructor(code) {
|
|
10
|
-
super(`process.exit(${code})`);
|
|
11
|
-
this.name = 'ProcessExitError';
|
|
12
|
-
this.code = code;
|
|
13
|
-
}
|
|
14
|
-
}
|
|
15
|
-
/** Patches console.log/console.error and process.exit so commands can be
|
|
16
|
-
* driven from tests without writing to the real terminal or exiting. */
|
|
17
|
-
export function captureConsole() {
|
|
18
|
-
const stdout = [];
|
|
19
|
-
const stderr = [];
|
|
20
|
-
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => {
|
|
21
|
-
stdout.push(args.map(String).join(' '));
|
|
22
|
-
});
|
|
23
|
-
const errSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
24
|
-
stderr.push(args.map(String).join(' '));
|
|
25
|
-
});
|
|
26
|
-
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code) => {
|
|
27
|
-
throw new ProcessExitError(code ?? 0);
|
|
28
|
-
}));
|
|
29
|
-
return {
|
|
30
|
-
stdout,
|
|
31
|
-
stderr,
|
|
32
|
-
restore: () => {
|
|
33
|
-
logSpy.mockRestore();
|
|
34
|
-
errSpy.mockRestore();
|
|
35
|
-
exitSpy.mockRestore();
|
|
36
|
-
},
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
/** Installs a fetch stub that matches requests by (method, urlSubstring) and
|
|
40
|
-
* returns the configured response. Each handler can fire only once unless
|
|
41
|
-
* `repeat: true` is set. */
|
|
42
|
-
export function mockFetch(handlers) {
|
|
43
|
-
const calls = [];
|
|
44
|
-
const used = new Set();
|
|
45
|
-
const original = global.fetch;
|
|
46
|
-
global.fetch = vi.fn(async (input, init) => {
|
|
47
|
-
const url = typeof input === 'string' ? input : input.url;
|
|
48
|
-
const method = (init?.method || 'GET').toUpperCase();
|
|
49
|
-
const headers = (init?.headers || {});
|
|
50
|
-
let body = undefined;
|
|
51
|
-
if (init?.body) {
|
|
52
|
-
try {
|
|
53
|
-
body = JSON.parse(init.body);
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
body = init.body;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
const call = { url, method, headers, body };
|
|
60
|
-
calls.push(call);
|
|
61
|
-
for (let i = 0; i < handlers.length; i++) {
|
|
62
|
-
const h = handlers[i];
|
|
63
|
-
if (!h.repeat && used.has(i))
|
|
64
|
-
continue;
|
|
65
|
-
const matchUrl = typeof h.url === 'string' ? url.includes(h.url) : h.url.test(url);
|
|
66
|
-
const matchMethod = !h.method || h.method.toUpperCase() === method;
|
|
67
|
-
if (matchUrl && matchMethod) {
|
|
68
|
-
used.add(i);
|
|
69
|
-
const r = typeof h.response === 'function' ? h.response(call) : h.response;
|
|
70
|
-
const status = r.status ?? 200;
|
|
71
|
-
const isJson = r.contentType ? r.contentType.includes('json') : typeof r.body !== 'string';
|
|
72
|
-
const payload = isJson ? JSON.stringify(r.body ?? {}) : r.body;
|
|
73
|
-
return new Response(payload, {
|
|
74
|
-
status,
|
|
75
|
-
headers: { 'content-type': r.contentType ?? (isJson ? 'application/json' : 'text/plain') },
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
throw new Error(`Unhandled fetch ${method} ${url}`);
|
|
80
|
-
});
|
|
81
|
-
return {
|
|
82
|
-
calls,
|
|
83
|
-
restore: () => {
|
|
84
|
-
global.fetch = original;
|
|
85
|
-
},
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
/** Creates a unique temporary directory and changes into it. The returned
|
|
89
|
-
* cleanup function restores the prior cwd and removes the dir. */
|
|
90
|
-
export function withTempDir() {
|
|
91
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'botdocs-cli-test-'));
|
|
92
|
-
const prevCwd = process.cwd();
|
|
93
|
-
process.chdir(dir);
|
|
94
|
-
return {
|
|
95
|
-
dir,
|
|
96
|
-
cleanup: () => {
|
|
97
|
-
process.chdir(prevCwd);
|
|
98
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
99
|
-
},
|
|
100
|
-
};
|
|
101
|
-
}
|