create-promptex-js 1.0.1 → 1.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/README.md +101 -15
- package/dist/contract.generated.d.ts +24 -0
- package/dist/contract.generated.js +59 -0
- package/dist/index.js +353 -158
- package/dist/messages.generated.js +140 -44
- package/dist/plan.d.ts +105 -0
- package/dist/plan.js +327 -0
- package/package.json +2 -2
package/dist/plan.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// create 的模式判斷、寫入規則與套用(m007 d004)。三個 create 套件各有一份同構的實作:
|
|
2
|
+
// create-promptex-py 的 `_plan.py` 與 create-promptex-rs 的 `plan.rs`。函式名、規則表與
|
|
3
|
+
// 輸出欄位三份對齊,允許清單與錯誤代碼則由 `contract.generated.ts` 從同一份來源生成。
|
|
4
|
+
//
|
|
5
|
+
// 流程分四段:inspectTarget(目標狀態與模式)→ planFiles(逐檔決定動作,不寫入)→
|
|
6
|
+
// applyPlan(照計畫寫入,失敗時只回復這次新建的東西)→ 由 CLI 輸出報告或 JSON。
|
|
7
|
+
// --dry-run 只走前兩段。
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { appendFileSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmdirSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
10
|
+
import { dirname, join, relative, resolve, isAbsolute } from 'node:path';
|
|
11
|
+
import { CONFIG_FILES, GITIGNORE_MARKER, isAllowlisted } from './contract.generated.js';
|
|
12
|
+
/** 依代碼帶結構化細節的失敗;CLI 依 `contract.generated.ts` 的錯誤表決定訊息與結束碼。 */
|
|
13
|
+
export class CreateError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
details;
|
|
16
|
+
constructor(code, details = {}) {
|
|
17
|
+
super(code);
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.details = details;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function lstatOrNull(path) {
|
|
23
|
+
return lstatSync(path, { throwIfNoEntry: false }) ?? null;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* 判斷目標狀態與模式。目標不存在,或第一層只有允許清單裡的項目,就走建立模式。
|
|
27
|
+
* 目標是符號連結或檔案時以用法錯誤拒絕,不寫入任何東西。
|
|
28
|
+
*/
|
|
29
|
+
export function inspectTarget(target) {
|
|
30
|
+
const stat = lstatOrNull(target);
|
|
31
|
+
if (stat === null)
|
|
32
|
+
return { exists: false, mode: 'create', unlisted: [] };
|
|
33
|
+
if (stat.isSymbolicLink())
|
|
34
|
+
throw new CreateError('USAGE_TARGET_IS_SYMLINK', { path: target });
|
|
35
|
+
if (!stat.isDirectory())
|
|
36
|
+
throw new CreateError('USAGE_TARGET_IS_FILE', { path: target });
|
|
37
|
+
const unlisted = readdirSync(target)
|
|
38
|
+
.filter((name) => !isAllowlisted(name))
|
|
39
|
+
.sort();
|
|
40
|
+
return { exists: true, mode: unlisted.length === 0 ? 'create' : 'init', unlisted };
|
|
41
|
+
}
|
|
42
|
+
/** 目標裡已存在的 promptex 設定檔;有就代表已初始化。 */
|
|
43
|
+
export function findConfig(target) {
|
|
44
|
+
return CONFIG_FILES.find((name) => lstatOrNull(join(target, name)) !== null) ?? null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* 目標是否位於任何 git 工作樹內(含上層資料夾)。往上找 `.git`(資料夾或 worktree 的
|
|
48
|
+
* 檔案),不呼叫 git:沒裝 git 也判得出來,三種語言的結果也因此一致。
|
|
49
|
+
*/
|
|
50
|
+
export function isInsideGitWorktree(target) {
|
|
51
|
+
let dir = resolve(target);
|
|
52
|
+
while (lstatOrNull(dir) === null && dirname(dir) !== dir)
|
|
53
|
+
dir = dirname(dir);
|
|
54
|
+
for (;;) {
|
|
55
|
+
if (lstatOrNull(join(dir, '.git')) !== null)
|
|
56
|
+
return true;
|
|
57
|
+
const parent = dirname(dir);
|
|
58
|
+
if (parent === dir)
|
|
59
|
+
return false;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export function commandAvailable(name) {
|
|
64
|
+
return spawnSync(name, ['--version'], { stdio: 'ignore', shell: process.platform === 'win32' }).status === 0;
|
|
65
|
+
}
|
|
66
|
+
/** 非互動環境:沒有終端機、帶 --yes,或 CI 環境變數為真(空字串、0、false 不算)。 */
|
|
67
|
+
export function isNonInteractive(stdinIsTty, yes, env) {
|
|
68
|
+
const ci = (env.CI ?? '').trim().toLowerCase();
|
|
69
|
+
return !stdinIsTty || yes || (ci !== '' && ci !== '0' && ci !== 'false');
|
|
70
|
+
}
|
|
71
|
+
/** 非互動時沒給又沒有預設值的輸入,一次全列出(結束碼 2)。互動時會問,因此不算缺。 */
|
|
72
|
+
export function missingInputs(inputs, interactive) {
|
|
73
|
+
if (interactive)
|
|
74
|
+
return [];
|
|
75
|
+
return inputs.filter((input) => !input.provided && !input.hasDefault).map((input) => input.flag);
|
|
76
|
+
}
|
|
77
|
+
/** 宣告檔與範例目錄依範本而定,其他角色看檔名。 */
|
|
78
|
+
const MANIFEST = 'package.json';
|
|
79
|
+
const EXAMPLE_DIR = { project: 'prompts', plugin: 'example', adapter: 'example' };
|
|
80
|
+
/**
|
|
81
|
+
* 每個角色遇到同名既有檔時怎麼做(m007 d004 寫入規則表)。`.gitignore` 走追加,不在表內。
|
|
82
|
+
* `forceOverwrites` 為假的角色連 --force 都不覆寫;`createModeOnly` 的角色在初始化模式一律不寫。
|
|
83
|
+
*/
|
|
84
|
+
const RULES = {
|
|
85
|
+
manifest: { existsReason: 'manifest-kept', forceOverwrites: false, createModeOnly: false },
|
|
86
|
+
readme: { existsReason: 'exists', forceOverwrites: true, createModeOnly: true },
|
|
87
|
+
example: { existsReason: 'exists', forceOverwrites: true, createModeOnly: false },
|
|
88
|
+
other: { existsReason: 'exists', forceOverwrites: true, createModeOnly: false },
|
|
89
|
+
};
|
|
90
|
+
export function roleOf(rel, template) {
|
|
91
|
+
if (rel === MANIFEST)
|
|
92
|
+
return 'manifest';
|
|
93
|
+
if (rel === 'README.md')
|
|
94
|
+
return 'readme';
|
|
95
|
+
if (rel === '.gitignore')
|
|
96
|
+
return 'gitignore';
|
|
97
|
+
if (rel.startsWith(`${EXAMPLE_DIR[template]}/`))
|
|
98
|
+
return 'example';
|
|
99
|
+
return 'other';
|
|
100
|
+
}
|
|
101
|
+
/** 上層路徑擋住寫入的原因:符號連結或不是資料夾。全部是資料夾或不存在時回 null。 */
|
|
102
|
+
function blockedParent(target, rel) {
|
|
103
|
+
const parts = rel.split('/').slice(0, -1);
|
|
104
|
+
let current = target;
|
|
105
|
+
for (const part of parts) {
|
|
106
|
+
current = join(current, part);
|
|
107
|
+
const stat = lstatOrNull(current);
|
|
108
|
+
if (stat === null)
|
|
109
|
+
return null;
|
|
110
|
+
if (stat.isSymbolicLink())
|
|
111
|
+
return 'symlink';
|
|
112
|
+
if (!stat.isDirectory())
|
|
113
|
+
return 'blocked';
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* `.gitignore`:不存在就建立;存在就在尾端追加帶標記的區塊,已有標記行就跳過。
|
|
119
|
+
* 追加的區塊前補換行與一個空行,作者的最後一條規則才不會和標記行黏成同一行
|
|
120
|
+
* (與核心 init 的 `apply_gitignore` 相同)。範本沒有標記行時(plugin、adapter)
|
|
121
|
+
* 追加前補上,重跑才判得出已追加過。
|
|
122
|
+
*/
|
|
123
|
+
function planGitignore(target, content) {
|
|
124
|
+
const path = join(target, '.gitignore');
|
|
125
|
+
const stat = lstatOrNull(path);
|
|
126
|
+
if (stat === null)
|
|
127
|
+
return { action: 'create', path: '.gitignore', content };
|
|
128
|
+
if (stat.isSymbolicLink())
|
|
129
|
+
return { action: 'skip', path: '.gitignore', reason: 'symlink' };
|
|
130
|
+
let existing;
|
|
131
|
+
try {
|
|
132
|
+
existing = readFileSync(path, 'utf8');
|
|
133
|
+
}
|
|
134
|
+
catch (e) {
|
|
135
|
+
throw new CreateError('RUN_READ_FAILED', { path, detail: e instanceof Error ? e.message : String(e) });
|
|
136
|
+
}
|
|
137
|
+
if (existing.split(/\r?\n/).includes(GITIGNORE_MARKER)) {
|
|
138
|
+
return { action: 'skip', path: '.gitignore', reason: 'marker-present' };
|
|
139
|
+
}
|
|
140
|
+
const block = content.startsWith(GITIGNORE_MARKER) ? content : `${GITIGNORE_MARKER}\n${content}`;
|
|
141
|
+
let prefix = '';
|
|
142
|
+
if (existing !== '' && !existing.endsWith('\n'))
|
|
143
|
+
prefix += '\n';
|
|
144
|
+
if (existing !== '')
|
|
145
|
+
prefix += '\n';
|
|
146
|
+
return { action: 'append', path: '.gitignore', content: prefix + block };
|
|
147
|
+
}
|
|
148
|
+
/** 逐檔決定動作,不寫入。回傳的順序是範本檔的路徑順序。 */
|
|
149
|
+
export function planFiles(target, files, mode, template, force) {
|
|
150
|
+
const exampleDir = EXAMPLE_DIR[template];
|
|
151
|
+
const exampleSkipped = !force && lstatOrNull(join(target, exampleDir)) !== null;
|
|
152
|
+
const actions = [];
|
|
153
|
+
let exampleListed = false;
|
|
154
|
+
for (const file of [...files].sort((a, b) => (a.rel < b.rel ? -1 : a.rel > b.rel ? 1 : 0))) {
|
|
155
|
+
const role = roleOf(file.rel, template);
|
|
156
|
+
if (role === 'example' && exampleSkipped) {
|
|
157
|
+
if (!exampleListed)
|
|
158
|
+
actions.push({ action: 'skip', path: `${exampleDir}/`, reason: 'example-dir-exists' });
|
|
159
|
+
exampleListed = true;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const blocked = blockedParent(target, file.rel);
|
|
163
|
+
if (blocked !== null) {
|
|
164
|
+
actions.push({ action: 'skip', path: file.rel, reason: blocked });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (role === 'gitignore') {
|
|
168
|
+
actions.push(planGitignore(target, file.content));
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
const rule = RULES[role];
|
|
172
|
+
if (rule.createModeOnly && mode === 'init') {
|
|
173
|
+
actions.push({ action: 'skip', path: file.rel, reason: 'init-mode' });
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const stat = lstatOrNull(join(target, file.rel));
|
|
177
|
+
if (stat === null) {
|
|
178
|
+
actions.push({ action: 'create', path: file.rel, content: file.content });
|
|
179
|
+
}
|
|
180
|
+
else if (stat.isDirectory() || !force || !rule.forceOverwrites) {
|
|
181
|
+
actions.push({ action: 'skip', path: file.rel, reason: rule.existsReason });
|
|
182
|
+
}
|
|
183
|
+
else {
|
|
184
|
+
actions.push({ action: 'overwrite', path: file.rel, content: file.content });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return actions;
|
|
188
|
+
}
|
|
189
|
+
/** 計畫的四類清單,路徑排序後輸出(JSON 與人讀報告共用)。 */
|
|
190
|
+
export function summarize(actions) {
|
|
191
|
+
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
192
|
+
const pick = (kind) => actions.filter((a) => a.action === kind).sort(byPath);
|
|
193
|
+
return {
|
|
194
|
+
create: pick('create').map((a) => a.path),
|
|
195
|
+
overwrite: pick('overwrite').map((a) => a.path),
|
|
196
|
+
append: pick('append').map((a) => a.path),
|
|
197
|
+
skip: pick('skip').map((a) => ({ path: a.path, reason: a.reason })),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* 照計畫寫入。先建立與覆寫,最後追加 `.gitignore`,追加之前的失敗因此不會動到既有的
|
|
202
|
+
* `.gitignore`。任何一步失敗時,倒序刪掉這次新建的檔案與資料夾(資料夾只在已清空時刪),
|
|
203
|
+
* 既有檔一律不刪,再以 RUN_WRITE_FAILED 回報。
|
|
204
|
+
*
|
|
205
|
+
* 環境變數 PROMPTEX_CREATE_FAIL_ON 指定一個相對路徑時,寫到那個檔就模擬失敗。只給測試
|
|
206
|
+
* 驗證回復用:真實的寫入失敗(磁碟滿、權限)難以在三種語言穩定重現。
|
|
207
|
+
*/
|
|
208
|
+
export function applyPlan(target, actions) {
|
|
209
|
+
const created = [];
|
|
210
|
+
const failOn = process.env.PROMPTEX_CREATE_FAIL_ON;
|
|
211
|
+
const ensureDir = (dir) => {
|
|
212
|
+
const missing = [];
|
|
213
|
+
let probe = dir;
|
|
214
|
+
while (lstatOrNull(probe) === null && dirname(probe) !== probe) {
|
|
215
|
+
missing.unshift(probe);
|
|
216
|
+
probe = dirname(probe);
|
|
217
|
+
}
|
|
218
|
+
for (const path of missing) {
|
|
219
|
+
mkdirSync(path);
|
|
220
|
+
created.push({ path, dir: true });
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
let current = target;
|
|
224
|
+
try {
|
|
225
|
+
ensureDir(target);
|
|
226
|
+
const ordered = [
|
|
227
|
+
...actions.filter((a) => a.action === 'create' || a.action === 'overwrite'),
|
|
228
|
+
...actions.filter((a) => a.action === 'append'),
|
|
229
|
+
];
|
|
230
|
+
for (const entry of ordered) {
|
|
231
|
+
current = join(target, entry.path);
|
|
232
|
+
if (failOn === entry.path)
|
|
233
|
+
throw new Error(`simulated failure (PROMPTEX_CREATE_FAIL_ON=${failOn})`);
|
|
234
|
+
ensureDir(dirname(current));
|
|
235
|
+
if (entry.action === 'append') {
|
|
236
|
+
appendFileSync(current, entry.content);
|
|
237
|
+
}
|
|
238
|
+
else if (entry.action === 'overwrite') {
|
|
239
|
+
// 先刪再寫:既有的若是符號連結,覆寫的是連結本身,不跟過去寫外部檔。
|
|
240
|
+
unlinkSync(current);
|
|
241
|
+
writeFileSync(current, entry.content, { flag: 'wx' });
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
writeFileSync(current, entry.content, { flag: 'wx' });
|
|
245
|
+
created.push({ path: current, dir: false });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch (e) {
|
|
250
|
+
for (const item of [...created].reverse()) {
|
|
251
|
+
try {
|
|
252
|
+
if (item.dir)
|
|
253
|
+
rmdirSync(item.path);
|
|
254
|
+
else
|
|
255
|
+
unlinkSync(item.path);
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
// 資料夾裡有別人放的東西,或已經不在:留著,不刪既有內容。
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
throw new CreateError('RUN_WRITE_FAILED', { path: current, detail: e instanceof Error ? e.message : String(e) });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
const LOCKFILES = [
|
|
265
|
+
['pnpm-lock.yaml', 'pnpm'],
|
|
266
|
+
['yarn.lock', 'yarn'],
|
|
267
|
+
['bun.lock', 'bun'],
|
|
268
|
+
['bun.lockb', 'bun'],
|
|
269
|
+
['package-lock.json', 'npm'],
|
|
270
|
+
];
|
|
271
|
+
export function detectPackageManager(target, pnpmAvailable) {
|
|
272
|
+
let dir = resolve(target);
|
|
273
|
+
for (;;) {
|
|
274
|
+
for (const [file, manager] of LOCKFILES) {
|
|
275
|
+
if (lstatOrNull(join(dir, file)) !== null)
|
|
276
|
+
return manager;
|
|
277
|
+
}
|
|
278
|
+
const parent = dirname(dir);
|
|
279
|
+
if (parent === dir)
|
|
280
|
+
break;
|
|
281
|
+
dir = parent;
|
|
282
|
+
}
|
|
283
|
+
return pnpmAvailable() ? 'pnpm' : 'npm';
|
|
284
|
+
}
|
|
285
|
+
/** 各套件管理器的四種指令形態。 */
|
|
286
|
+
export const PM_COMMANDS = {
|
|
287
|
+
pnpm: { install: 'pnpm install', add: 'pnpm add', run: 'pnpm run', exec: 'pnpm exec' },
|
|
288
|
+
npm: { install: 'npm install', add: 'npm install', run: 'npm run', exec: 'npx' },
|
|
289
|
+
yarn: { install: 'yarn install', add: 'yarn add', run: 'yarn run', exec: 'yarn' },
|
|
290
|
+
bun: { install: 'bun install', add: 'bun add', run: 'bun run', exec: 'bunx' },
|
|
291
|
+
};
|
|
292
|
+
/**
|
|
293
|
+
* 給人看的目標路徑:在工作目錄底下就用相對路徑,目標就是工作目錄時回空字串(不印 `cd .`),
|
|
294
|
+
* 其他情況用絕對路徑。
|
|
295
|
+
*/
|
|
296
|
+
export function cdPath(target, cwd) {
|
|
297
|
+
const rel = relative(cwd, target);
|
|
298
|
+
if (rel === '')
|
|
299
|
+
return '';
|
|
300
|
+
if (rel.startsWith('..') || isAbsolute(rel))
|
|
301
|
+
return target;
|
|
302
|
+
return rel.split('\\').join('/');
|
|
303
|
+
}
|
|
304
|
+
export function nextSteps(opts) {
|
|
305
|
+
const pm = PM_COMMANDS[opts.pm];
|
|
306
|
+
const steps = [];
|
|
307
|
+
if (opts.template === 'project') {
|
|
308
|
+
if (opts.cd !== '')
|
|
309
|
+
steps.push(`cd ${opts.cd}`);
|
|
310
|
+
if (opts.manifestKept) {
|
|
311
|
+
// 既有的 package.json 沒有 promptex-js 依賴,也沒有範本的 install:prompts script。
|
|
312
|
+
steps.push(`${pm.add} promptex-js`, `${pm.exec} promptex build --install`);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
if (!opts.installed)
|
|
316
|
+
steps.push(pm.install);
|
|
317
|
+
steps.push(`${pm.run} install:prompts`);
|
|
318
|
+
}
|
|
319
|
+
return steps;
|
|
320
|
+
}
|
|
321
|
+
if (opts.cd !== '')
|
|
322
|
+
steps.push(`cd ${opts.cd}`);
|
|
323
|
+
if (!opts.installed)
|
|
324
|
+
steps.push(pm.install);
|
|
325
|
+
steps.push(`${pm.run} build`, `cd example && ${pm.exec} promptex build --install`);
|
|
326
|
+
return steps;
|
|
327
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-promptex-js",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Scaffold a promptex TypeScript project, plugin or adapter.",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"homepage": "https://github.com/promptex-ai/promptex/tree/main/packages/create-promptex-js#readme",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"scripts": {
|
|
42
42
|
"build": "tsc -p tsconfig.json",
|
|
43
43
|
"pretest": "npm run build",
|
|
44
|
-
"test": "node --experimental-strip-types --test test/smoke.test.ts && npm run test:types",
|
|
44
|
+
"test": "node --experimental-strip-types --test test/smoke.test.ts test/modes.test.ts && npm run test:types",
|
|
45
45
|
"test:types": "tsc -p tsconfig.typelevel.json",
|
|
46
46
|
"prepack": "npm run build"
|
|
47
47
|
},
|