@tokenroll/joplo-cli 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 +55 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +13 -0
- package/dist/check.d.ts +4 -0
- package/dist/check.js +28 -0
- package/dist/cli.d.ts +10 -0
- package/dist/cli.js +153 -0
- package/dist/common.d.ts +5 -0
- package/dist/common.js +12 -0
- package/dist/init.d.ts +21 -0
- package/dist/init.js +102 -0
- package/dist/pm.d.ts +12 -0
- package/dist/pm.js +26 -0
- package/dist/selection.d.ts +10 -0
- package/dist/selection.js +41 -0
- package/dist/skill.d.ts +11 -0
- package/dist/skill.js +153 -0
- package/dist/startup.d.ts +5 -0
- package/dist/startup.js +68 -0
- package/package.json +41 -3
- package/README.md +0 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Apache License
|
|
2
|
+
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
|
|
5
|
+
http://www.apache.org/licenses/
|
|
6
|
+
|
|
7
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
8
|
+
|
|
9
|
+
1. Definitions.
|
|
10
|
+
|
|
11
|
+
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
|
|
14
|
+
|
|
15
|
+
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
|
|
16
|
+
|
|
17
|
+
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
|
|
18
|
+
|
|
19
|
+
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
|
|
20
|
+
|
|
21
|
+
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
|
|
22
|
+
|
|
23
|
+
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
|
|
24
|
+
|
|
25
|
+
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
|
|
26
|
+
|
|
27
|
+
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
|
|
28
|
+
|
|
29
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
|
|
30
|
+
|
|
31
|
+
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
|
|
32
|
+
|
|
33
|
+
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
|
|
34
|
+
|
|
35
|
+
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
|
|
36
|
+
|
|
37
|
+
You must give any other recipients of the Work or Derivative Works a copy of this License; and
|
|
38
|
+
|
|
39
|
+
You must cause any modified files to carry prominent notices stating that You changed the files; and
|
|
40
|
+
|
|
41
|
+
You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
|
|
42
|
+
|
|
43
|
+
If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
|
|
44
|
+
|
|
45
|
+
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
|
|
46
|
+
|
|
47
|
+
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
|
|
48
|
+
|
|
49
|
+
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
|
|
50
|
+
|
|
51
|
+
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
|
|
52
|
+
|
|
53
|
+
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
|
|
54
|
+
|
|
55
|
+
END OF TERMS AND CONDITIONS
|
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @tokenroll/joplo-cli 的命令入口。通常经由 `joplo` 包(packages/joplo)转发到这里;也可以直接运行。
|
|
4
|
+
* 先检查 Node 版本再载入其余模块(发布构建会把 shebang 改为 node)。
|
|
5
|
+
*/
|
|
6
|
+
const major = Number(/^(\d+)\./.exec(process.versions.node)?.[1] ?? 0);
|
|
7
|
+
if (major < 22) {
|
|
8
|
+
process.stderr.write(`✗ Joplo 需要 Node.js 22 或更高版本,当前是 ${process.versions.node}。\n 升级:nvm install 22,或从 https://nodejs.org 下载。\n`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
const { runCli } = await import("./cli.js");
|
|
12
|
+
process.exitCode = await runCli(process.argv.slice(2));
|
|
13
|
+
export {};
|
package/dist/check.d.ts
ADDED
package/dist/check.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { realpath } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
|
+
import { tsImport } from 'tsx/esm/api';
|
|
5
|
+
import { compile, validate } from '@tokenroll/joplo-core';
|
|
6
|
+
import { loadAuthoring } from '@tokenroll/joplo-core/node';
|
|
7
|
+
export async function check(dir, optionId) {
|
|
8
|
+
const root = await realpath(resolve(dir));
|
|
9
|
+
const tsconfig = join(root, 'tsconfig.json');
|
|
10
|
+
const loaded = await loadAuthoring(root, p => tsImport(p, { parentURL: import.meta.url, tsconfig: existsSync(tsconfig) ? tsconfig : false }));
|
|
11
|
+
const compiled = loaded.authoring ? compile(loaded.authoring, 'dev') : null;
|
|
12
|
+
const validated = compiled?.project ? validate(compiled.project, { optionId, mode: 'all' }) : null;
|
|
13
|
+
// 编译器也会为同一布局的每份画面输出相同诊断;在 CLI 边界统一去重。
|
|
14
|
+
const seen = new Set();
|
|
15
|
+
const diagnostics = [...loaded.diagnostics, ...(compiled?.diagnostics ?? []), ...(validated?.diagnostics ?? [])].map(d => ({ ...d, at: d.at ?? 'joplo.config.ts:1:1' })).filter(d => {
|
|
16
|
+
const key = JSON.stringify([d.at, d.code, d.message, d.severity]);
|
|
17
|
+
if (seen.has(key))
|
|
18
|
+
return false;
|
|
19
|
+
seen.add(key);
|
|
20
|
+
return true;
|
|
21
|
+
});
|
|
22
|
+
return {
|
|
23
|
+
name: compiled?.project?.name ?? loaded.authoring?.config.name ?? root,
|
|
24
|
+
diagnostics,
|
|
25
|
+
summary: { error: diagnostics.filter(d => d.severity === 'error').length, warning: diagnostics.filter(d => d.severity === 'warning').length, info: diagnostics.filter(d => d.severity === 'info').length },
|
|
26
|
+
verification: validated?.verification ?? {},
|
|
27
|
+
};
|
|
28
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
type Dependencies = {
|
|
2
|
+
stdout?: (s: string) => void;
|
|
3
|
+
stderr?: (s: string) => void;
|
|
4
|
+
loadServer?: () => Promise<Record<string, unknown>>;
|
|
5
|
+
/** 测试用:覆盖 CLI 自己的版本(缺省读 package.json)。 */
|
|
6
|
+
cliVersion?: string;
|
|
7
|
+
};
|
|
8
|
+
/** 每次创建独立 commander 实例,测试无需启动真实服务或占用固定端口。 */
|
|
9
|
+
export declare function runCli(args: string[], deps?: Dependencies): Promise<number>;
|
|
10
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
import { Command, CommanderError, Option } from 'commander';
|
|
10
|
+
import { resolve } from 'node:path';
|
|
11
|
+
import { COMPONENT_DOCS, guide, JoploError, skillIndex, skillTopic } from '@tokenroll/joplo-core';
|
|
12
|
+
import { check } from "./check.js";
|
|
13
|
+
import { initProject, initSummary } from "./init.js";
|
|
14
|
+
import { projectNotes } from "./startup.js";
|
|
15
|
+
import { readSelection } from "./selection.js";
|
|
16
|
+
import { installSkill, readCliVersion } from "./skill.js";
|
|
17
|
+
import { diagnosticLine, failure, oneLine, result } from "./common.js";
|
|
18
|
+
const integer = (name, max = Number.MAX_SAFE_INTEGER) => (text) => {
|
|
19
|
+
const n = Number(text);
|
|
20
|
+
if (!/^\d+$/.test(text) || !Number.isSafeInteger(n) || n < 1 || n > max)
|
|
21
|
+
throw new JoploError('USAGE', `${name} 必须是 1–${max} 的整数`);
|
|
22
|
+
return n;
|
|
23
|
+
};
|
|
24
|
+
const scale = (text) => {
|
|
25
|
+
const n = Number(text);
|
|
26
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
27
|
+
throw new JoploError('USAGE', 'scale 必须为正数');
|
|
28
|
+
return n;
|
|
29
|
+
};
|
|
30
|
+
/** 每次创建独立 commander 实例,测试无需启动真实服务或占用固定端口。 */
|
|
31
|
+
export async function runCli(args, deps = {}) {
|
|
32
|
+
const stdout = deps.stdout ?? (s => { process.stdout.write(s); });
|
|
33
|
+
const stderr = deps.stderr ?? (s => { process.stderr.write(s); });
|
|
34
|
+
const line = (s) => stdout(s + '\n');
|
|
35
|
+
const json = (value) => line(JSON.stringify(value));
|
|
36
|
+
let exitCode = 0;
|
|
37
|
+
const serverPackage = '@tokenroll/joplo-server';
|
|
38
|
+
const loadServer = deps.loadServer ?? (() => import(__rewriteRelativeImportExtension(serverPackage)));
|
|
39
|
+
async function serverFunction(name) {
|
|
40
|
+
let module;
|
|
41
|
+
try {
|
|
42
|
+
module = await loadServer();
|
|
43
|
+
}
|
|
44
|
+
catch (e) {
|
|
45
|
+
throw new JoploError('UNSUPPORTED_ACTION', `@tokenroll/joplo-server 无法加载 ${name}:${e instanceof Error ? e.message : String(e)}`);
|
|
46
|
+
}
|
|
47
|
+
if (typeof module[name] !== 'function')
|
|
48
|
+
throw new JoploError('UNSUPPORTED_ACTION', `@tokenroll/joplo-server 尚未提供 ${name};请使用实现 IR v2 服务契约的版本`);
|
|
49
|
+
return module[name];
|
|
50
|
+
}
|
|
51
|
+
const program = new Command().name('joplo').description('Joplo:用 TSX 创作、演示和发布功能设计').exitOverride()
|
|
52
|
+
.configureOutput({ writeOut: stdout, writeErr: stderr, outputError: () => { } });
|
|
53
|
+
// 读取项目的命令先检查项目里的 @tokenroll/joplo-kit 与 CLI 是否同一版本(只提示,不阻止)
|
|
54
|
+
program.hook('preAction', (_root, action) => {
|
|
55
|
+
for (const note of projectNotes(resolve(String(action.args[0] ?? '.')), action.name(), { cli: deps.cliVersion }))
|
|
56
|
+
stderr(note + '\n');
|
|
57
|
+
});
|
|
58
|
+
const format = () => new Option('--format <format>', '输出格式').choices(['text', 'json']).default('text');
|
|
59
|
+
program.command('check').argument('[dir]', '项目目录', '.').addOption(format()).option('--option <qid>', '仅校验指定方案').action(async (dir, opts) => {
|
|
60
|
+
const report = await check(dir, opts.option);
|
|
61
|
+
const summary = `${report.summary.error} error, ${report.summary.warning} warning, ${report.summary.info} info`;
|
|
62
|
+
if (opts.format === 'json') {
|
|
63
|
+
if (report.summary.error)
|
|
64
|
+
json({ ...failure(new JoploError('INVALID_SCHEMA', summary, { diagnostics: report.diagnostics })), data: report });
|
|
65
|
+
else
|
|
66
|
+
json(result(report.name, summary, report, report.diagnostics));
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
for (const d of report.diagnostics)
|
|
70
|
+
line(diagnosticLine(d));
|
|
71
|
+
line(summary);
|
|
72
|
+
}
|
|
73
|
+
if (report.summary.error)
|
|
74
|
+
exitCode = 1;
|
|
75
|
+
});
|
|
76
|
+
program.command('selection').argument('[dir]', '项目目录', '.').addOption(format()).action(async (dir, opts) => {
|
|
77
|
+
const data = await readSelection(dir);
|
|
78
|
+
const summary = data.selection ? `${data.selection.at ?? '无源位置'} ${oneLine(data.selection.label ?? '')}` : '当前没有选中';
|
|
79
|
+
if (opts.format === 'json')
|
|
80
|
+
json(result(resolve(dir), summary, data));
|
|
81
|
+
else {
|
|
82
|
+
line(summary);
|
|
83
|
+
for (const l of data.lines)
|
|
84
|
+
line(`${l.number} | ${l.text}`);
|
|
85
|
+
if (data.note)
|
|
86
|
+
line(data.note);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
program.command('init').argument('<dir>').option('--name <name>', '项目名(默认目录名)').option('--keep', '目录非空时只补缺失的文件,已有文件不改动')
|
|
90
|
+
.action(async (dir, opts) => { for (const l of initSummary(await initProject(dir, opts.name, { keep: opts.keep })))
|
|
91
|
+
line(l); });
|
|
92
|
+
program.command('skill').description('按需读取 Agent 用法,或安装用法入口')
|
|
93
|
+
.argument('[topic]', 'authoring/components/cases/check/selection/publish,或 install')
|
|
94
|
+
.argument('[dir]', 'install 的目标项目目录')
|
|
95
|
+
.option('--force', '覆盖被修改过的 SKILL.md')
|
|
96
|
+
.option('--diff', '只显示 unified diff,不写入')
|
|
97
|
+
.option('--agents-md', '即使 AGENTS.md 不存在也创建托管段')
|
|
98
|
+
.action(async (topic, dir, opts) => {
|
|
99
|
+
if (topic === 'install') {
|
|
100
|
+
const installed = await installSkill(dir ?? '.', await readCliVersion(), opts);
|
|
101
|
+
stdout(installed.output);
|
|
102
|
+
exitCode = installed.code;
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
if (dir !== undefined || opts.force || opts.diff || opts.agentsMd)
|
|
106
|
+
throw new JoploError('USAGE', 'dir、--force、--diff、--agents-md 只适用于 joplo skill install');
|
|
107
|
+
stdout(topic === undefined ? skillIndex(await readCliVersion(), COMPONENT_DOCS) : skillTopic(topic, COMPONENT_DOCS));
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
program.command('guide').description('joplo skill authoring 的兼容别名').action(() => { stdout(guide()); });
|
|
111
|
+
program.command('dev').argument('[dir]', '项目目录', '.').option('--port <port>', '端口(默认 4790)', integer('port', 65535)).option('--open', '打开浏览器').action(async (dir, opts) => {
|
|
112
|
+
const startDev = await serverFunction('startDev');
|
|
113
|
+
const handle = await startDev({ projectDir: resolve(dir), port: opts.port, open: opts.open, log: s => stderr(oneLine(s) + '\n') });
|
|
114
|
+
line(handle.url);
|
|
115
|
+
});
|
|
116
|
+
program.command('build').argument('[dir]', '项目目录', '.').option('--out-dir <dir>', '输出目录(默认项目 dist)').option('--overwrite', '允许清空非空输出目录').action(async (dir, opts) => {
|
|
117
|
+
const buildSite = await serverFunction('buildSite');
|
|
118
|
+
const data = await buildSite({ projectDir: resolve(dir), outDir: opts.outDir === undefined ? undefined : resolve(opts.outDir), overwrite: opts.overwrite });
|
|
119
|
+
for (const d of data.diagnostics)
|
|
120
|
+
line(diagnosticLine(d));
|
|
121
|
+
line(`已构建 ${data.outDir}(${data.files.length} 文件)`);
|
|
122
|
+
if (data.diagnostics.some(d => d.severity === 'error'))
|
|
123
|
+
exitCode = 1;
|
|
124
|
+
});
|
|
125
|
+
program.command('render').argument('[dir]', '项目目录', '.').requiredOption('--scenario-id <qid>', '场景 ID').option('--step-id <qid>', '步骤执行后的位置;默认初始状态')
|
|
126
|
+
.addOption(new Option('--theme <theme>').choices(['sketch', 'clean'])).requiredOption('--out <file>', '截图文件')
|
|
127
|
+
.option('--width <pixels>', '视口宽度,需同时提供 height', integer('width')).option('--height <pixels>', '视口高度,需同时提供 width', integer('height'))
|
|
128
|
+
.option('--scale <scale>', '像素倍率', scale).option('--overwrite', '允许覆盖截图').action(async (dir, opts) => {
|
|
129
|
+
if ((opts.width === undefined) !== (opts.height === undefined))
|
|
130
|
+
throw new JoploError('USAGE', 'width 与 height 必须同时提供');
|
|
131
|
+
const renderStep = await serverFunction('renderStep');
|
|
132
|
+
const data = await renderStep({ projectDir: resolve(dir), scenarioId: opts.scenarioId, stepId: opts.stepId, theme: opts.theme, out: resolve(opts.out),
|
|
133
|
+
viewport: opts.width === undefined ? undefined : { width: opts.width, height: opts.height }, scale: opts.scale, overwrite: opts.overwrite });
|
|
134
|
+
line(data.path);
|
|
135
|
+
for (const warning of data.warnings)
|
|
136
|
+
stderr(oneLine(warning) + '\n');
|
|
137
|
+
});
|
|
138
|
+
try {
|
|
139
|
+
await program.parseAsync(args, { from: 'user' });
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
if (e instanceof CommanderError && ['commander.helpDisplayed', 'commander.help', 'commander.version'].includes(e.code))
|
|
143
|
+
return 0;
|
|
144
|
+
const f = failure(e instanceof CommanderError ? new JoploError('USAGE', e.message) : e);
|
|
145
|
+
const wantsJson = args.some((arg, i) => arg === '--format=json' || arg === '--format' && args[i + 1] === 'json');
|
|
146
|
+
if (wantsJson)
|
|
147
|
+
json(f);
|
|
148
|
+
else
|
|
149
|
+
line(`${f.error.code} ${oneLine(f.error.message)}`);
|
|
150
|
+
return f.error.code === 'USAGE' ? 2 : 1;
|
|
151
|
+
}
|
|
152
|
+
return exitCode;
|
|
153
|
+
}
|
package/dist/common.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { type Diagnostic, type DomainFailure, type DomainResult } from '@tokenroll/joplo-core';
|
|
2
|
+
export declare function result<T>(projectId: string, summary: string, data: T, diagnostics?: Diagnostic[]): DomainResult<T>;
|
|
3
|
+
export declare function failure(e: unknown): DomainFailure;
|
|
4
|
+
export declare const oneLine: (s: string) => string;
|
|
5
|
+
export declare const diagnosticLine: (d: Diagnostic) => string;
|
package/dist/common.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { JoploError } from '@tokenroll/joplo-core';
|
|
3
|
+
export function result(projectId, summary, data, diagnostics = []) {
|
|
4
|
+
return { ok: true, projectId, revision: 0, requestId: randomUUID(), summary, changedIds: [], diagnostics, artifacts: [], data };
|
|
5
|
+
}
|
|
6
|
+
export function failure(e) {
|
|
7
|
+
const code = e?.code;
|
|
8
|
+
const error = e instanceof JoploError ? e : new JoploError(['ENOENT', 'ENOTDIR'].includes(code ?? '') ? 'NOT_FOUND' : ['EACCES', 'EPERM'].includes(code ?? '') ? 'PERMISSION_DENIED' : e instanceof SyntaxError ? 'INVALID_SCHEMA' : 'UNSUPPORTED_ACTION', e instanceof Error ? e.message : String(e));
|
|
9
|
+
return { ok: false, requestId: randomUUID(), error: { code: error.code, message: error.message, ...(error.details === undefined ? {} : { details: error.details }) }, diagnostics: error.diagnostics };
|
|
10
|
+
}
|
|
11
|
+
export const oneLine = (s) => s.replace(/[\r\n\t]+/g, ' ');
|
|
12
|
+
export const diagnosticLine = (d) => `${d.at ?? 'joplo.config.ts:1:1'} ${d.severity} ${d.code} ${oneLine(d.message)}`;
|
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type PackageManager } from './pm.js';
|
|
2
|
+
export type InitResult = {
|
|
3
|
+
root: string;
|
|
4
|
+
written: string[];
|
|
5
|
+
skipped: string[];
|
|
6
|
+
version: string;
|
|
7
|
+
};
|
|
8
|
+
/** 目录名 → 合法的 npm 包名(小写、无空格;无法推导时用 joplo-demo)。 */
|
|
9
|
+
export declare function packageName(dirName: string): string;
|
|
10
|
+
/** init 写入的 package.json:joplo 与 @tokenroll/joplo-kit 锁定为当前 CLI 的精确版本(D36:所有包同一版本)。 */
|
|
11
|
+
export declare function projectPackageJson(dirName: string, version: string): string;
|
|
12
|
+
/** init 写入的 tsconfig.json:作者的 TSX 由 @tokenroll/joplo-kit 的 JSX 运行时收集成数据。 */
|
|
13
|
+
export declare function projectTsconfig(): string;
|
|
14
|
+
/** init 结尾的一行:进入目录、安装、打开看板(与用户的包管理器一致)。 */
|
|
15
|
+
export declare function nextStepLine(pm: PackageManager, relDir: string): string;
|
|
16
|
+
export declare function initProject(dir: string, name?: string, opts?: {
|
|
17
|
+
keep?: boolean;
|
|
18
|
+
env?: Record<string, string | undefined>;
|
|
19
|
+
}): Promise<InitResult>;
|
|
20
|
+
/** `joplo init` 的输出(最后一行是下一步命令)。 */
|
|
21
|
+
export declare function initSummary(r: InitResult, env?: Record<string, string | undefined>): string[];
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
3
|
+
import { JoploError } from '@tokenroll/joplo-core';
|
|
4
|
+
import { detectPackageManager, pmCommands } from "./pm.js";
|
|
5
|
+
import { cliVersion } from "./startup.js";
|
|
6
|
+
/** 目录名 → 合法的 npm 包名(小写、无空格;无法推导时用 joplo-demo)。 */
|
|
7
|
+
export function packageName(dirName) {
|
|
8
|
+
const n = dirName.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[._-]+|[-._]+$/g, '').slice(0, 214);
|
|
9
|
+
return n || 'joplo-demo';
|
|
10
|
+
}
|
|
11
|
+
/** init 写入的 package.json:joplo 与 @tokenroll/joplo-kit 锁定为当前 CLI 的精确版本(D36:所有包同一版本)。 */
|
|
12
|
+
export function projectPackageJson(dirName, version) {
|
|
13
|
+
const pkg = {
|
|
14
|
+
name: packageName(dirName),
|
|
15
|
+
private: true,
|
|
16
|
+
type: 'module',
|
|
17
|
+
scripts: { dev: 'joplo dev', check: 'joplo check', build: 'joplo build' },
|
|
18
|
+
devDependencies: { joplo: version, '@tokenroll/joplo-kit': version },
|
|
19
|
+
};
|
|
20
|
+
return JSON.stringify(pkg, null, 2) + '\n';
|
|
21
|
+
}
|
|
22
|
+
/** init 写入的 tsconfig.json:作者的 TSX 由 @tokenroll/joplo-kit 的 JSX 运行时收集成数据。 */
|
|
23
|
+
export function projectTsconfig() {
|
|
24
|
+
const compilerOptions = {
|
|
25
|
+
target: 'ES2023',
|
|
26
|
+
module: 'ESNext',
|
|
27
|
+
moduleResolution: 'Bundler',
|
|
28
|
+
jsx: 'react-jsxdev',
|
|
29
|
+
jsxImportSource: '@tokenroll/joplo-kit',
|
|
30
|
+
allowImportingTsExtensions: true,
|
|
31
|
+
noEmit: true,
|
|
32
|
+
strict: true,
|
|
33
|
+
skipLibCheck: true,
|
|
34
|
+
};
|
|
35
|
+
return JSON.stringify({ compilerOptions, include: ['**/*.ts', '**/*.tsx'], exclude: ['node_modules', 'dist', '.joplo'] }, null, 2) + '\n';
|
|
36
|
+
}
|
|
37
|
+
const shellArg = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
38
|
+
/** init 结尾的一行:进入目录、安装、打开看板(与用户的包管理器一致)。 */
|
|
39
|
+
export function nextStepLine(pm, relDir) {
|
|
40
|
+
const c = pmCommands(pm);
|
|
41
|
+
const steps = [...(relDir && relDir !== '.' ? [`cd ${shellArg(relDir)}`] : []), c.install, `${c.run} dev`];
|
|
42
|
+
return `下一步:${steps.join(' && ')}`;
|
|
43
|
+
}
|
|
44
|
+
export async function initProject(dir, name, opts = {}) {
|
|
45
|
+
const root = resolve(dir);
|
|
46
|
+
const version = cliVersion();
|
|
47
|
+
if (!opts.keep) {
|
|
48
|
+
let entries = [];
|
|
49
|
+
try {
|
|
50
|
+
entries = await readdir(root);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e.code !== 'ENOENT')
|
|
54
|
+
throw e;
|
|
55
|
+
}
|
|
56
|
+
if (entries.length) {
|
|
57
|
+
const rel = relative(process.cwd(), root) || '.';
|
|
58
|
+
const run = pmCommands(detectPackageManager(opts.env ?? process.env)).run;
|
|
59
|
+
throw new JoploError('UNSUPPORTED_ACTION', `${rel}/ 不是空目录(已有 ${entries.length} 个文件),没有写入任何内容。换一个目录名,或进入它后运行 ${run} init . --keep 只补缺失的文件。`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const files = {
|
|
63
|
+
'package.json': projectPackageJson(basename(root), version),
|
|
64
|
+
'joplo.config.ts': `import { config } from '@tokenroll/joplo-kit';\nexport default config({ name: ${JSON.stringify(name ?? (basename(root) || 'Joplo'))}, layout: 'app', home: '/' });\n`,
|
|
65
|
+
'layouts/app.tsx': `import { layout, Outlet, Slot } from '@tokenroll/joplo-kit';\nimport { AppShell } from '@tokenroll/joplo-kit/ui';\nexport default layout(\n <AppShell padding="lg">\n <AppShell.Main>\n <Outlet />\n </AppShell.Main>\n <Slot name="overlay" />\n </AppShell>,\n);\n`,
|
|
66
|
+
'routes/page.tsx': `import { page, Slot } from '@tokenroll/joplo-kit';\nimport { Group, Stack, Title } from '@tokenroll/joplo-kit/ui';\nexport default page(\n { title: '首页' },\n <Stack gap="md">\n <Title order={2}>开始设计</Title>\n <Group gap="sm">\n <Slot name="actions" />\n </Group>\n </Stack>,\n);\n`,
|
|
67
|
+
'features/example/feature.ts': `import { feature } from '@tokenroll/joplo-kit';\nexport default feature({ name: '示例功能', goal: '点击按钮完成演示', cases: { happy: '正常完成' }, pick: 'a' });\n`,
|
|
68
|
+
'features/example/options/a.tsx': `import { option, flow, done } from '@tokenroll/joplo-kit';\nimport { Button } from '@tokenroll/joplo-kit/ui';\nexport default option({\n name: '方案 A', idea: '一个按钮完成操作',\n on: { '/': { actions: <Button id="start">开始</Button> } },\n scenarios: { happy: flow('正常完成', { cases: ['happy'], start: '/' }).click('start', done('已完成')) },\n});\n`,
|
|
69
|
+
'tsconfig.json': projectTsconfig(),
|
|
70
|
+
'.gitignore': 'node_modules/\n.joplo/\ndist/\n',
|
|
71
|
+
};
|
|
72
|
+
await mkdir(root, { recursive: true });
|
|
73
|
+
const written = [];
|
|
74
|
+
const skipped = [];
|
|
75
|
+
for (const [path, content] of Object.entries(files)) {
|
|
76
|
+
await mkdir(dirname(join(root, path)), { recursive: true });
|
|
77
|
+
try {
|
|
78
|
+
await writeFile(join(root, path), content, { flag: 'wx' });
|
|
79
|
+
written.push(path);
|
|
80
|
+
}
|
|
81
|
+
catch (e) {
|
|
82
|
+
if (e.code !== 'EEXIST' || !opts.keep)
|
|
83
|
+
throw e;
|
|
84
|
+
skipped.push(path);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { root, written, skipped, version };
|
|
88
|
+
}
|
|
89
|
+
/** `joplo init` 的输出(最后一行是下一步命令)。 */
|
|
90
|
+
export function initSummary(r, env = process.env) {
|
|
91
|
+
const rel = relative(process.cwd(), r.root) || '.';
|
|
92
|
+
const top = [...new Set(r.written.map((f) => (f.includes('/') ? `${f.split('/')[0]}/` : f)))].filter((f) => !['package.json', 'tsconfig.json', '.gitignore'].includes(f));
|
|
93
|
+
const lines = [`✓ 已创建 ${rel === '.' ? './' : `${rel}/`}${top.length ? `:${top.join('、')}` : ''}`];
|
|
94
|
+
if (r.written.includes('package.json'))
|
|
95
|
+
lines.push(`✓ package.json devDependencies: { "joplo": "${r.version}", "@tokenroll/joplo-kit": "${r.version}" }(锁定同一版本)`);
|
|
96
|
+
if (r.written.includes('tsconfig.json'))
|
|
97
|
+
lines.push('✓ tsconfig.json jsxImportSource: "@tokenroll/joplo-kit"');
|
|
98
|
+
if (r.skipped.length)
|
|
99
|
+
lines.push(`! 已存在、未改动:${r.skipped.join('、')}${r.skipped.includes('package.json') ? `(请确认 devDependencies 含 "joplo": "${r.version}" 与 "@tokenroll/joplo-kit": "${r.version}")` : ''}`);
|
|
100
|
+
lines.push(nextStepLine(detectPackageManager(env), rel));
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
package/dist/pm.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type PackageManager = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
|
2
|
+
type Env = Record<string, string | undefined>;
|
|
3
|
+
export declare function detectPackageManager(env?: Env, projectDir?: string): PackageManager;
|
|
4
|
+
export type PmCommands = {
|
|
5
|
+
install: string;
|
|
6
|
+
run: string;
|
|
7
|
+
addDev: string;
|
|
8
|
+
};
|
|
9
|
+
export declare const pmCommands: (pm: PackageManager) => PmCommands;
|
|
10
|
+
/** 对齐 joplo 与 @tokenroll/joplo-kit 到同一版本的命令。 */
|
|
11
|
+
export declare const alignCommand: (pm: PackageManager, version: string) => string;
|
|
12
|
+
export {};
|
package/dist/pm.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包管理器识别(init 结尾的下一步命令、版本对齐命令)。
|
|
3
|
+
* 优先看 npm_config_user_agent(npx / pnpm dlx / yarn dlx / bunx 都会设置),其次看项目里的锁文件,缺省 npm。
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
const LOCKFILES = [['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'], ['bun.lock', 'bun'], ['bun.lockb', 'bun'], ['package-lock.json', 'npm']];
|
|
8
|
+
export function detectPackageManager(env = process.env, projectDir) {
|
|
9
|
+
const m = /^(npm|pnpm|yarn|bun)\//.exec(env.npm_config_user_agent ?? '');
|
|
10
|
+
if (m)
|
|
11
|
+
return m[1];
|
|
12
|
+
if (projectDir)
|
|
13
|
+
for (const [file, pm] of LOCKFILES)
|
|
14
|
+
if (existsSync(join(projectDir, file)))
|
|
15
|
+
return pm;
|
|
16
|
+
return 'npm';
|
|
17
|
+
}
|
|
18
|
+
const COMMANDS = {
|
|
19
|
+
npm: { install: 'npm install', run: 'npx joplo', addDev: 'npm i -D' },
|
|
20
|
+
pnpm: { install: 'pnpm install', run: 'pnpm joplo', addDev: 'pnpm add -D' },
|
|
21
|
+
yarn: { install: 'yarn', run: 'yarn joplo', addDev: 'yarn add -D' },
|
|
22
|
+
bun: { install: 'bun install', run: 'bunx joplo', addDev: 'bun add -d' },
|
|
23
|
+
};
|
|
24
|
+
export const pmCommands = (pm) => COMMANDS[pm];
|
|
25
|
+
/** 对齐 joplo 与 @tokenroll/joplo-kit 到同一版本的命令。 */
|
|
26
|
+
export const alignCommand = (pm, version) => `${COMMANDS[pm].addDev} joplo@${version} @tokenroll/joplo-kit@${version}`;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Selection } from '@tokenroll/joplo-core';
|
|
2
|
+
export type SelectionView = {
|
|
3
|
+
selection: Selection | null;
|
|
4
|
+
lines: {
|
|
5
|
+
number: number;
|
|
6
|
+
text: string;
|
|
7
|
+
}[];
|
|
8
|
+
note?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function readSelection(dir: string): Promise<SelectionView>;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { JoploError } from '@tokenroll/joplo-core';
|
|
4
|
+
export async function readSelection(dir) {
|
|
5
|
+
const root = resolve(dir);
|
|
6
|
+
let raw;
|
|
7
|
+
try {
|
|
8
|
+
raw = JSON.parse(await readFile(resolve(root, '.joplo/selection.json'), 'utf8'));
|
|
9
|
+
}
|
|
10
|
+
catch (e) {
|
|
11
|
+
if (e.code === 'ENOENT')
|
|
12
|
+
return { selection: null, lines: [] };
|
|
13
|
+
throw e;
|
|
14
|
+
}
|
|
15
|
+
if (raw === null)
|
|
16
|
+
return { selection: null, lines: [] };
|
|
17
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
18
|
+
throw new JoploError('INVALID_SCHEMA', 'selection.json 应为 Selection 或 null');
|
|
19
|
+
const selection = raw;
|
|
20
|
+
if (selection.at == null)
|
|
21
|
+
return { selection, lines: [], note: '该选中对象没有源位置' };
|
|
22
|
+
const match = typeof selection.at === 'string' && /^(.*):(\d+):(\d+)$/.exec(selection.at);
|
|
23
|
+
if (!match || Number(match[2]) < 1 || Number(match[3]) < 1)
|
|
24
|
+
throw new JoploError('INVALID_SCHEMA', 'selection.at 必须为 文件:行:列');
|
|
25
|
+
const file = resolve(root, match[1]);
|
|
26
|
+
const rel = relative(root, file);
|
|
27
|
+
if (isAbsolute(match[1]) || rel === '..' || rel.startsWith(`..${sep}`))
|
|
28
|
+
throw new JoploError('INVALID_SCHEMA', 'selection.at 必须指向项目内的相对路径');
|
|
29
|
+
try {
|
|
30
|
+
const lines = (await readFile(file, 'utf8')).split(/\r?\n/);
|
|
31
|
+
const line = Number(match[2]);
|
|
32
|
+
if (line > lines.length)
|
|
33
|
+
return { selection, lines: [], note: '源文件已变化,该行不存在' };
|
|
34
|
+
return { selection, lines: lines.slice(Math.max(0, line - 4), line + 3).map((text, i) => ({ number: Math.max(1, line - 3) + i, text })) };
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
if (e.code === 'ENOENT')
|
|
38
|
+
return { selection, lines: [], note: '源文件已不存在' };
|
|
39
|
+
throw e;
|
|
40
|
+
}
|
|
41
|
+
}
|
package/dist/skill.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type InstallSkillOptions = {
|
|
2
|
+
force?: boolean;
|
|
3
|
+
diff?: boolean;
|
|
4
|
+
agentsMd?: boolean;
|
|
5
|
+
};
|
|
6
|
+
/** 每次运行从当前 CLI 的包元数据读取,不另存一份版本常量。 */
|
|
7
|
+
export declare function readCliVersion(file?: URL): Promise<string>;
|
|
8
|
+
export declare function installSkill(dir: string, version: string, options?: InstallSkillOptions): Promise<{
|
|
9
|
+
code: number;
|
|
10
|
+
output: string;
|
|
11
|
+
}>;
|
package/dist/skill.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { access, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { JoploError } from '@tokenroll/joplo-core';
|
|
5
|
+
const SKILL_PATH = '.claude/skills/joplo/SKILL.md';
|
|
6
|
+
const START = '<!-- joplo:start -->';
|
|
7
|
+
const END = '<!-- joplo:end -->';
|
|
8
|
+
/** 每次运行从当前 CLI 的包元数据读取,不另存一份版本常量。 */
|
|
9
|
+
export async function readCliVersion(file = new URL('../package.json', import.meta.url)) {
|
|
10
|
+
const metadata = JSON.parse(await readFile(file, 'utf8'));
|
|
11
|
+
if (!metadata || typeof metadata !== 'object' || !('version' in metadata) || typeof metadata.version !== 'string' || !metadata.version.trim())
|
|
12
|
+
throw new JoploError('INVALID_SCHEMA', '无法从 @tokenroll/joplo-cli package.json 读取版本');
|
|
13
|
+
return metadata.version;
|
|
14
|
+
}
|
|
15
|
+
function pointer(version) {
|
|
16
|
+
const body = [
|
|
17
|
+
'---',
|
|
18
|
+
'name: joplo',
|
|
19
|
+
'description: 在 Joplo 项目(有 joplo.config.ts)里设计功能原型、方案和演示时使用',
|
|
20
|
+
'---',
|
|
21
|
+
'这个项目用 Joplo 描述功能原型。不要凭记忆写,先按需读取用法:',
|
|
22
|
+
'- npx joplo skill authoring 写页面、方案、场景',
|
|
23
|
+
'- npx joplo skill components 可用组件和属性',
|
|
24
|
+
'- npx joplo skill cases 开工前用情况清单主动提问',
|
|
25
|
+
'其他任务运行 npx joplo skill,选择 check、selection 或 publish 主题。',
|
|
26
|
+
'改完运行 npx joplo check,按 文件:行 修改。',
|
|
27
|
+
'用户在看板选中元素后,运行 npx joplo selection,只改那一处。',
|
|
28
|
+
'',
|
|
29
|
+
].join('\n');
|
|
30
|
+
return Buffer.from(`${body}<!-- joplo-skill ${version} sha256:${hash(Buffer.from(body))} -->\n`);
|
|
31
|
+
}
|
|
32
|
+
const hash = (body) => createHash('sha256').update(body).digest('hex');
|
|
33
|
+
function untouched(content) {
|
|
34
|
+
const text = content.toString('utf8');
|
|
35
|
+
const marker = /<!-- joplo-skill [^\s]+ sha256:([a-f0-9]{64}) -->\n$/.exec(text);
|
|
36
|
+
if (!marker || marker.index === 0 || text[marker.index - 1] !== '\n')
|
|
37
|
+
return false;
|
|
38
|
+
const offset = content.length - Buffer.byteLength(text.slice(marker.index));
|
|
39
|
+
return hash(content.subarray(0, offset)) === marker[1];
|
|
40
|
+
}
|
|
41
|
+
async function optionalFile(path) {
|
|
42
|
+
try {
|
|
43
|
+
return await readFile(path);
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
if (e.code === 'ENOENT')
|
|
47
|
+
return null;
|
|
48
|
+
throw e;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function agentsContent(current) {
|
|
52
|
+
const block = Buffer.from(`${START}\n## Joplo\n在有 joplo.config.ts 的项目中设计功能原型时,先运行 npx joplo skill 查看主题,再按需读取。\n写页面/方案/场景读 npx joplo skill authoring;组件查 components,开工前的情况提问查 cases。\n改完运行 npx joplo check;用户在看板选中后运行 npx joplo selection,只改指向的那一处。\n${END}`);
|
|
53
|
+
if (current === null)
|
|
54
|
+
return Buffer.concat([block, Buffer.from('\n')]);
|
|
55
|
+
const start = current.indexOf(START), end = current.indexOf(END);
|
|
56
|
+
if (start === -1 && end === -1)
|
|
57
|
+
return Buffer.concat([current, Buffer.from(current.length ? current.at(-1) === 10 ? '\n' : '\n\n' : ''), block, Buffer.from('\n')]);
|
|
58
|
+
if (start < 0 || end < start || current.indexOf(START, start + START.length) !== -1 || current.indexOf(END, end + END.length) !== -1)
|
|
59
|
+
throw new JoploError('UNSUPPORTED_ACTION', 'AGENTS.md 的 joplo:start/end 标记不成对或重复,请先修正;没有写入任何文件');
|
|
60
|
+
// Buffer 切片保留托管段之外的原始字节,包括 BOM、CRLF、非 UTF-8 内容。
|
|
61
|
+
return Buffer.concat([current.subarray(0, start), block, current.subarray(end + Buffer.byteLength(END))]);
|
|
62
|
+
}
|
|
63
|
+
export async function installSkill(dir, version, options = {}) {
|
|
64
|
+
const root = resolve(dir);
|
|
65
|
+
const lines = [];
|
|
66
|
+
try {
|
|
67
|
+
await access(join(root, 'joplo.config.ts'));
|
|
68
|
+
}
|
|
69
|
+
catch (e) {
|
|
70
|
+
if (e.code !== 'ENOENT')
|
|
71
|
+
throw e;
|
|
72
|
+
lines.push('警告:目录中没有 joplo.config.ts,仍将安装 Joplo 用法入口。');
|
|
73
|
+
}
|
|
74
|
+
const path = join(root, SKILL_PATH);
|
|
75
|
+
const current = await optionalFile(path);
|
|
76
|
+
const next = pointer(version);
|
|
77
|
+
// 用户修改保护先于所有写入,AGENTS.md 也不会被部分更新。
|
|
78
|
+
if (!options.diff && !options.force && current !== null && !untouched(current))
|
|
79
|
+
return {
|
|
80
|
+
code: 1,
|
|
81
|
+
output: [...lines, `${SKILL_PATH} 已存在,并且被修改过,没有覆盖。`, `查看差异:npx joplo skill install ${displayDir(dir)}--diff`, `覆盖:npx joplo skill install ${displayDir(dir)}--force`, ''].join('\n'),
|
|
82
|
+
};
|
|
83
|
+
const agentsPath = join(root, 'AGENTS.md');
|
|
84
|
+
const agents = await optionalFile(agentsPath);
|
|
85
|
+
const planned = [{ name: SKILL_PATH, path, current, next }];
|
|
86
|
+
if (agents !== null || options.agentsMd)
|
|
87
|
+
planned.push({ name: 'AGENTS.md', path: agentsPath, current: agents, next: agentsContent(agents) });
|
|
88
|
+
const changed = planned.filter(file => file.current === null || !file.current.equals(file.next));
|
|
89
|
+
if (options.diff) {
|
|
90
|
+
for (const file of changed)
|
|
91
|
+
lines.push(unifiedDiff(file.name, file.current, file.next));
|
|
92
|
+
if (!changed.length)
|
|
93
|
+
lines.push('已是最新');
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
// 再读一次以避免覆盖计划阶段之后发生的编辑;diff 路径完全不创建目录。
|
|
97
|
+
for (const file of changed) {
|
|
98
|
+
const latest = await optionalFile(file.path);
|
|
99
|
+
if (file.current === null ? latest !== null : latest === null || !file.current.equals(latest))
|
|
100
|
+
throw new JoploError('UNSUPPORTED_ACTION', `${file.name} 在安装期间被修改,没有覆盖;请重新运行查看差异`);
|
|
101
|
+
}
|
|
102
|
+
for (const file of changed) {
|
|
103
|
+
await mkdir(dirname(file.path), { recursive: true });
|
|
104
|
+
await writeFile(file.path, file.next, { flag: file.current === null ? 'wx' : 'w' });
|
|
105
|
+
lines.push(`已写入 ${file.name}${file.name === SKILL_PATH ? '(仅用法入口)' : '(仅 Joplo 托管段)'}`);
|
|
106
|
+
}
|
|
107
|
+
if (!changed.length)
|
|
108
|
+
lines.push('已是最新');
|
|
109
|
+
else
|
|
110
|
+
lines.push('提示:把用法入口提交到 Git,项目中的 Agent 即可发现。');
|
|
111
|
+
}
|
|
112
|
+
return { code: 0, output: lines.join('\n') + '\n' };
|
|
113
|
+
}
|
|
114
|
+
function displayDir(dir) {
|
|
115
|
+
return dir === '.' ? '' : "'" + dir.replace(/'/g, "'\\''") + "' ";
|
|
116
|
+
}
|
|
117
|
+
/** 单个连续 hunk,保留前后 3 行上下文;不依赖 Git/diff,不输出颜色。 */
|
|
118
|
+
function unifiedDiff(name, before, after) {
|
|
119
|
+
const split = (buffer) => {
|
|
120
|
+
const text = buffer?.toString('utf8') ?? '';
|
|
121
|
+
const lines = text ? text.split('\n') : [];
|
|
122
|
+
const newline = text.endsWith('\n');
|
|
123
|
+
if (newline)
|
|
124
|
+
lines.pop();
|
|
125
|
+
return { lines, newline };
|
|
126
|
+
};
|
|
127
|
+
const a = split(before), b = split(after);
|
|
128
|
+
const equal = (i, j) => a.lines[i] === b.lines[j] && (i !== a.lines.length - 1 && j !== b.lines.length - 1 || a.newline === b.newline);
|
|
129
|
+
let prefix = 0;
|
|
130
|
+
while (prefix < Math.min(a.lines.length, b.lines.length) && equal(prefix, prefix))
|
|
131
|
+
prefix++;
|
|
132
|
+
let suffix = 0;
|
|
133
|
+
while (suffix < Math.min(a.lines.length, b.lines.length) - prefix && equal(a.lines.length - suffix - 1, b.lines.length - suffix - 1))
|
|
134
|
+
suffix++;
|
|
135
|
+
const start = Math.max(0, prefix - 3);
|
|
136
|
+
const aEnd = a.lines.length - Math.max(0, suffix - 3), bEnd = b.lines.length - Math.max(0, suffix - 3);
|
|
137
|
+
const range = (length) => `${length ? start + 1 : start},${length}`;
|
|
138
|
+
const output = [`--- ${before === null ? '/dev/null' : `a/${name}`}`, `+++ b/${name}`, `@@ -${range(aEnd - start)} +${range(bEnd - start)} @@`];
|
|
139
|
+
const append = (sign, lines, i, newline) => {
|
|
140
|
+
output.push(sign + lines[i]);
|
|
141
|
+
if (i === lines.length - 1 && !newline)
|
|
142
|
+
output.push('\');
|
|
143
|
+
};
|
|
144
|
+
for (let i = start; i < prefix; i++)
|
|
145
|
+
append(' ', a.lines, i, a.newline);
|
|
146
|
+
for (let i = prefix; i < a.lines.length - suffix; i++)
|
|
147
|
+
append('-', a.lines, i, a.newline);
|
|
148
|
+
for (let i = prefix; i < b.lines.length - suffix; i++)
|
|
149
|
+
append('+', b.lines, i, b.newline);
|
|
150
|
+
for (let i = a.lines.length - suffix; i < aEnd; i++)
|
|
151
|
+
append(' ', a.lines, i, a.newline);
|
|
152
|
+
return output.join('\n') + '\n';
|
|
153
|
+
}
|
package/dist/startup.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 启动时的版本一致性检查(D36):CLI 发现项目里的 @tokenroll/joplo-kit 与自己版本不同、或项目声明了依赖却没安装时,
|
|
3
|
+
* 在 stderr 打印一行提示和对齐命令。只对读取项目源文件的命令(check / dev / build / render)检查;任何读取错误都不阻止命令运行。
|
|
4
|
+
*
|
|
5
|
+
* 版本来源:CLI 自己的 package.json(与 skill.ts 的 readCliVersion 同一位置:src/ 与 dist/ 都在包根下一层)。
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { alignCommand, detectPackageManager, pmCommands } from "./pm.js";
|
|
11
|
+
const PROJECT_COMMANDS = new Set(['check', 'dev', 'build', 'render']);
|
|
12
|
+
const readJson = (file) => {
|
|
13
|
+
try {
|
|
14
|
+
const v = JSON.parse(readFileSync(file, 'utf8'));
|
|
15
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : null;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
export function cliVersion() {
|
|
22
|
+
const v = readJson(fileURLToPath(new URL('../package.json', import.meta.url)))?.version;
|
|
23
|
+
return typeof v === 'string' ? v : '0.0.0';
|
|
24
|
+
}
|
|
25
|
+
/** 从 dir 起向上找 node_modules/<name>/package.json(Node 的包解析顺序)。 */
|
|
26
|
+
function findUp(dir, name) {
|
|
27
|
+
let cur = resolve(dir);
|
|
28
|
+
for (;;) {
|
|
29
|
+
const f = join(cur, 'node_modules', name, 'package.json');
|
|
30
|
+
if (existsSync(f))
|
|
31
|
+
return f;
|
|
32
|
+
const parent = dirname(cur);
|
|
33
|
+
if (parent === cur)
|
|
34
|
+
return null;
|
|
35
|
+
cur = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
function findPackageRoot(dir) {
|
|
39
|
+
let cur = resolve(dir);
|
|
40
|
+
for (;;) {
|
|
41
|
+
if (existsSync(join(cur, 'package.json')))
|
|
42
|
+
return cur;
|
|
43
|
+
const parent = dirname(cur);
|
|
44
|
+
if (parent === cur)
|
|
45
|
+
return null;
|
|
46
|
+
cur = parent;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function projectNotes(dir, command, opts = {}) {
|
|
50
|
+
if (!command || !PROJECT_COMMANDS.has(command))
|
|
51
|
+
return [];
|
|
52
|
+
const cli = opts.cli ?? cliVersion();
|
|
53
|
+
const env = opts.env ?? process.env;
|
|
54
|
+
const root = findPackageRoot(dir);
|
|
55
|
+
const pm = detectPackageManager(env, root ?? undefined);
|
|
56
|
+
const kitFile = findUp(dir, '@tokenroll/joplo-kit');
|
|
57
|
+
if (kitFile) {
|
|
58
|
+
const kit = readJson(kitFile)?.version;
|
|
59
|
+
if (typeof kit !== 'string' || kit === cli)
|
|
60
|
+
return [];
|
|
61
|
+
return [`! 项目里的 @tokenroll/joplo-kit 是 ${kit},当前 CLI 是 ${cli},两者需要同一版本。要对齐:${alignCommand(pm, cli)}`];
|
|
62
|
+
}
|
|
63
|
+
const pkg = root ? readJson(join(root, 'package.json')) : null;
|
|
64
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
65
|
+
if (pkg && ('@tokenroll/joplo-kit' in deps || 'joplo' in deps))
|
|
66
|
+
return [`! 项目声明了 @tokenroll/joplo-kit,但还没有安装依赖,先运行:${pmCommands(pm).install}`];
|
|
67
|
+
return [];
|
|
68
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenroll/joplo-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Joplo 命令实现(init / dev / check / build / render / selection);通常经由 joplo 命令入口使用",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"joplo-cli": "./dist/bin.js"
|
|
9
|
+
},
|
|
10
|
+
"exports": {
|
|
11
|
+
".": {
|
|
12
|
+
"types": "./dist/cli.d.ts",
|
|
13
|
+
"default": "./dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist"
|
|
19
|
+
],
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"@tokenroll/joplo-core": "0.1.0",
|
|
22
|
+
"@tokenroll/joplo-server": "0.1.0",
|
|
23
|
+
"commander": "^15.0.0",
|
|
24
|
+
"tsx": "4.23.15"
|
|
25
|
+
},
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/TokenRollAI/Joplo.git",
|
|
35
|
+
"directory": "packages/cli"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/TokenRollAI/Joplo#readme",
|
|
38
|
+
"bugs": {
|
|
39
|
+
"url": "https://github.com/TokenRollAI/Joplo/issues"
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "node ../../scripts/build-package.mjs"
|
|
43
|
+
}
|
|
6
44
|
}
|
package/README.md
DELETED