create-promptex-js 1.0.0 → 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.
Files changed (32) hide show
  1. package/README.md +102 -18
  2. package/dist/contract.generated.d.ts +24 -0
  3. package/dist/contract.generated.js +59 -0
  4. package/dist/index.js +356 -176
  5. package/dist/messages.generated.js +145 -61
  6. package/dist/plan.d.ts +105 -0
  7. package/dist/plan.js +327 -0
  8. package/package.json +3 -2
  9. package/templates/adapter-starter/README.md +45 -0
  10. package/templates/adapter-starter/_gitignore +6 -0
  11. package/templates/adapter-starter/example/promptex.config.ts +12 -0
  12. package/templates/adapter-starter/example/prompts/nodes.ts +18 -0
  13. package/templates/adapter-starter/package.json +28 -0
  14. package/templates/adapter-starter/promptex.config.schema.json +12 -0
  15. package/templates/adapter-starter/src/index.ts +93 -0
  16. package/templates/adapter-starter/tsconfig.json +14 -0
  17. package/templates/plugin-starter/README.md +40 -0
  18. package/templates/plugin-starter/_gitignore +5 -0
  19. package/templates/plugin-starter/example/promptex.config.ts +12 -0
  20. package/templates/plugin-starter/example/prompts/nodes.ts +16 -0
  21. package/templates/plugin-starter/package.json +28 -0
  22. package/templates/plugin-starter/promptex.config.schema.json +12 -0
  23. package/templates/plugin-starter/src/index.ts +63 -0
  24. package/templates/plugin-starter/tsconfig.json +14 -0
  25. package/templates/project-starter/README.md +30 -0
  26. package/templates/project-starter/_gitignore +11 -0
  27. package/templates/project-starter/package.json +13 -0
  28. package/templates/project-starter/promptex.config.ts +8 -0
  29. package/templates/project-starter/prompts/example-skill.ts +14 -0
  30. package/templates/project-starter/prompts/example.ts +14 -0
  31. package/dist/default-ref.d.ts +0 -6
  32. package/dist/default-ref.js +0 -14
package/dist/index.js CHANGED
@@ -1,27 +1,32 @@
1
1
  #!/usr/bin/env node
2
- // 鷹架 CLI:從 promptex-templates 倉庫開出 TypeScript 的 project/plugin/
3
- // adapter 專案。六階段:環境檢查 → 互動收集 → 模板取得 → 具現化 → 後處理 →
4
- // 稽核輸出;非互動時(--yes 或無 TTY)全部取預設或旗標值。
2
+ // 鷹架 CLI:開出 TypeScript 的 project/plugin/adapter 專案,或在既有資料夾加入 promptex。
3
+ // 目標不存在或只有系統、版本控制、編輯器的檔案時走建立模式,否則走初始化模式
4
+ // (m007 d004)。規則與套用在 plan.ts,本檔負責旗標、互動、輸出與結束碼。
5
5
  //
6
- // 模板不內嵌:單一來源住 promptex-templates,GA 版預設抓與本套件版本對應的
7
- // tag(v<版本>),預發布版預設抓 main,--ref 覆寫、--source 改用本地 checkout
8
- // (離線與開發用)。
6
+ // Templates ship inside this package (templates/), so every version scaffolds the
7
+ // templates it was released with, offline. --source points at a local templates
8
+ // directory (the one holding the *-starter folders) for template development.
9
9
  //
10
10
  // 用法:
11
- // npm create promptex-js [目標目錄] [-- --template project|plugin|adapter \
12
- // --name <kebab 名> --ref <branch|tag> --source <本地路徑> \
13
- // --git|--no-git --install --yes --locale <語系>]
14
- import { execFileSync, spawnSync } from 'node:child_process';
15
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
16
- import { tmpdir } from 'node:os';
17
- import { basename, dirname, join, resolve } from 'node:path';
11
+ // pnpm create promptex-js [目標目錄] [--template project|plugin|adapter] \
12
+ // [--name <kebab 名>] [--source <模板目錄>] [--git|--no-git] [--install] \
13
+ // [--force] [--dry-run [--json]] [--yes] [--locale <語系>]
14
+ //
15
+ // 結束碼:0 成功(含已初始化),1 執行失敗,2 用法錯誤(ADR-041)。
16
+ import { spawnSync } from 'node:child_process';
17
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
18
+ import { basename, join, resolve } from 'node:path';
18
19
  import { fileURLToPath } from 'node:url';
19
20
  import * as p from '@clack/prompts';
20
21
  import mri from 'mri';
21
- import { resolveRef } from './default-ref.js';
22
+ import { errorCode, FORMAT_VERSION } from './contract.generated.js';
22
23
  import { resolveLocale, t } from './messages.generated.js';
24
+ import { applyPlan, cdPath, commandAvailable, CreateError, detectPackageManager, findConfig, inspectTarget, isInsideGitWorktree, isNonInteractive, missingInputs, nextSteps, planFiles, PM_COMMANDS, summarize, } from './plan.js';
23
25
  const here = fileURLToPath(new URL('.', import.meta.url));
24
26
  const ownVersion = JSON.parse(readFileSync(join(here, '..', 'package.json'), 'utf8')).version;
27
+ const BUNDLED_TEMPLATES = join(here, '..', 'templates');
28
+ const PACKAGE = { name: 'create-promptex-js', version: ownVersion };
29
+ const LANGUAGE = 'ts';
25
30
  const TEMPLATES = ['project', 'plugin', 'adapter'];
26
31
  // 與 promptex init 的單元名正規化同一套規則(crates/promptex-core/src/init.rs)。
27
32
  const RESERVED_UNIT_NAMES = ['cache', 'preview', 'content', 'content-index', 'graph', 'main'];
@@ -36,115 +41,297 @@ export function normalizeName(raw) {
36
41
  out = out.replace(/^-+|-+$/g, '');
37
42
  return out === '' || RESERVED_UNIT_NAMES.includes(out) ? 'main' : out;
38
43
  }
39
- function bail(message) {
40
- console.error(message);
41
- process.exit(1);
44
+ const BOOLEAN_FLAGS = ['install', 'yes', 'git', 'force', 'dry-run', 'json', 'help'];
45
+ const STRING_FLAGS = ['template', 'name', 'source', 'locale'];
46
+ // ── 輸出 ──
47
+ // 帶 --json 時 stdout 只放一份 JSON,人讀訊息一律不印,錯誤另在 stderr 寫一行(ADR-041)。
48
+ // 語系與 --json 在解析旗標之前先取,解析失敗時的訊息才用得上它們。
49
+ const rawArgv = process.argv.slice(2);
50
+ const jsonRequested = rawArgv.includes('--json');
51
+ function localeFlag(argv) {
52
+ for (let i = 0; i < argv.length; i++) {
53
+ if (argv[i] === '--locale')
54
+ return argv[i + 1];
55
+ if (argv[i].startsWith('--locale='))
56
+ return argv[i].slice('--locale='.length);
57
+ }
58
+ return undefined;
42
59
  }
43
- const TEMPLATES_REPO = 'https://github.com/promptex-ai/promptex-templates.git';
44
- const args = mri(process.argv.slice(2), {
45
- boolean: ['install', 'yes', 'git', 'help'],
46
- string: ['template', 'name', 'ref', 'source', 'locale'],
47
- alias: { h: 'help' },
48
- });
49
- const locale = resolveLocale(args.locale);
60
+ const locale = resolveLocale(localeFlag(rawArgv));
50
61
  const separator = t('common.list_separator', locale);
51
- if (args.help) {
52
- console.log(t('scaffold.usage.js', locale));
53
- process.exit(0);
62
+ /** 細節值裡的陣列在訊息中以語系的清單分隔符接起來。 */
63
+ function messageParams(details) {
64
+ return Object.fromEntries(Object.entries(details).map(([k, v]) => [k, Array.isArray(v) ? v.join(separator) : v]));
54
65
  }
55
- const interactive = process.stdin.isTTY && !args.yes;
56
- p.intro(`create-promptex-js v${ownVersion}`);
57
- // ── 互動收集(缺什麼問什麼;非互動時取預設) ──
58
- /**
59
- * 缺什麼問什麼:旗標已給就用它,非互動(`--yes` 或無 TTY)取預設,其餘才問。
60
- *
61
- * 泛型鎖住「問到的答案與預設值同型」:`p.text` 回字串、`p.confirm` 回布林,
62
- * 兩者的呼叫點接著就把值餵給只收該型別的下游。
63
- */
64
- async function ask(value, prompt) {
65
- if (value !== undefined)
66
- return value;
67
- if (!interactive)
68
- return prompt.fallback;
69
- const answer = await prompt.run();
70
- if (p.isCancel(answer)) {
71
- p.cancel(t('scaffold.cancelled', locale));
72
- process.exit(1);
66
+ function fail(error) {
67
+ const record = errorCode(error.code);
68
+ const message = t(record.messageKey, locale, messageParams(error.details));
69
+ const firstLine = message.split('\n')[0];
70
+ if (jsonRequested) {
71
+ console.log(JSON.stringify({ formatVersion: FORMAT_VERSION, error: { code: error.code, message: firstLine, details: error.details } }));
72
+ console.error(firstLine);
73
73
  }
74
- return answer;
75
- }
76
- const targetDirRaw = await ask(args._[0], {
77
- fallback: 'promptex-project',
78
- run: () => p.text({
79
- message: t('scaffold.prompt.target_dir', locale),
80
- placeholder: 'promptex-project',
81
- defaultValue: 'promptex-project',
82
- }),
83
- });
84
- const targetDir = resolve(String(targetDirRaw));
85
- const template = await ask(args.template, {
86
- fallback: 'project',
87
- run: () => p.select({
88
- message: t('scaffold.prompt.template', locale),
89
- options: [
90
- { value: 'project', label: t('scaffold.template_option.project', locale) },
91
- { value: 'plugin', label: t('scaffold.template_option.plugin', locale) },
92
- { value: 'adapter', label: t('scaffold.template_option.adapter', locale) },
93
- ],
94
- }),
95
- });
96
- if (!TEMPLATES.includes(template)) {
97
- bail(t('scaffold.error.template_unsupported', locale, { template, choices: TEMPLATES.join(separator) }));
74
+ else {
75
+ console.error(message);
76
+ }
77
+ process.exit(record.exitCode);
98
78
  }
99
- const defaultName = normalizeName(basename(targetDir));
100
- const name = normalizeName(String(await ask(args.name, {
101
- fallback: defaultName,
102
- run: () => p.text({ message: t('scaffold.prompt.name', locale), defaultValue: defaultName, placeholder: defaultName }),
103
- })));
104
- const wantGit = args.git !== undefined
105
- ? args.git
106
- : await ask(undefined, {
107
- fallback: true,
108
- run: () => p.confirm({ message: t('scaffold.prompt.git_init', locale), initialValue: true }),
109
- });
110
- // ── 環境檢查:目標目錄非空即列衝突、零寫入退出(比照 promptex init)。 ──
111
- if (existsSync(targetDir)) {
112
- const entries = readdirSync(targetDir);
113
- if (entries.length > 0) {
114
- const listing = `${entries.slice(0, 10).join(separator)}${entries.length > 10 ? '…' : ''}`;
115
- bail(t('scaffold.error.dir_not_empty', locale, { path: targetDir, entries: listing }));
79
+ function parseArgs() {
80
+ const args = mri(rawArgv, { boolean: BOOLEAN_FLAGS, string: STRING_FLAGS, alias: { h: 'help' } });
81
+ // mri accepts any flag, so reject the ones this tool does not declare (a removed
82
+ // --ref must fail loudly instead of being ignored). `--no-git` arrives as `git`.
83
+ const knownFlags = new Set(['_', 'h', ...BOOLEAN_FLAGS, ...STRING_FLAGS]);
84
+ for (const key of Object.keys(args)) {
85
+ if (knownFlags.has(key))
86
+ continue;
87
+ // 回報使用者打的原字(`-x`、`--ref`),與另兩個 create 相同;`--ref=main` 只取旗標名。
88
+ const raw = rawArgv.find((a) => a === `-${key}` || a === `--${key}` || a.startsWith(`--${key}=`));
89
+ throw new CreateError('USAGE_UNKNOWN_ARGUMENT', { arg: raw?.split('=')[0] ?? `--${key}` });
90
+ }
91
+ // mri 把缺值的字串旗標填成空字串。
92
+ for (const key of STRING_FLAGS) {
93
+ const value = args[key];
94
+ if (value === '' || typeof value === 'boolean')
95
+ throw new CreateError('USAGE_FLAG_MISSING_VALUE', { flag: `--${key}` });
116
96
  }
97
+ if (args._.length > 1)
98
+ throw new CreateError('USAGE_UNKNOWN_ARGUMENT', { arg: String(args._[1]) });
99
+ if (args.json && !args['dry-run'])
100
+ throw new CreateError('USAGE_JSON_REQUIRES_DRY_RUN');
101
+ return args;
102
+ }
103
+ /** 三個 create 共用的說明版面:用法、兩種模式、旗標逐行。 */
104
+ function helpText() {
105
+ const options = [
106
+ ['[target-dir]', 'scaffold.help.target_dir'],
107
+ ['--template', 'scaffold.help.template'],
108
+ ['--name', 'scaffold.help.name'],
109
+ ['--source', 'scaffold.help.source'],
110
+ ['--git, --no-git', 'scaffold.help.git'],
111
+ ['--install', 'scaffold.help.install'],
112
+ ['--force', 'scaffold.help.force'],
113
+ ['--dry-run', 'scaffold.help.dry_run'],
114
+ ['--json', 'scaffold.help.json'],
115
+ ['--yes', 'scaffold.help.yes'],
116
+ ['--locale', 'scaffold.help.locale'],
117
+ ];
118
+ return [
119
+ t('scaffold.usage.js', locale),
120
+ '',
121
+ t('scaffold.usage.modes', locale),
122
+ '',
123
+ t('scaffold.usage.options', locale),
124
+ ...options.map(([flag, key]) => ` ${flag.padEnd(16)}${t(key, locale)}`),
125
+ ].join('\n');
117
126
  }
118
- const gitAvailable = spawnSync('git', ['--version'], { stdio: 'ignore' }).status === 0;
119
- if (!args.source && !gitAvailable)
120
- bail(t('scaffold.error.git_required', locale));
121
- // ── 模板取得:--source 讀本地 checkout;否則淺 clone,GA 版預設 ref 綁本套件版本、預發布版取 main。 ──
122
- let sourceRoot;
123
- let sourceLabel;
124
- let cloneDir;
125
- if (args.source) {
126
- sourceRoot = join(resolve(args.source), 'typescript');
127
- sourceLabel = t('scaffold.source.local', locale, { path: resolve(args.source) });
127
+ const REASON_KEYS = {
128
+ exists: 'scaffold.skip.exists',
129
+ 'manifest-kept': 'scaffold.skip.manifest_kept',
130
+ 'example-dir-exists': 'scaffold.skip.example_dir_exists',
131
+ 'init-mode': 'scaffold.skip.init_mode',
132
+ 'marker-present': 'scaffold.skip.marker_present',
133
+ symlink: 'scaffold.skip.symlink',
134
+ blocked: 'scaffold.skip.blocked',
135
+ };
136
+ /** 人讀的動作清單:依建立、覆寫、追加、跳過分組,組內依路徑排序。 */
137
+ function actionLines(actions) {
138
+ const summary = summarize(actions);
139
+ const rows = [
140
+ ...summary.create.map((path) => [t('scaffold.action.create', locale), path]),
141
+ ...summary.overwrite.map((path) => [t('scaffold.action.overwrite', locale), path]),
142
+ ...summary.append.map((path) => [t('scaffold.action.append', locale), path]),
143
+ ...summary.skip.map(({ path, reason }) => [
144
+ t('scaffold.action.skip', locale),
145
+ `${path} (${t(REASON_KEYS[reason], locale)})`,
146
+ ]),
147
+ ];
148
+ const width = Math.max(0, ...rows.map(([label]) => label.length));
149
+ return rows.map(([label, text]) => ` ${label.padEnd(width)} ${text}`);
150
+ }
151
+ /** `--dry-run --json` 的成功輸出。欄位與另兩個 create 逐欄相同,只有 package 與 language 依語言而異。 */
152
+ function report(opts) {
153
+ return {
154
+ formatVersion: FORMAT_VERSION,
155
+ package: PACKAGE,
156
+ language: LANGUAGE,
157
+ mode: opts.mode,
158
+ alreadyInitialized: opts.existingConfig !== null,
159
+ existingConfig: opts.existingConfig,
160
+ target: opts.targetDir,
161
+ template: opts.template,
162
+ name: opts.name,
163
+ files: summarize(opts.actions),
164
+ git: opts.git,
165
+ };
128
166
  }
129
- else {
130
- const { ref, showCloneHint } = resolveRef(args.ref, ownVersion);
131
- cloneDir = mkdtempSync(join(tmpdir(), 'promptex-templates-'));
132
- try {
133
- execFileSync('git', ['clone', '--depth', '1', '--branch', ref, TEMPLATES_REPO, cloneDir], {
134
- stdio: ['ignore', 'ignore', 'inherit'],
167
+ async function main() {
168
+ const args = parseArgs();
169
+ if (args.help) {
170
+ console.log(helpText());
171
+ process.exit(0);
172
+ }
173
+ const interactive = !isNonInteractive(Boolean(process.stdin.isTTY), Boolean(args.yes) || Boolean(args.json), process.env);
174
+ const say = (line) => {
175
+ if (jsonRequested)
176
+ return;
177
+ if (interactive)
178
+ p.log.message(line);
179
+ else
180
+ console.log(line);
181
+ };
182
+ // 目前沒有必填輸入(m008 d007:非互動時全部有預設值)。新增必填輸入時把它的
183
+ // hasDefault 設成 false,非互動執行就會以結束碼 2 一次列出所有缺的旗標。
184
+ const missing = missingInputs([
185
+ { flag: '[target-dir]', provided: args._[0] !== undefined, hasDefault: true },
186
+ { flag: '--template', provided: args.template !== undefined, hasDefault: true },
187
+ { flag: '--name', provided: args.name !== undefined, hasDefault: true },
188
+ ], interactive);
189
+ if (missing.length > 0)
190
+ throw new CreateError('USAGE_MISSING_INPUTS', { flags: missing });
191
+ if (interactive)
192
+ p.intro(`create-promptex-js v${ownVersion}`);
193
+ /** 缺什麼問什麼:旗標已給就用它,非互動取預設,其餘才問。 */
194
+ async function ask(value, prompt) {
195
+ if (value !== undefined)
196
+ return value;
197
+ if (!interactive)
198
+ return prompt.fallback;
199
+ const answer = await prompt.run();
200
+ if (p.isCancel(answer))
201
+ throw new CreateError('RUN_CANCELLED');
202
+ return answer;
203
+ }
204
+ // ── 目標與模式:輸出的第一行說明所選的模式。 ──
205
+ const targetRaw = await ask(args._[0] === undefined ? undefined : String(args._[0]), {
206
+ fallback: 'promptex-project',
207
+ run: () => p.text({
208
+ message: t('scaffold.prompt.target_dir', locale),
209
+ placeholder: 'promptex-project',
210
+ defaultValue: 'promptex-project',
211
+ }),
212
+ });
213
+ const targetDir = resolve(String(targetRaw));
214
+ const state = inspectTarget(targetDir);
215
+ if (state.mode === 'create') {
216
+ say(t('scaffold.mode.create', locale, { path: targetDir }));
217
+ }
218
+ else {
219
+ const shown = `${state.unlisted.slice(0, 10).join(separator)}${state.unlisted.length > 10 ? '…' : ''}`;
220
+ say(t('scaffold.mode.init', locale, { path: targetDir, entries: shown }));
221
+ }
222
+ // 初始化模式只支援 project(m008 d007),不問模板。
223
+ const template = state.mode === 'init'
224
+ ? (args.template ?? 'project')
225
+ : await ask(args.template, {
226
+ fallback: 'project',
227
+ run: () => p.select({
228
+ message: t('scaffold.prompt.template', locale),
229
+ options: [
230
+ { value: 'project', label: t('scaffold.template_option.project', locale) },
231
+ { value: 'plugin', label: t('scaffold.template_option.plugin', locale) },
232
+ { value: 'adapter', label: t('scaffold.template_option.adapter', locale) },
233
+ ],
234
+ }),
135
235
  });
236
+ if (!TEMPLATES.includes(template)) {
237
+ throw new CreateError('USAGE_TEMPLATE_UNSUPPORTED', { template, choices: TEMPLATES });
238
+ }
239
+ if (state.mode === 'init' && template !== 'project') {
240
+ throw new CreateError('USAGE_TEMPLATE_NEEDS_EMPTY_DIR', { template, path: targetDir, entries: state.unlisted });
136
241
  }
137
- catch {
138
- const hint = showCloneHint ? t('scaffold.error.clone_hint', locale) : '';
139
- bail(t('scaffold.error.clone_failed', locale, { ref, hint }));
242
+ const defaultName = normalizeName(basename(targetDir));
243
+ const existingConfig = state.mode === 'init' ? findConfig(targetDir) : null;
244
+ if (existingConfig !== null) {
245
+ const git = { init: false, reason: 'already-initialized' };
246
+ if (jsonRequested) {
247
+ console.log(JSON.stringify(report({ mode: state.mode, existingConfig, targetDir, template, name: defaultName, actions: [], git })));
248
+ }
249
+ else {
250
+ say(t('scaffold.already_initialized', locale, { config: existingConfig }));
251
+ }
252
+ process.exit(0);
140
253
  }
141
- sourceRoot = join(cloneDir, 'typescript');
142
- sourceLabel = `promptex-templates@${ref}`;
254
+ const name = normalizeName(String(await ask(args.name, {
255
+ fallback: defaultName,
256
+ run: () => p.text({ message: t('scaffold.prompt.name', locale), defaultValue: defaultName, placeholder: defaultName }),
257
+ })));
258
+ // ── git:在既有工作樹內就不 init,也不問。 ──
259
+ const insideWorktree = isInsideGitWorktree(targetDir);
260
+ const gitAvailable = commandAvailable('git');
261
+ if (args.git === true && !gitAvailable)
262
+ throw new CreateError('RUN_GIT_UNAVAILABLE');
263
+ const wantGit = args.git !== undefined
264
+ ? args.git
265
+ : insideWorktree
266
+ ? false
267
+ : await ask(undefined, {
268
+ fallback: true,
269
+ run: () => p.confirm({ message: t('scaffold.prompt.git_init', locale), initialValue: true }),
270
+ });
271
+ const git = insideWorktree
272
+ ? { init: false, reason: 'inside-worktree' }
273
+ : !wantGit
274
+ ? { init: false, reason: 'disabled' }
275
+ : !gitAvailable
276
+ ? { init: false, reason: 'unavailable' }
277
+ : { init: true, reason: null };
278
+ // ── 模板取得:預設用內附模板;--source 讀本地模板目錄。 ──
279
+ const templatesRoot = args.source ? resolve(args.source) : BUNDLED_TEMPLATES;
280
+ const sourceLabel = args.source
281
+ ? t('scaffold.source.local', locale, { path: templatesRoot })
282
+ : t('scaffold.source.bundled', locale, { version: ownVersion });
283
+ const starterDir = join(templatesRoot, `${template}-starter`);
284
+ if (!existsSync(starterDir))
285
+ throw new CreateError('RUN_STARTER_MISSING', { template, path: starterDir });
286
+ const files = loadTemplateFiles(starterDir, name);
287
+ // ── 計畫:逐檔決定建立、覆寫、追加或跳過。--dry-run 到此為止。 ──
288
+ const actions = planFiles(targetDir, files, state.mode, template, Boolean(args.force));
289
+ if (args['dry-run']) {
290
+ if (jsonRequested) {
291
+ console.log(JSON.stringify(report({ mode: state.mode, existingConfig: null, targetDir, template, name, actions, git })));
292
+ }
293
+ else {
294
+ say(t('scaffold.dry_run.header', locale, { path: targetDir, source: sourceLabel }));
295
+ for (const line of actionLines(actions))
296
+ say(line);
297
+ if (git.reason === 'inside-worktree')
298
+ say(t('scaffold.git.inside_worktree', locale));
299
+ if (git.init)
300
+ say(t('scaffold.git.will_init', locale));
301
+ }
302
+ process.exit(0);
303
+ }
304
+ applyPlan(targetDir, actions);
305
+ // ── 後處理:git init(可選)、--install 裝依賴。兩種模式都不自動 commit。 ──
306
+ if (git.init)
307
+ spawnSync('git', ['init', '--quiet'], { cwd: targetDir, stdio: 'inherit' });
308
+ const pm = detectPackageManager(targetDir, () => commandAvailable('pnpm'));
309
+ if (args.install) {
310
+ const command = PM_COMMANDS[pm].install;
311
+ if (!commandAvailable(pm))
312
+ throw new CreateError('RUN_INSTALL_TOOL_MISSING', { command });
313
+ const [bin, ...rest] = command.split(' ');
314
+ const r = spawnSync(bin, rest, { cwd: targetDir, stdio: 'inherit', shell: process.platform === 'win32' });
315
+ if (r.status !== 0)
316
+ throw new CreateError('RUN_INSTALL_FAILED', { command });
317
+ }
318
+ // ── 稽核輸出:做了什麼、來源為何、下一步。 ──
319
+ const summary = summarize(actions);
320
+ const count = summary.create.length + summary.overwrite.length + summary.append.length;
321
+ say(t('scaffold.done.written', locale, { count, path: targetDir, source: sourceLabel }));
322
+ for (const line of actionLines(actions))
323
+ say(line);
324
+ if (git.reason === 'inside-worktree')
325
+ say(t('scaffold.git.inside_worktree', locale));
326
+ const manifestKept = summary.skip.some((s) => s.path === 'package.json');
327
+ const steps = nextSteps({ template, cd: cdPath(targetDir, process.cwd()), manifestKept, installed: Boolean(args.install), pm });
328
+ const nextBlock = `${t('scaffold.done.next_steps', locale)}\n${steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}`;
329
+ if (interactive)
330
+ p.outro(nextBlock);
331
+ else
332
+ console.log(nextBlock);
143
333
  }
144
- const starterDir = join(sourceRoot, `${template}-starter`);
145
- if (!existsSync(starterDir))
146
- bail(t('scaffold.error.starter_missing', locale, { template, path: starterDir }));
147
- // ── 具現化:複製、佔位替換、_gitignore 改名。 ──
334
+ // ── 具現化:佔位替換、_gitignore 改名。 ──
148
335
  /**
149
336
  * 把一則訊息文字變成可直接嵌進單引號字串字面量的形態。
150
337
  *
@@ -159,71 +346,64 @@ if (!existsSync(starterDir))
159
346
  function escapeForLiteral(text) {
160
347
  return text.replaceAll('\\', '\\\\').replaceAll('\n', '\\n').replaceAll("'", "\\'");
161
348
  }
162
- const tokens = {
163
- '{{name}}': name,
164
- '{{snakeName}}': name.replaceAll('-', '_'),
165
- '{{unitName}}': name,
166
- '{{promptexVersion}}': ownVersion,
167
- // 模板的範例資源文字(ADR-030「一次性的起手內容」、AC-523):模板檔以佔位符留位,
168
- // 寫檔當下代入本次語系的那一份。實作指引註解不在此列,它們在模板裡就是英文。
169
- '{{adapterDemoRuleContent}}': escapeForLiteral(t('template.adapter_demo.rule_content', locale)),
170
- '{{adapterDemoSkillContent}}': escapeForLiteral(t('template.adapter_demo.skill_content', locale)),
171
- '{{adapterDemoSkillDescription}}': escapeForLiteral(t('template.adapter_demo.skill_description', locale)),
172
- '{{adapterDemoSkillWhenToUse}}': escapeForLiteral(t('template.adapter_demo.skill_when_to_use', locale)),
173
- '{{demoRuleName}}': escapeForLiteral(t('template.demo.rule_name', locale)),
174
- '{{demoSkillName}}': escapeForLiteral(t('template.demo.skill_name', locale)),
175
- '{{exampleRuleIntro}}': escapeForLiteral(t('template.example_rule.intro', locale)),
176
- '{{exampleRuleName}}': escapeForLiteral(t('template.example_rule.name', locale)),
177
- '{{exampleRuleOtherStates}}': escapeForLiteral(t('template.example_rule.other_states', locale)),
178
- '{{exampleRuleScoped}}': escapeForLiteral(t('template.example_rule.scoped', locale)),
179
- '{{exampleSkillDescription}}': escapeForLiteral(t('template.example_skill.description', locale)),
180
- '{{exampleSkillIntro}}': escapeForLiteral(t('template.example_skill.intro', locale)),
181
- '{{exampleSkillInvoked}}': escapeForLiteral(t('template.example_skill.invoked', locale)),
182
- '{{exampleSkillInvokedRs}}': escapeForLiteral(t('template.example_skill.invoked_rs', locale)),
183
- '{{exampleSkillName}}': escapeForLiteral(t('template.example_skill.name', locale)),
184
- '{{exampleSkillWhenToUse}}': escapeForLiteral(t('template.example_skill.when_to_use', locale)),
185
- '{{pluginDemoRuleContent}}': escapeForLiteral(t('template.plugin_demo.rule_content', locale)),
186
- '{{pluginDemoSkillContent}}': escapeForLiteral(t('template.plugin_demo.skill_content', locale)),
187
- '{{pluginDemoSkillDescription}}': escapeForLiteral(t('template.plugin_demo.skill_description', locale)),
188
- '{{pluginDemoSkillWhenToUse}}': escapeForLiteral(t('template.plugin_demo.skill_when_to_use', locale)),
189
- };
190
- const substitute = (text) => Object.entries(tokens).reduce((acc, [token, value]) => acc.replaceAll(token, value), text);
191
- function walk(dir, rel = '') {
192
- const out = [];
193
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
194
- const relPath = rel ? `${rel}/${entry.name}` : entry.name;
195
- if (entry.isDirectory())
196
- out.push(...walk(join(dir, entry.name), relPath));
197
- else
198
- out.push(relPath);
199
- }
200
- return out;
349
+ function loadTemplateFiles(starterDir, name) {
350
+ const tokens = {
351
+ '{{name}}': name,
352
+ '{{snakeName}}': name.replaceAll('-', '_'),
353
+ '{{unitName}}': name,
354
+ '{{promptexVersion}}': ownVersion,
355
+ // 模板的範例資源文字(ADR-030「一次性的起手內容」、AC-523):模板檔以佔位符留位,
356
+ // 寫檔當下代入本次語系的那一份。實作指引註解不在此列,它們在模板裡就是英文。
357
+ '{{adapterDemoRuleContent}}': escapeForLiteral(t('template.adapter_demo.rule_content', locale)),
358
+ '{{adapterDemoSkillContent}}': escapeForLiteral(t('template.adapter_demo.skill_content', locale)),
359
+ '{{adapterDemoSkillDescription}}': escapeForLiteral(t('template.adapter_demo.skill_description', locale)),
360
+ '{{adapterDemoSkillWhenToUse}}': escapeForLiteral(t('template.adapter_demo.skill_when_to_use', locale)),
361
+ '{{demoRuleName}}': escapeForLiteral(t('template.demo.rule_name', locale)),
362
+ '{{demoSkillName}}': escapeForLiteral(t('template.demo.skill_name', locale)),
363
+ '{{exampleRuleIntro}}': escapeForLiteral(t('template.example_rule.intro', locale)),
364
+ '{{exampleRuleName}}': escapeForLiteral(t('template.example_rule.name', locale)),
365
+ '{{exampleRuleOtherStates}}': escapeForLiteral(t('template.example_rule.other_states', locale)),
366
+ '{{exampleRuleScoped}}': escapeForLiteral(t('template.example_rule.scoped', locale)),
367
+ '{{exampleSkillDescription}}': escapeForLiteral(t('template.example_skill.description', locale)),
368
+ '{{exampleSkillIntro}}': escapeForLiteral(t('template.example_skill.intro', locale)),
369
+ '{{exampleSkillInvoked}}': escapeForLiteral(t('template.example_skill.invoked', locale)),
370
+ '{{exampleSkillInvokedRs}}': escapeForLiteral(t('template.example_skill.invoked_rs', locale)),
371
+ '{{exampleSkillName}}': escapeForLiteral(t('template.example_skill.name', locale)),
372
+ '{{exampleSkillWhenToUse}}': escapeForLiteral(t('template.example_skill.when_to_use', locale)),
373
+ '{{pluginDemoRuleContent}}': escapeForLiteral(t('template.plugin_demo.rule_content', locale)),
374
+ '{{pluginDemoSkillContent}}': escapeForLiteral(t('template.plugin_demo.skill_content', locale)),
375
+ '{{pluginDemoSkillDescription}}': escapeForLiteral(t('template.plugin_demo.skill_description', locale)),
376
+ '{{pluginDemoSkillWhenToUse}}': escapeForLiteral(t('template.plugin_demo.skill_when_to_use', locale)),
377
+ };
378
+ const substitute = (text) => Object.entries(tokens).reduce((acc, [token, value]) => acc.replaceAll(token, value), text);
379
+ const walk = (dir, rel = '') => {
380
+ const out = [];
381
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
382
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
383
+ if (entry.isDirectory())
384
+ out.push(...walk(join(dir, entry.name), relPath));
385
+ else
386
+ out.push(relPath);
387
+ }
388
+ return out;
389
+ };
390
+ return walk(starterDir).map((rel) => {
391
+ const dest = substitute(rel);
392
+ let content;
393
+ try {
394
+ content = readFileSync(join(starterDir, rel), 'utf8');
395
+ }
396
+ catch (e) {
397
+ throw new CreateError('RUN_READ_FAILED', { path: join(starterDir, rel), detail: e instanceof Error ? e.message : String(e) });
398
+ }
399
+ return { rel: dest === '_gitignore' ? '.gitignore' : dest, content: substitute(content) };
400
+ });
201
401
  }
202
- const written = [];
203
- for (const rel of walk(starterDir)) {
204
- let destRel = substitute(rel);
205
- if (destRel === '_gitignore')
206
- destRel = '.gitignore';
207
- const destPath = join(targetDir, destRel);
208
- mkdirSync(dirname(destPath), { recursive: true });
209
- writeFileSync(destPath, substitute(readFileSync(join(starterDir, rel), 'utf8')));
210
- written.push(destRel);
402
+ try {
403
+ await main();
211
404
  }
212
- if (cloneDir)
213
- rmSync(cloneDir, { recursive: true, force: true });
214
- // ── 後處理:git init(可選)、--install 裝依賴。 ──
215
- if (wantGit && gitAvailable)
216
- spawnSync('git', ['init', '--quiet'], { cwd: targetDir, stdio: 'inherit' });
217
- if (args.install) {
218
- const r = spawnSync('npm', ['install'], { cwd: targetDir, stdio: 'inherit', shell: process.platform === 'win32' });
219
- if (r.status !== 0)
220
- bail(t('scaffold.error.install_failed.js', locale));
405
+ catch (e) {
406
+ if (e instanceof CreateError)
407
+ fail(e);
408
+ throw e;
221
409
  }
222
- // ── 稽核輸出:寫了什麼、來源為何、下一步。 ──
223
- p.log.success(t('scaffold.done.written', locale, { count: written.length, path: targetDir, source: sourceLabel }));
224
- for (const rel of written)
225
- p.log.message(` ${rel}`);
226
- const nextSteps = template === 'project'
227
- ? [`cd ${targetDirRaw}`, ...(args.install ? [] : ['npm install']), 'npm run install:prompts']
228
- : [`cd ${targetDirRaw}`, ...(args.install ? [] : ['npm install']), 'npm run build', 'cd example && npx promptex build --install'];
229
- p.outro(`${t('scaffold.done.next_steps', locale)}\n${nextSteps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}`);