@jackwener/opencli 1.6.5 → 1.6.6
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/dist/clis/yollomi/background.js +2 -2
- package/dist/clis/yollomi/edit.js +3 -3
- package/dist/clis/yollomi/face-swap.js +2 -2
- package/dist/clis/yollomi/generate.js +3 -3
- package/dist/clis/yollomi/object-remover.js +2 -2
- package/dist/clis/yollomi/remove-bg.js +2 -2
- package/dist/clis/yollomi/restore.js +2 -2
- package/dist/clis/yollomi/try-on.js +2 -2
- package/dist/clis/yollomi/upload.js +3 -3
- package/dist/clis/yollomi/upscale.js +3 -3
- package/dist/clis/yollomi/video.js +3 -3
- package/dist/clis/yuanbao/ask.js +34 -51
- package/dist/src/discovery.js +0 -2
- package/dist/src/engine.test.js +3 -2
- package/dist/src/logger.d.ts +4 -0
- package/dist/src/logger.js +8 -0
- package/dist/src/package-exports.test.js +39 -2
- package/dist/src/utils.d.ts +3 -0
- package/dist/src/utils.js +22 -0
- package/package.json +1 -1
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Yollomi AI background generator — POST /api/ai/ai-background-generator
|
|
3
3
|
*/
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
5
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
6
|
import { CliError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { log } from '@jackwener/opencli/logger';
|
|
8
8
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
9
9
|
cli({
|
|
10
10
|
site: 'yollomi',
|
|
@@ -22,7 +22,7 @@ cli({
|
|
|
22
22
|
func: async (page, kwargs) => {
|
|
23
23
|
const imageUrl = kwargs.image;
|
|
24
24
|
const prompt = kwargs.prompt;
|
|
25
|
-
|
|
25
|
+
log.status('Generating background...');
|
|
26
26
|
const data = await yollomiPost(page, '/api/ai/ai-background-generator', {
|
|
27
27
|
images: [imageUrl],
|
|
28
28
|
prompt: prompt || undefined,
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Matches frontend workspace-generator.tsx for qwen-image-edit model.
|
|
4
4
|
*/
|
|
5
5
|
import * as path from 'node:path';
|
|
6
|
-
import chalk from 'chalk';
|
|
7
6
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
7
|
import { CliError } from '@jackwener/opencli/errors';
|
|
8
|
+
import { log } from '@jackwener/opencli/logger';
|
|
9
9
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
10
10
|
cli({
|
|
11
11
|
site: 'yollomi',
|
|
@@ -33,7 +33,7 @@ cli({
|
|
|
33
33
|
body = { image: imageInput, prompt, go_fast: true, output_format: 'png' };
|
|
34
34
|
}
|
|
35
35
|
const apiPath = modelId === 'qwen-image-edit-plus' ? '/api/ai/qwen-image-edit-plus' : '/api/ai/qwen-image-edit';
|
|
36
|
-
|
|
36
|
+
log.status(`Editing with ${modelId}...`);
|
|
37
37
|
const data = await yollomiPost(page, apiPath, body);
|
|
38
38
|
const images = data.images || (data.image ? [data.image] : []);
|
|
39
39
|
if (!images.length)
|
|
@@ -46,7 +46,7 @@ cli({
|
|
|
46
46
|
const filename = `yollomi_edit_${Date.now()}.png`;
|
|
47
47
|
const { path: fp, size } = await downloadOutput(url, kwargs.output, filename);
|
|
48
48
|
if (credits !== undefined)
|
|
49
|
-
|
|
49
|
+
log.status(`Credits remaining: ${credits}`);
|
|
50
50
|
return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), credits: credits ?? '-', url }];
|
|
51
51
|
}
|
|
52
52
|
catch {
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Uses swap_image / input_image field names matching the frontend.
|
|
4
4
|
*/
|
|
5
5
|
import * as path from 'node:path';
|
|
6
|
-
import chalk from 'chalk';
|
|
7
6
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
7
|
import { CliError } from '@jackwener/opencli/errors';
|
|
8
|
+
import { log } from '@jackwener/opencli/logger';
|
|
9
9
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
10
10
|
cli({
|
|
11
11
|
site: 'yollomi',
|
|
@@ -21,7 +21,7 @@ cli({
|
|
|
21
21
|
],
|
|
22
22
|
columns: ['status', 'file', 'size', 'url'],
|
|
23
23
|
func: async (page, kwargs) => {
|
|
24
|
-
|
|
24
|
+
log.status('Swapping faces...');
|
|
25
25
|
const data = await yollomiPost(page, '/api/ai/face-swap', {
|
|
26
26
|
swap_image: kwargs.source,
|
|
27
27
|
input_image: kwargs.target,
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
* POST /api/ai/flux-2-pro { prompt, aspectRatio, imageUrl?, ... }
|
|
8
8
|
*/
|
|
9
9
|
import * as path from 'node:path';
|
|
10
|
-
import chalk from 'chalk';
|
|
11
10
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
12
11
|
import { CliError } from '@jackwener/opencli/errors';
|
|
12
|
+
import { log } from '@jackwener/opencli/logger';
|
|
13
13
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes, MODEL_ROUTES } from './utils.js';
|
|
14
14
|
function getDimensions(ratio) {
|
|
15
15
|
const map = {
|
|
@@ -63,7 +63,7 @@ cli({
|
|
|
63
63
|
if (kwargs.image)
|
|
64
64
|
body.imageUrl = kwargs.image;
|
|
65
65
|
}
|
|
66
|
-
|
|
66
|
+
log.status(`Generating with ${modelId}...`);
|
|
67
67
|
const data = await yollomiPost(page, apiPath, body);
|
|
68
68
|
const images = data.images || (data.image ? [data.image] : []);
|
|
69
69
|
if (!images.length)
|
|
@@ -94,7 +94,7 @@ cli({
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
if (data.remainingCredits !== undefined)
|
|
97
|
-
|
|
97
|
+
log.status(`Credits remaining: ${data.remainingCredits}`);
|
|
98
98
|
return results;
|
|
99
99
|
},
|
|
100
100
|
});
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Yollomi object remover — POST /api/ai/object-remover
|
|
3
3
|
*/
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
5
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
6
|
import { CliError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { log } from '@jackwener/opencli/logger';
|
|
8
8
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
9
9
|
cli({
|
|
10
10
|
site: 'yollomi',
|
|
@@ -20,7 +20,7 @@ cli({
|
|
|
20
20
|
],
|
|
21
21
|
columns: ['status', 'file', 'size', 'url'],
|
|
22
22
|
func: async (page, kwargs) => {
|
|
23
|
-
|
|
23
|
+
log.status('Removing object...');
|
|
24
24
|
const data = await yollomiPost(page, '/api/ai/object-remover', {
|
|
25
25
|
image: kwargs.image,
|
|
26
26
|
mask: kwargs.mask,
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Yollomi background removal — POST /api/ai/remove-bg (free, 0 credits)
|
|
3
3
|
*/
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
5
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
6
|
import { CliError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { log } from '@jackwener/opencli/logger';
|
|
8
8
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
9
9
|
cli({
|
|
10
10
|
site: 'yollomi',
|
|
@@ -19,7 +19,7 @@ cli({
|
|
|
19
19
|
],
|
|
20
20
|
columns: ['status', 'file', 'size', 'url'],
|
|
21
21
|
func: async (page, kwargs) => {
|
|
22
|
-
|
|
22
|
+
log.status('Removing background...');
|
|
23
23
|
const data = await yollomiPost(page, '/api/ai/remove-bg', { imageUrl: kwargs.image });
|
|
24
24
|
const url = data.image || (data.images?.[0]);
|
|
25
25
|
if (!url)
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Yollomi photo restoration — POST /api/ai/photo-restoration
|
|
3
3
|
*/
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
5
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
6
|
import { CliError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { log } from '@jackwener/opencli/logger';
|
|
8
8
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
9
9
|
cli({
|
|
10
10
|
site: 'yollomi',
|
|
@@ -19,7 +19,7 @@ cli({
|
|
|
19
19
|
],
|
|
20
20
|
columns: ['status', 'file', 'size', 'url'],
|
|
21
21
|
func: async (page, kwargs) => {
|
|
22
|
-
|
|
22
|
+
log.status('Restoring photo...');
|
|
23
23
|
const data = await yollomiPost(page, '/api/ai/photo-restoration', { imageUrl: kwargs.image });
|
|
24
24
|
const url = data.image || (data.images?.[0]);
|
|
25
25
|
if (!url)
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Yollomi virtual try-on — POST /api/ai/virtual-try-on
|
|
3
3
|
*/
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
5
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
6
|
import { CliError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { log } from '@jackwener/opencli/logger';
|
|
8
8
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
9
9
|
cli({
|
|
10
10
|
site: 'yollomi',
|
|
@@ -21,7 +21,7 @@ cli({
|
|
|
21
21
|
],
|
|
22
22
|
columns: ['status', 'file', 'size', 'url'],
|
|
23
23
|
func: async (page, kwargs) => {
|
|
24
|
-
|
|
24
|
+
log.status('Processing virtual try-on...');
|
|
25
25
|
const data = await yollomiPost(page, '/api/ai/virtual-try-on', {
|
|
26
26
|
person_image: kwargs.person,
|
|
27
27
|
cloth_image: kwargs.cloth,
|
|
@@ -6,9 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import * as path from 'node:path';
|
|
9
|
-
import chalk from 'chalk';
|
|
10
9
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
11
10
|
import { CliError } from '@jackwener/opencli/errors';
|
|
11
|
+
import { log } from '@jackwener/opencli/logger';
|
|
12
12
|
import { YOLLOMI_DOMAIN, ensureOnYollomi, fmtBytes } from './utils.js';
|
|
13
13
|
const MIME_MAP = {
|
|
14
14
|
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
|
|
@@ -42,7 +42,7 @@ cli({
|
|
|
42
42
|
throw new CliError('FILE_TOO_LARGE', `File too large: ${fmtBytes(data.length)}`, `Max ${mime.startsWith('video/') ? '20MB' : '10MB'} (upload larger videos from a URL)`);
|
|
43
43
|
const b64 = data.toString('base64');
|
|
44
44
|
const fileName = path.basename(filePath);
|
|
45
|
-
|
|
45
|
+
log.status(`Uploading ${fileName} (${fmtBytes(data.length)})...`);
|
|
46
46
|
await ensureOnYollomi(page);
|
|
47
47
|
const result = await page.evaluate(`
|
|
48
48
|
(async () => {
|
|
@@ -65,7 +65,7 @@ cli({
|
|
|
65
65
|
throw new CliError('UPLOAD_ERROR', result?.data?.error || 'Upload failed', 'Make sure you are logged in to yollomi.com');
|
|
66
66
|
}
|
|
67
67
|
const url = result.data.url;
|
|
68
|
-
|
|
68
|
+
log.success('Uploaded! Use this URL as input for other commands.');
|
|
69
69
|
return [{ status: 'uploaded', file: fileName, size: fmtBytes(data.length), url }];
|
|
70
70
|
},
|
|
71
71
|
});
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Yollomi image upscaling — POST /api/ai/image-upscaler
|
|
3
3
|
*/
|
|
4
4
|
import * as path from 'node:path';
|
|
5
|
-
import chalk from 'chalk';
|
|
6
5
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
7
6
|
import { CliError } from '@jackwener/opencli/errors';
|
|
7
|
+
import { log } from '@jackwener/opencli/logger';
|
|
8
8
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
9
9
|
cli({
|
|
10
10
|
site: 'yollomi',
|
|
@@ -21,7 +21,7 @@ cli({
|
|
|
21
21
|
columns: ['status', 'file', 'size', 'scale', 'url'],
|
|
22
22
|
func: async (page, kwargs) => {
|
|
23
23
|
const scale = parseInt(kwargs.scale, 10);
|
|
24
|
-
|
|
24
|
+
log.status(`Upscaling ${scale}x...`);
|
|
25
25
|
const data = await yollomiPost(page, '/api/ai/image-upscaler', {
|
|
26
26
|
imageUrl: kwargs.image,
|
|
27
27
|
scale,
|
|
@@ -43,7 +43,7 @@ cli({
|
|
|
43
43
|
const filename = `yollomi_upscale_${scale}x_${Date.now()}${ext}`;
|
|
44
44
|
const { path: fp, size } = await downloadOutput(url, kwargs.output, filename);
|
|
45
45
|
if (data.remainingCredits !== undefined)
|
|
46
|
-
|
|
46
|
+
log.status(`Credits remaining: ${data.remainingCredits}`);
|
|
47
47
|
return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), scale: `${scale}x`, url }];
|
|
48
48
|
}
|
|
49
49
|
catch {
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
* Matches the frontend video-generator.tsx request format exactly.
|
|
4
4
|
*/
|
|
5
5
|
import * as path from 'node:path';
|
|
6
|
-
import chalk from 'chalk';
|
|
7
6
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
8
7
|
import { CliError } from '@jackwener/opencli/errors';
|
|
8
|
+
import { log } from '@jackwener/opencli/logger';
|
|
9
9
|
import { YOLLOMI_DOMAIN, yollomiPost, downloadOutput, fmtBytes } from './utils.js';
|
|
10
10
|
cli({
|
|
11
11
|
site: 'yollomi',
|
|
@@ -31,7 +31,7 @@ cli({
|
|
|
31
31
|
if (kwargs.image)
|
|
32
32
|
inputs.image = kwargs.image;
|
|
33
33
|
const body = { modelId, prompt, inputs };
|
|
34
|
-
|
|
34
|
+
log.status(`Generating video with ${modelId} (may take a while)...`);
|
|
35
35
|
const data = await yollomiPost(page, '/api/ai/video', body);
|
|
36
36
|
const videoUrl = data.video || '';
|
|
37
37
|
if (!videoUrl)
|
|
@@ -46,7 +46,7 @@ cli({
|
|
|
46
46
|
const filename = `yollomi_${modelId}_${Date.now()}.mp4`;
|
|
47
47
|
const { path: fp, size } = await downloadOutput(videoUrl, outputDir, filename);
|
|
48
48
|
if (credits !== undefined)
|
|
49
|
-
|
|
49
|
+
log.status(`Credits remaining: ${credits}`);
|
|
50
50
|
return [{ status: 'saved', file: path.relative('.', fp), size: fmtBytes(size), credits: credits ?? '-', url: videoUrl }];
|
|
51
51
|
}
|
|
52
52
|
catch {
|
package/dist/clis/yuanbao/ask.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
-
import
|
|
2
|
+
import { htmlToMarkdown } from '@jackwener/opencli/utils';
|
|
3
3
|
import { CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors';
|
|
4
4
|
import { YUANBAO_DOMAIN, IS_VISIBLE_JS, authRequired, isOnYuanbao, ensureYuanbaoPage, hasLoginGate } from './shared.js';
|
|
5
5
|
const YUANBAO_RESPONSE_POLL_INTERVAL_SECONDS = 2;
|
|
@@ -20,57 +20,40 @@ function normalizeBooleanFlag(value, fallback) {
|
|
|
20
20
|
const normalized = String(value).trim().toLowerCase();
|
|
21
21
|
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
|
22
22
|
}
|
|
23
|
-
function createYuanbaoTurndown() {
|
|
24
|
-
const td = new TurndownService({
|
|
25
|
-
headingStyle: 'atx',
|
|
26
|
-
codeBlockStyle: 'fenced',
|
|
27
|
-
bulletListMarker: '-',
|
|
28
|
-
});
|
|
29
|
-
td.addRule('linebreak', {
|
|
30
|
-
filter: 'br',
|
|
31
|
-
replacement: () => '\n',
|
|
32
|
-
});
|
|
33
|
-
td.addRule('table', {
|
|
34
|
-
filter: 'table',
|
|
35
|
-
replacement: (content) => `\n\n${content}\n\n`,
|
|
36
|
-
});
|
|
37
|
-
td.addRule('tableSection', {
|
|
38
|
-
filter: ['thead', 'tbody', 'tfoot'],
|
|
39
|
-
replacement: (content) => content,
|
|
40
|
-
});
|
|
41
|
-
td.addRule('tableRow', {
|
|
42
|
-
filter: 'tr',
|
|
43
|
-
replacement: (content, node) => {
|
|
44
|
-
const element = node;
|
|
45
|
-
const cells = Array.from(element.children);
|
|
46
|
-
const isHeaderRow = element.parentElement?.tagName === 'THEAD'
|
|
47
|
-
|| (cells.length > 0 && cells.every((cell) => cell.tagName === 'TH'));
|
|
48
|
-
const row = `${content}\n`;
|
|
49
|
-
if (!isHeaderRow)
|
|
50
|
-
return row;
|
|
51
|
-
const separator = `| ${cells.map(() => '---').join(' | ')} |\n`;
|
|
52
|
-
return `${row}${separator}`;
|
|
53
|
-
},
|
|
54
|
-
});
|
|
55
|
-
td.addRule('tableCell', {
|
|
56
|
-
filter: ['th', 'td'],
|
|
57
|
-
replacement: (content, node) => {
|
|
58
|
-
const element = node;
|
|
59
|
-
const index = element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : 0;
|
|
60
|
-
const prefix = index === 0 ? '| ' : ' ';
|
|
61
|
-
return `${prefix}${content.trim()} |`;
|
|
62
|
-
},
|
|
63
|
-
});
|
|
64
|
-
return td;
|
|
65
|
-
}
|
|
66
|
-
const yuanbaoTurndown = createYuanbaoTurndown();
|
|
67
23
|
export function convertYuanbaoHtmlToMarkdown(value) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
.
|
|
24
|
+
return htmlToMarkdown(value, (td) => {
|
|
25
|
+
td.addRule('table', {
|
|
26
|
+
filter: 'table',
|
|
27
|
+
replacement: (content) => `\n\n${content}\n\n`,
|
|
28
|
+
});
|
|
29
|
+
td.addRule('tableSection', {
|
|
30
|
+
filter: ['thead', 'tbody', 'tfoot'],
|
|
31
|
+
replacement: (content) => content,
|
|
32
|
+
});
|
|
33
|
+
td.addRule('tableRow', {
|
|
34
|
+
filter: 'tr',
|
|
35
|
+
replacement: (content, node) => {
|
|
36
|
+
const element = node;
|
|
37
|
+
const cells = Array.from(element.children);
|
|
38
|
+
const isHeaderRow = element.parentElement?.tagName === 'THEAD'
|
|
39
|
+
|| (cells.length > 0 && cells.every((cell) => cell.tagName === 'TH'));
|
|
40
|
+
const row = `${content}\n`;
|
|
41
|
+
if (!isHeaderRow)
|
|
42
|
+
return row;
|
|
43
|
+
const separator = `| ${cells.map(() => '---').join(' | ')} |\n`;
|
|
44
|
+
return `${row}${separator}`;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
td.addRule('tableCell', {
|
|
48
|
+
filter: ['th', 'td'],
|
|
49
|
+
replacement: (content, node) => {
|
|
50
|
+
const element = node;
|
|
51
|
+
const index = element.parentElement ? Array.from(element.parentElement.children).indexOf(element) : 0;
|
|
52
|
+
const prefix = index === 0 ? '| ' : ' ';
|
|
53
|
+
return `${prefix}${content.trim()} |`;
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
});
|
|
74
57
|
}
|
|
75
58
|
export function sanitizeYuanbaoResponseText(value, promptText) {
|
|
76
59
|
let sanitized = value
|
package/dist/src/discovery.js
CHANGED
|
@@ -78,12 +78,10 @@ export async function ensureUserCliCompatShims(baseDir = USER_OPENCLI_DIR) {
|
|
|
78
78
|
catch { /* doesn't exist */ }
|
|
79
79
|
if (needsUpdate) {
|
|
80
80
|
await fs.promises.mkdir(symlinkDir, { recursive: true });
|
|
81
|
-
// Use rm instead of unlink — handles both symlinks and stale directories
|
|
82
81
|
try {
|
|
83
82
|
await fs.promises.rm(symlinkPath, { recursive: true, force: true });
|
|
84
83
|
}
|
|
85
84
|
catch { /* doesn't exist */ }
|
|
86
|
-
// Use 'junction' on Windows — doesn't require admin/Developer Mode privileges
|
|
87
85
|
const symlinkType = process.platform === 'win32' ? 'junction' : 'dir';
|
|
88
86
|
await fs.promises.symlink(opencliRoot, symlinkPath, symlinkType);
|
|
89
87
|
}
|
package/dist/src/engine.test.js
CHANGED
|
@@ -82,6 +82,7 @@ cli({
|
|
|
82
82
|
await fs.promises.writeFile(commandPath, `
|
|
83
83
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
84
84
|
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
85
|
+
import { htmlToMarkdown } from '@jackwener/opencli/utils';
|
|
85
86
|
|
|
86
87
|
cli({
|
|
87
88
|
site: 'legacy-site',
|
|
@@ -89,13 +90,13 @@ cli({
|
|
|
89
90
|
description: 'hello command',
|
|
90
91
|
strategy: Strategy.PUBLIC,
|
|
91
92
|
browser: false,
|
|
92
|
-
func: async () => [{ ok: true, errorName: new CommandExecutionError('boom').name }],
|
|
93
|
+
func: async () => [{ ok: true, errorName: new CommandExecutionError('boom').name, markdown: htmlToMarkdown('<p>hello</p>') }],
|
|
93
94
|
});
|
|
94
95
|
`);
|
|
95
96
|
await discoverClis(userClisDir);
|
|
96
97
|
const cmd = getRegistry().get('legacy-site/hello');
|
|
97
98
|
expect(cmd).toBeDefined();
|
|
98
|
-
await expect(executeCommand(cmd, {})).resolves.toEqual([{ ok: true, errorName: 'CommandExecutionError' }]);
|
|
99
|
+
await expect(executeCommand(cmd, {})).resolves.toEqual([{ ok: true, errorName: 'CommandExecutionError', markdown: 'hello' }]);
|
|
99
100
|
}
|
|
100
101
|
finally {
|
|
101
102
|
await fs.promises.rm(tempOpencliRoot, { recursive: true, force: true });
|
package/dist/src/logger.d.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
export declare const log: {
|
|
8
8
|
/** Informational message (always shown) */
|
|
9
9
|
info(msg: string): void;
|
|
10
|
+
/** Lightweight status line for adapter progress updates */
|
|
11
|
+
status(msg: string): void;
|
|
12
|
+
/** Positive completion/status line without the heavier info prefix */
|
|
13
|
+
success(msg: string): void;
|
|
10
14
|
/** Warning (always shown) */
|
|
11
15
|
warn(msg: string): void;
|
|
12
16
|
/** Error (always shown) */
|
package/dist/src/logger.js
CHANGED
|
@@ -16,6 +16,14 @@ export const log = {
|
|
|
16
16
|
info(msg) {
|
|
17
17
|
process.stderr.write(`${chalk.blue('ℹ')} ${msg}\n`);
|
|
18
18
|
},
|
|
19
|
+
/** Lightweight status line for adapter progress updates */
|
|
20
|
+
status(msg) {
|
|
21
|
+
process.stderr.write(`${chalk.dim(msg)}\n`);
|
|
22
|
+
},
|
|
23
|
+
/** Positive completion/status line without the heavier info prefix */
|
|
24
|
+
success(msg) {
|
|
25
|
+
process.stderr.write(`${chalk.green(msg)}\n`);
|
|
26
|
+
},
|
|
19
27
|
/** Warning (always shown) */
|
|
20
28
|
warn(msg) {
|
|
21
29
|
process.stderr.write(`${chalk.yellow('⚠')} ${msg}\n`);
|
|
@@ -8,24 +8,41 @@
|
|
|
8
8
|
import { describe, it, expect } from 'vitest';
|
|
9
9
|
import * as fs from 'node:fs';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
|
+
import { builtinModules } from 'node:module';
|
|
11
12
|
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import ts from 'typescript';
|
|
12
14
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
13
15
|
const ROOT = path.resolve(__dirname, '..');
|
|
14
16
|
const CLIS_DIR = path.join(ROOT, 'clis');
|
|
15
17
|
/** Recursively collect all .ts files in a directory. */
|
|
16
|
-
function collectTsFiles(dir) {
|
|
18
|
+
function collectTsFiles(dir, opts) {
|
|
17
19
|
const results = [];
|
|
18
20
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
19
21
|
const full = path.join(dir, entry.name);
|
|
20
22
|
if (entry.isDirectory()) {
|
|
21
|
-
results.push(...collectTsFiles(full));
|
|
23
|
+
results.push(...collectTsFiles(full, opts));
|
|
22
24
|
}
|
|
23
25
|
else if (entry.name.endsWith('.ts') && !entry.name.endsWith('.d.ts')) {
|
|
26
|
+
if (opts?.excludeTests && (entry.name.endsWith('.test.ts') || entry.name === 'test-utils.ts'))
|
|
27
|
+
continue;
|
|
24
28
|
results.push(full);
|
|
25
29
|
}
|
|
26
30
|
}
|
|
27
31
|
return results;
|
|
28
32
|
}
|
|
33
|
+
const ALLOWED_BARE_IMPORTS = new Set([
|
|
34
|
+
'@jackwener/opencli',
|
|
35
|
+
...builtinModules.flatMap((name) => name.startsWith('node:')
|
|
36
|
+
? [name, name.slice(5)]
|
|
37
|
+
: [name, `node:${name}`]),
|
|
38
|
+
]);
|
|
39
|
+
function isAllowedImport(specifier) {
|
|
40
|
+
return specifier.startsWith('./')
|
|
41
|
+
|| specifier.startsWith('../')
|
|
42
|
+
|| specifier.startsWith('/')
|
|
43
|
+
|| specifier.startsWith('@jackwener/opencli/')
|
|
44
|
+
|| ALLOWED_BARE_IMPORTS.has(specifier);
|
|
45
|
+
}
|
|
29
46
|
/** Forbidden relative import patterns that should have been replaced.
|
|
30
47
|
* Uses (?:\.\./)+ to catch any depth of ../ traversal.
|
|
31
48
|
* Covers: import/export from, vi.mock(), vi.importActual(). */
|
|
@@ -37,6 +54,7 @@ const FORBIDDEN_PATTERNS = [
|
|
|
37
54
|
];
|
|
38
55
|
describe('adapter imports use package exports', () => {
|
|
39
56
|
const adapterFiles = collectTsFiles(CLIS_DIR);
|
|
57
|
+
const runtimeAdapterFiles = collectTsFiles(CLIS_DIR, { excludeTests: true });
|
|
40
58
|
it('found adapter files to check', () => {
|
|
41
59
|
expect(adapterFiles.length).toBeGreaterThan(100);
|
|
42
60
|
});
|
|
@@ -54,6 +72,25 @@ describe('adapter imports use package exports', () => {
|
|
|
54
72
|
}
|
|
55
73
|
expect(violations).toEqual([]);
|
|
56
74
|
});
|
|
75
|
+
it('non-test adapters only import node builtins, relative modules, or opencli public APIs', () => {
|
|
76
|
+
const violations = [];
|
|
77
|
+
for (const file of runtimeAdapterFiles) {
|
|
78
|
+
const source = fs.readFileSync(file, 'utf-8');
|
|
79
|
+
const module = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
|
|
80
|
+
for (const stmt of module.statements) {
|
|
81
|
+
if (!ts.isImportDeclaration(stmt) && !ts.isExportDeclaration(stmt))
|
|
82
|
+
continue;
|
|
83
|
+
const specifier = stmt.moduleSpecifier?.getText(module).slice(1, -1);
|
|
84
|
+
if (specifier && !isAllowedImport(specifier)) {
|
|
85
|
+
violations.push({
|
|
86
|
+
file: path.relative(ROOT, file),
|
|
87
|
+
specifier,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
expect(violations).toEqual([]);
|
|
93
|
+
});
|
|
57
94
|
});
|
|
58
95
|
describe('package.json exports resolve to real files', () => {
|
|
59
96
|
const pkgJson = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf-8'));
|
package/dist/src/utils.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared utility functions used across the codebase.
|
|
3
3
|
*/
|
|
4
|
+
import TurndownService from 'turndown';
|
|
4
5
|
/** Type guard: checks if a value is a non-null, non-array object. */
|
|
5
6
|
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
6
7
|
/** Simple async concurrency limiter. */
|
|
@@ -9,3 +10,5 @@ export declare function mapConcurrent<T, R>(items: T[], limit: number, fn: (item
|
|
|
9
10
|
export declare function sleep(ms: number): Promise<void>;
|
|
10
11
|
/** Save a base64-encoded string to a file, creating parent directories as needed. */
|
|
11
12
|
export declare function saveBase64ToFile(base64: string, filePath: string): Promise<void>;
|
|
13
|
+
export declare function createMarkdownConverter(configure?: (td: TurndownService) => void): TurndownService;
|
|
14
|
+
export declare function htmlToMarkdown(value: string, configure?: (td: TurndownService) => void): string;
|
package/dist/src/utils.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import * as fs from 'node:fs';
|
|
5
5
|
import * as path from 'node:path';
|
|
6
|
+
import TurndownService from 'turndown';
|
|
6
7
|
/** Type guard: checks if a value is a non-null, non-array object. */
|
|
7
8
|
export function isRecord(value) {
|
|
8
9
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -31,3 +32,24 @@ export async function saveBase64ToFile(base64, filePath) {
|
|
|
31
32
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
32
33
|
await fs.promises.writeFile(filePath, Buffer.from(base64, 'base64'));
|
|
33
34
|
}
|
|
35
|
+
export function createMarkdownConverter(configure) {
|
|
36
|
+
const td = new TurndownService({
|
|
37
|
+
headingStyle: 'atx',
|
|
38
|
+
codeBlockStyle: 'fenced',
|
|
39
|
+
bulletListMarker: '-',
|
|
40
|
+
});
|
|
41
|
+
td.addRule('linebreak', {
|
|
42
|
+
filter: 'br',
|
|
43
|
+
replacement: () => '\n',
|
|
44
|
+
});
|
|
45
|
+
if (configure)
|
|
46
|
+
configure(td);
|
|
47
|
+
return td;
|
|
48
|
+
}
|
|
49
|
+
export function htmlToMarkdown(value, configure) {
|
|
50
|
+
return createMarkdownConverter(configure).turndown(value || '')
|
|
51
|
+
.replace(/\u00a0/g, ' ')
|
|
52
|
+
.replace(/\n{4,}/g, '\n\n\n')
|
|
53
|
+
.replace(/[ \t]+$/gm, '')
|
|
54
|
+
.trim();
|
|
55
|
+
}
|