flower-trellis 0.6.1 → 0.6.3

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 (34) hide show
  1. package/enhancements/MANIFEST.json +1 -1
  2. package/package.json +3 -3
  3. package/src/builtin-marketplaces/rd-guide.json +6 -3
  4. package/src/builtin-plugins/flower-plugin-author/plugin.json +6 -1
  5. package/src/builtin-plugins/flower-plugin-author/skills/flower-plugin-author/references/ci-and-review.md +2 -2
  6. package/src/commands/plugin-interactive.js +546 -38
  7. package/src/commands/plugin-remote.js +156 -10
  8. package/src/commands/plugin.js +35 -4
  9. package/src/lib/skill-catalog.js +20 -1
  10. package/src/lib/telemetry.js +2 -2
  11. package/src/lib/trellis-runner.js +82 -11
  12. package/src/plugin/application-service.js +81 -7
  13. package/src/plugin/auth/credential-store.js +25 -3
  14. package/src/plugin/auth/gitlab-credential-resolver.js +234 -0
  15. package/src/plugin/auth/gitlab-oauth.js +26 -5
  16. package/src/plugin/authoring/scaffold.js +6 -1
  17. package/src/plugin/authoring/templates/rd-guide/CODEOWNERS +1 -1
  18. package/src/plugin/authoring/templates/rd-guide/gitlab-ci.yml +2 -2
  19. package/src/plugin/authoring/templates/rd-guide/verify-integration-review.mjs +1 -1
  20. package/src/plugin/content-selection.js +189 -0
  21. package/src/plugin/contracts.js +21 -2
  22. package/src/plugin/formats/adapters.js +27 -3
  23. package/src/plugin/formats/normalized-package.js +126 -14
  24. package/src/plugin/gitlab/rest-client.js +21 -0
  25. package/src/plugin/install/content-projector.js +19 -7
  26. package/src/plugin/resolver/dependency-resolver.js +6 -0
  27. package/src/plugin/runtime-errors.js +1 -0
  28. package/src/plugin/schemas/marketplace-manifest.js +4 -1
  29. package/src/plugin/schemas/plugin-manifest.js +55 -3
  30. package/src/plugin/schemas/project-files.js +18 -0
  31. package/src/plugin/schemas/shared.js +10 -0
  32. package/src/plugin/sources/github-provider.js +1 -1
  33. package/src/plugin/sources/gitlab-provider.js +377 -51
  34. package/src/plugin/sources/user-source-store.js +17 -1
@@ -7,13 +7,17 @@ import {
7
7
  PluginRuntimeError,
8
8
  } from "../plugin/runtime-errors.js";
9
9
  import { parseCanonicalPluginId } from "../plugin/schemas/shared.js";
10
+ import { GITLAB_OAUTH_REQUEST_SCOPES } from "../plugin/auth/credential-store.js";
11
+ import { GitLabCredentialResolver } from "../plugin/auth/gitlab-credential-resolver.js";
10
12
  import { createCredentialStore } from "../plugin/auth/keyring-credential-store.js";
11
13
  import { GitLabCredentialManager, GitLabOAuthClient } from "../plugin/auth/gitlab-oauth.js";
12
14
  import { GitLabRestClient } from "../plugin/gitlab/rest-client.js";
13
15
  import { GitHubRestClient } from "../plugin/github/rest-client.js";
14
16
  import { GitLabSourceProvider } from "../plugin/sources/gitlab-provider.js";
15
17
  import { GitHubSourceProvider } from "../plugin/sources/github-provider.js";
18
+ import { SourceRegistry } from "../plugin/sources/source-registry.js";
16
19
  import { UserSourceStore } from "../plugin/sources/user-source-store.js";
20
+ import { listContentSkillChoices } from "../plugin/content-selection.js";
17
21
  import { compareUtf8 } from "../plugin/stable-order.js";
18
22
 
19
23
  /**
@@ -53,8 +57,9 @@ function printManagementResult(result, json, output) {
53
57
  return;
54
58
  }
55
59
  if (result.subcommand === "status") {
60
+ const scopeSuffix = result.scopes?.length ? `,scope=${result.scopes.join(",")}` : "";
56
61
  output.log(result.authorized
57
- ? `GitLab 已登录:${result.sourceId},scope=${result.scopes.join(",")}`
62
+ ? `GitLab 已登录:${result.sourceId}${scopeSuffix}`
58
63
  : `GitLab 未登录:${result.sourceId}`);
59
64
  } else output.log(`GitLab auth ${result.subcommand} 完成:${result.sourceId}`);
60
65
  if (result.persistent === false) output.log("系统 Keyring 不可用,凭据仅在当前进程内有效");
@@ -134,16 +139,23 @@ export async function getPluginAuthStatus(sourceId, options = {}) {
134
139
  };
135
140
  }
136
141
  const credentialBundle = options.credentialBundle || await createCredentialStore(options.credentialStoreOptions);
137
- const credential = await credentialBundle.store.get(source);
142
+ const resolver = options.credentialResolver || new GitLabCredentialResolver({
143
+ store: credentialBundle.store,
144
+ persistent: credentialBundle.persistent,
145
+ env: options.env,
146
+ runGlab: options.runGlab,
147
+ glabCommand: options.glabCommand,
148
+ });
149
+ const status = await resolver.status(source);
138
150
  return {
139
151
  ok: true,
140
152
  command: "auth",
141
153
  subcommand: "status",
142
154
  sourceId: source.id,
143
- authorized: Boolean(credential),
144
- scopes: credential?.scope || [],
145
- expiresAt: credential?.expiresAt || null,
146
- persistent: credentialBundle.persistent,
155
+ authorized: status.authorized,
156
+ scopes: status.scopes,
157
+ expiresAt: status.expiresAt,
158
+ persistent: status.persistent,
147
159
  };
148
160
  }
149
161
 
@@ -167,7 +179,16 @@ export async function searchPluginMarketplaces(parsed, ctx, options = {}) {
167
179
  sleep: options.sleep,
168
180
  now: options.now,
169
181
  });
170
- manager = new GitLabCredentialManager({ store: credentialBundle.store, oauth, now: options.now });
182
+ manager = new GitLabCredentialManager({
183
+ store: credentialBundle.store,
184
+ oauth,
185
+ now: options.now,
186
+ persistent: credentialBundle.persistent,
187
+ credentialResolver: options.credentialResolver,
188
+ env: options.env,
189
+ runGlab: options.runGlab,
190
+ glabCommand: options.glabCommand,
191
+ });
171
192
  }
172
193
  const results = [];
173
194
  const diagnostics = [];
@@ -193,6 +214,122 @@ export async function searchPluginMarketplaces(parsed, ctx, options = {}) {
193
214
  };
194
215
  }
195
216
 
217
+ /**
218
+ * 为 TUI 按需读取 Marketplace Plugin 的 manifest Skill 清单。
219
+ *
220
+ * @param {{pluginId:string,version?:string,source?:string|null,lockedPlugin?:import("../plugin/contracts.js").ResolvedPlugin|null}} request 读取请求
221
+ * @param {object} ctx CLI 上下文
222
+ * @param {object} [options] Provider、凭据和测试注入
223
+ * @returns {Promise<{ok:true,pluginId:string,version:string,name:string,skills:Array<{name:string,path:string,description?:string,version?:string}>}>} Skill 清单
224
+ */
225
+ export async function inspectPluginContentSkills(request, ctx, options = {}) {
226
+ const canonicalId = request.pluginId || request.lockedPlugin?.id;
227
+ if (!canonicalId) {
228
+ throw new PluginRuntimeError("读取 Plugin Skill 清单需要 Plugin ID", {
229
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
230
+ path: "pluginId",
231
+ });
232
+ }
233
+ const registry = new SourceRegistry(options.providers || []);
234
+ const lock = request.lockedPlugin
235
+ ? { schemaVersion: 1, roots: [request.lockedPlugin.id], plugins: [request.lockedPlugin] }
236
+ : null;
237
+ const parsed = request.lockedPlugin
238
+ ? { command: "verify", pluginId: canonicalId, source: null }
239
+ : { command: "add", pluginId: canonicalId, source: request.source || null };
240
+
241
+ await registerRemotePluginSources({
242
+ parsed,
243
+ projectRoot: ctx.target,
244
+ options,
245
+ registry,
246
+ lock,
247
+ });
248
+ const manifestInspection = await inspectRemotePluginContentManifest({
249
+ canonicalId,
250
+ registry,
251
+ version: request.version,
252
+ lockedPlugin: request.lockedPlugin || null,
253
+ });
254
+ if (manifestInspection) {
255
+ return {
256
+ ok: true,
257
+ pluginId: canonicalId,
258
+ version: manifestInspection.version,
259
+ name: manifestInspection.manifest.name,
260
+ skills: listContentSkillChoices(manifestInspection.manifest.content.skills || [], canonicalId),
261
+ };
262
+ }
263
+ await prepareRemotePluginForSkillInspection({
264
+ parsed,
265
+ canonicalId,
266
+ registry,
267
+ lock,
268
+ version: request.version,
269
+ });
270
+
271
+ let plugin = request.lockedPlugin || null;
272
+ if (!plugin) {
273
+ const candidates = registry.listCandidates(canonicalId);
274
+ plugin = candidates.find((candidate) => candidate.version === request.version) || null;
275
+ if (!plugin) {
276
+ throw new PluginRuntimeError(`Marketplace Plugin 版本不存在:${canonicalId}@${request.version}`, {
277
+ code: PLUGIN_RUNTIME_ERROR_CODES.SOURCE_NOT_FOUND,
278
+ path: canonicalId,
279
+ });
280
+ }
281
+ }
282
+
283
+ const pluginPackage = registry.readPackage(plugin);
284
+ return {
285
+ ok: true,
286
+ pluginId: canonicalId,
287
+ version: plugin.version,
288
+ name: pluginPackage.manifest.name,
289
+ skills: listContentSkillChoices(pluginPackage.manifest.content.skills || [], canonicalId),
290
+ };
291
+ }
292
+
293
+ /**
294
+ * 优先用 Provider 的 manifest-only inspection 读取 Skill 清单元数据。
295
+ *
296
+ * 这里不需要运行时包内容,也不需要 canonical tree hash;完整安装/更新仍会在生命周期
297
+ * 中准备固定包并校验摘要。TUI 首屏只展示 manifest 声明,避免为 checkbox 下载 archive。
298
+ *
299
+ * @param {{canonicalId:string,registry:object,version?:string|null,lockedPlugin?:object|null}} context 读取上下文
300
+ * @returns {Promise<{version:string,manifest:import("../plugin/contracts.js").PluginManifest}|null>} manifest inspection 结果
301
+ */
302
+ async function inspectRemotePluginContentManifest({ canonicalId, registry, version, lockedPlugin }) {
303
+ const { sourceId } = parseCanonicalPluginId(canonicalId);
304
+ if (!registry.has(sourceId)) return null;
305
+ const provider = registry.get(sourceId);
306
+ if (typeof provider.inspectContentManifest !== "function") return null;
307
+ return provider.inspectContentManifest(canonicalId, { version, lockedPlugin });
308
+ }
309
+
310
+ /**
311
+ * 为 TUI Skill inspection 准备远程包。
312
+ *
313
+ * inspection 只需要读取用户当前选择的一个包版本;Provider 支持单版本准备时不走完整依赖闭包,
314
+ * 避免为了展示 checkbox 下载同一 Plugin 的全部历史版本。
315
+ *
316
+ * @param {{parsed:object,canonicalId:string,registry:object,lock:object|null,version?:string|null}} context 准备上下文
317
+ * @returns {Promise<void>} 准备完成
318
+ */
319
+ async function prepareRemotePluginForSkillInspection({ parsed, canonicalId, registry, lock, version }) {
320
+ if (lock) {
321
+ await prepareRemotePluginCandidates({ parsed, canonicalId, registry, lock });
322
+ return;
323
+ }
324
+ const { sourceId } = parseCanonicalPluginId(canonicalId);
325
+ const provider = registry.has(sourceId) ? registry.get(sourceId) : null;
326
+ if (version && typeof provider?.prepareVersion === "function") {
327
+ await provider.prepareVersion(canonicalId, version);
328
+ return;
329
+ }
330
+ await prepareRemotePluginCandidates({ parsed, canonicalId, registry, lock });
331
+ }
332
+
196
333
  /**
197
334
  * 探测 GitHub 来源并返回可持久化 descriptor 与兼容性结果。
198
335
  *
@@ -309,10 +446,10 @@ export async function runPluginManagementCommand(parsed, ctx, options, output) {
309
446
  baseUrl: parsed.baseUrl || base.baseUrl,
310
447
  project: parsed.project || base.project,
311
448
  ref: parsed.ref || base.ref || "main",
312
- marketplacePath: parsed.marketplacePath || base.marketplacePath || ".flower-marketplace/marketplace.json",
449
+ marketplacePath: parsed.marketplacePath || base.marketplacePath || ".flower-plugin/marketplace.json",
313
450
  oauth: {
314
451
  applicationId: parsed.applicationId || base.oauth?.applicationId,
315
- scopes: ["read_api", "read_repository"],
452
+ scopes: GITLAB_OAUTH_REQUEST_SCOPES,
316
453
  },
317
454
  };
318
455
  if (sourceType === "github") {
@@ -470,7 +607,16 @@ export async function registerRemotePluginSources({ parsed, projectRoot, options
470
607
  sleep: options.sleep,
471
608
  now: options.now,
472
609
  });
473
- manager = new GitLabCredentialManager({ store: credentialBundle.store, oauth, now: options.now });
610
+ manager = new GitLabCredentialManager({
611
+ store: credentialBundle.store,
612
+ oauth,
613
+ now: options.now,
614
+ persistent: credentialBundle.persistent,
615
+ credentialResolver: options.credentialResolver,
616
+ env: options.env,
617
+ runGlab: options.runGlab,
618
+ glabCommand: options.glabCommand,
619
+ });
474
620
  }
475
621
  // 依赖闭包可能跨 source,因此登记全部可用来源,而不是只登记入口来源。
476
622
  for (const source of sources) {
@@ -17,6 +17,7 @@ import {
17
17
  SKILL_GARDEN_PLUGIN_ID,
18
18
  SkillGardenBuiltinProvider,
19
19
  } from "../builtin-plugins/skill-garden/provider.js";
20
+ import { contentSelectionFromSkillNames } from "../plugin/content-selection.js";
20
21
 
21
22
  const REMOTE_PLUGIN_ENTRY = new URL("./plugin-remote.js", import.meta.url);
22
23
  const PATCH_RUNTIME_ENTRY = new URL("../plugin/install/patch-planner.js", import.meta.url);
@@ -35,6 +36,7 @@ const PLUGIN_CONFLICT_CODES = new Set([
35
36
  PLUGIN_RUNTIME_ERROR_CODES.DEPENDENCY_MISSING,
36
37
  PLUGIN_RUNTIME_ERROR_CODES.DEPENDENCY_CONFLICT,
37
38
  PLUGIN_RUNTIME_ERROR_CODES.DEPENDENCY_CYCLE,
39
+ PLUGIN_RUNTIME_ERROR_CODES.CONTENT_SELECTION_INVALID,
38
40
  PLUGIN_RUNTIME_ERROR_CODES.CONTENT_CONFLICT,
39
41
  PLUGIN_RUNTIME_ERROR_CODES.TARGET_DRIFT,
40
42
  PLUGIN_RUNTIME_ERROR_CODES.VERIFY_FAILED,
@@ -227,7 +229,7 @@ function parseManagementArgs(argv) {
227
229
  * 解析 Plugin 多级命令参数。
228
230
  *
229
231
  * @param {string[]} argv `plugin` 后的参数
230
- * @returns {{command:string,pluginId:string|null,source:string|null,version:string|null,platforms:string[],dryRun:boolean,json:boolean,help:boolean}} 解析结果
232
+ * @returns {{command:string,pluginId:string|null,source:string|null,version:string|null,platforms:string[],contentSelection:object|null,dryRun:boolean,json:boolean,help:boolean}} 解析结果
231
233
  */
232
234
  export function parsePluginArgs(argv) {
233
235
  const authoring = parsePluginAuthoringArgs(argv);
@@ -238,6 +240,7 @@ export function parsePluginArgs(argv) {
238
240
  const command = rootHelp ? "list" : (argv[0] || "list");
239
241
  const positional = [];
240
242
  const platforms = [];
243
+ const contentSkills = [];
241
244
  const widen = [];
242
245
  let source = null;
243
246
  let version = null;
@@ -247,7 +250,13 @@ export function parsePluginArgs(argv) {
247
250
 
248
251
  for (let index = 1; index < argv.length; index += 1) {
249
252
  const token = argv[index];
250
- if (token === "--source" || token === "--version" || token === "--platform" || token === "--widen") {
253
+ if (
254
+ token === "--source" ||
255
+ token === "--version" ||
256
+ token === "--platform" ||
257
+ token === "--widen" ||
258
+ token === "--content-skill"
259
+ ) {
251
260
  const value = argv[index + 1];
252
261
  if (!value || value.startsWith("--")) {
253
262
  throw new PluginRuntimeError(`${token} 缺少取值`, {
@@ -259,6 +268,7 @@ export function parsePluginArgs(argv) {
259
268
  if (token === "--source") source = value;
260
269
  else if (token === "--version") version = value;
261
270
  else if (token === "--widen") widen.push(value);
271
+ else if (token === "--content-skill") contentSkills.push(value);
262
272
  else platforms.push(value);
263
273
  continue;
264
274
  }
@@ -341,6 +351,24 @@ export function parsePluginArgs(argv) {
341
351
  path: command,
342
352
  });
343
353
  }
354
+ const contentSelection = contentSkills.length > 0
355
+ ? contentSelectionFromSkillNames(contentSkills, {
356
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
357
+ path: "--content-skill",
358
+ })
359
+ : null;
360
+ if (contentSelection && !["add", "update"].includes(command)) {
361
+ throw new PluginRuntimeError("--content-skill 仅支持 plugin add 与单个 plugin update", {
362
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
363
+ path: command,
364
+ });
365
+ }
366
+ if (contentSelection && command === "update" && (!positional[0] || widen.length > 0)) {
367
+ throw new PluginRuntimeError("--content-skill 仅支持单个 plugin update,且不能与 --widen 同时使用", {
368
+ code: PLUGIN_RUNTIME_ERROR_CODES.USAGE_ERROR,
369
+ path: "--content-skill",
370
+ });
371
+ }
344
372
  return {
345
373
  command,
346
374
  pluginId: positional[0] || null,
@@ -348,6 +376,7 @@ export function parsePluginArgs(argv) {
348
376
  version,
349
377
  widen: parseWidenPairs(widen),
350
378
  platforms: parsePlatforms(platforms),
379
+ contentSelection,
351
380
  dryRun,
352
381
  json,
353
382
  help,
@@ -365,8 +394,8 @@ function printPluginHelp(output) {
365
394
  : "";
366
395
  output.log(`用法:
367
396
  flower-trellis plugin list [--json]
368
- flower-trellis plugin add <plugin> [--source <来源 ID|项目内路径>] [--version <range>] [--platform <id>] [--dry-run] [--json]
369
- flower-trellis plugin update [plugin] [--version <range>] [--widen <plugin>=<range>]... [--platform <id>] [--dry-run] [--json]
397
+ flower-trellis plugin add <plugin> [--source <来源 ID|项目内路径>] [--version <range>] [--platform <id>] [--content-skill <name>]... [--dry-run] [--json]
398
+ flower-trellis plugin update [plugin] [--version <range>] [--widen <plugin>=<range>]... [--platform <id>] [--content-skill <name>]... [--dry-run] [--json]
370
399
  flower-trellis plugin remove <plugin> [--dry-run] [--json]
371
400
  flower-trellis plugin verify [plugin] [--json]
372
401
  flower-trellis plugin init --id <source/plugin> --name <name> [--version <semver>] [--profile <standard|integration>] [--patches] [--marketplace] [--non-interactive] [--json]
@@ -875,6 +904,7 @@ export async function plugin(ctx, options = {}) {
875
904
  : "*"
876
905
  ),
877
906
  platforms: parsed.platforms,
907
+ ...(parsed.contentSelection ? { contentSelection: parsed.contentSelection } : {}),
878
908
  dryRun: parsed.dryRun,
879
909
  onPreflight: options.onPreflight,
880
910
  };
@@ -893,6 +923,7 @@ export async function plugin(ctx, options = {}) {
893
923
  : parsed.version ? { version: parsed.version } : {}),
894
924
  ...(Object.keys(parsed.widen || {}).length > 0 ? { widen: parsed.widen } : {}),
895
925
  platforms: parsed.platforms,
926
+ ...(parsed.contentSelection ? { contentSelection: parsed.contentSelection } : {}),
896
927
  dryRun: parsed.dryRun,
897
928
  onPreflight: options.onPreflight,
898
929
  };
@@ -444,6 +444,16 @@ function isCommonSkillInstalled(target, name) {
444
444
  );
445
445
  }
446
446
 
447
+ /**
448
+ * 判断目标是否已经初始化为 Trellis 项目。
449
+ *
450
+ * @param {string} target 目标项目根目录
451
+ * @returns {boolean} 是否存在 `.trellis` 入口
452
+ */
453
+ function hasTrellisProject(target) {
454
+ return fs.existsSync(path.join(target, ".trellis"));
455
+ }
456
+
447
457
  /**
448
458
  * 读取当前可管理的通用技能元数据。
449
459
  *
@@ -502,6 +512,15 @@ function listWorkflowEnhancementSkills(target, variantDir) {
502
512
  * @returns {{variant: string, version: string, commonSkills: object[], enhancementSkills: object[]}} 菜单清单
503
513
  */
504
514
  export function listSkillCatalog(target, variantOverride) {
515
+ if (!hasTrellisProject(target)) {
516
+ return {
517
+ variant: variantOverride || "common",
518
+ version: "",
519
+ commonSkills: listCommonSkills(target),
520
+ enhancementSkills: [],
521
+ };
522
+ }
523
+
505
524
  const { variant, version, variantDir } = resolveEnhancementSnapshot(
506
525
  target,
507
526
  variantOverride,
@@ -625,7 +644,7 @@ export function syncInstalledCommonSkills(target) {
625
644
  * @returns {{removed: string[], skipped: string[]}} 已删除与未发现的路径
626
645
  */
627
646
  export function removeCommonSkills(target, variantOverride, names) {
628
- resolveEnhancementSnapshot(target, variantOverride);
647
+ if (hasTrellisProject(target)) resolveEnhancementSnapshot(target, variantOverride);
629
648
  const available = new Set(listCommonSnapshotNames());
630
649
  const removed = [];
631
650
  const skipped = [];
@@ -8,8 +8,8 @@ import { readGitDeveloper } from "./developer.js";
8
8
  import { readManifest, readUpdateCheck } from "./manifest.js";
9
9
  import { flowerVersion, trellisVersion } from "./versions.js";
10
10
 
11
- /** Flower 遥测接收地址。 */
12
- export const FLOWER_TELEMETRY_ENDPOINT = "https://ai-api.hub.flower-cli.com/";
11
+ /** Flower 遥测接收地址。主机名与 `/api/flower-trellis` 路由前缀都由服务端契约固定,缺一不可。 */
12
+ export const FLOWER_TELEMETRY_ENDPOINT = "https://ai-api.hub.flower-cli.com/api/flower-trellis/telemetry";
13
13
 
14
14
  const TELEMETRY_SCHEMA_VERSION = 1;
15
15
  /** 遥测请求保留 10 秒公网预算,避免慢速 TLS 建连或服务端响应被过早中止。 */
@@ -60,6 +60,45 @@ function classifyHeaderLine(line, bannerSet) {
60
60
  return "content";
61
61
  }
62
62
 
63
+ /**
64
+ * 上游 `trellis update` 的 npm 落后提示行。用宽松匹配容忍上游微调文案,
65
+ * 捕获组取出它自报的 CLI 版本,复用到 Flower 的替代说明里。
66
+ */
67
+ const UPSTREAM_UPGRADE_NOTICE = /Your CLI \(([^)]+)\) is behind npm\b/;
68
+
69
+ /**
70
+ * 上游紧跟其后的引导动作行。只匹配独立成行的 `Run: trellis upgrade`,
71
+ * 避免误伤降级分支里 `1. Update your CLI: trellis upgrade` 这类真实错误指引。
72
+ */
73
+ const UPSTREAM_UPGRADE_ACTION = /^Run:\s*trellis upgrade$/;
74
+
75
+ /**
76
+ * 正文开始后继续按行过滤的行数上限。
77
+ *
78
+ * 上游版本提示固定打印在 `trellis update` 输出最前面,给一个有限窗口即可覆盖;
79
+ * 设上限是为了不让不完整行长期滞留在缓冲区里,影响后续交互输出的实时性。
80
+ */
81
+ const UPSTREAM_NOTICE_LINE_BUDGET = 40;
82
+
83
+ /**
84
+ * 改写上游 trellis 的 npm 升级提示。
85
+ *
86
+ * Flower 在 package.json 里固定 `@mindfoldhq/trellis` 版本,并由 postinstall 同步全局 CLI,
87
+ * 所以上游「CLI 落后于 npm,请运行 trellis upgrade」是错误引导:照做会让全局 CLI 脱离
88
+ * Flower 的版本控制,下次安装又被同步回去。这里替换成 Flower 自己的说明而不是静默丢弃,
89
+ * 让用户仍然知道版本检查发生过、以及为什么不需要照做。
90
+ *
91
+ * @param {string} line 未去色的原始输出行
92
+ * @returns {string|null|undefined} 替换文案;null 表示丢弃该行;undefined 表示不匹配
93
+ */
94
+ export function rewriteUpstreamUpgradeNotice(line) {
95
+ const t = stripAnsi(line).trim();
96
+ const notice = UPSTREAM_UPGRADE_NOTICE.exec(t);
97
+ if (notice) return ` · Trellis 版本由 Flower 固定(${notice[1]}),已忽略上游 npm 升级提示`;
98
+ if (UPSTREAM_UPGRADE_ACTION.test(t)) return null;
99
+ return undefined;
100
+ }
101
+
63
102
  /**
64
103
  * 用当前 node 执行 trellis bin(普通 spawn),透传子命令与参数。
65
104
  *
@@ -83,6 +122,11 @@ export function runTrellis(args, cwd, opts = {}) {
83
122
  const banner = trellisBannerLines();
84
123
  const rl = createInterface({ input: child.stdout });
85
124
  rl.on("line", (line) => {
125
+ const notice = rewriteUpstreamUpgradeNotice(line);
126
+ if (notice !== undefined) {
127
+ if (notice !== null) process.stdout.write(notice + "\n");
128
+ return;
129
+ }
86
130
  const kind = classifyHeaderLine(line, banner);
87
131
  // 普通(非 pty)场景一直按行过滤即可:trellis 此时是 -y 非交互,无 inquirer
88
132
  if (kind === "skip") return;
@@ -99,10 +143,15 @@ export function runTrellis(args, cwd, opts = {}) {
99
143
 
100
144
  /**
101
145
  * 在伪终端(node-pty)里运行 trellis,**保留其全部交互**(模板 / monorepo / 冲突菜单),
102
- * 同时过滤掉它开头重复打印的启动 banner / 副标题 / Developer / Mode。
146
+ * 同时过滤掉它开头重复打印的启动 banner / 副标题 / Developer / Mode,
147
+ * 以及它自带的、与 Flower 版本固定策略冲突的 npm 升级提示。
148
+ *
149
+ * 过滤分三个阶段:
150
+ * - "header":逐行识别 banner 类行并丢弃(proxy 保留),遇到正文行转入 "notice";
151
+ * - "notice":只改写上游 npm 升级提示,其余原样输出,超出行数预算后转入 "raw";
152
+ * - "raw":完全透传。
103
153
  *
104
- * 过滤只作用于「头部阶段」:逐行识别 banner 类行并丢弃(proxy 保留),
105
- * 一旦遇到正文行或 inquirer 渲染(隐藏光标序列 ESC[?25l)立即停止过滤、转为完全透传,
154
+ * 任一阶段遇到 inquirer 渲染(隐藏光标序列 ESC[?25l)都立即转入 "raw",
106
155
  * 以免破坏交互菜单的光标控制。
107
156
  *
108
157
  * @param {string[]} args
@@ -133,17 +182,18 @@ export function runTrellisPty(args, cwd, opts = {}) {
133
182
  }
134
183
 
135
184
  const bannerSet = trellisBannerLines();
136
- let filtering = !!opts.stripBanner;
185
+ let phase = opts.stripBanner ? "header" : "raw";
186
+ let noticeBudget = UPSTREAM_NOTICE_LINE_BUDGET;
137
187
  let buf = "";
138
188
 
139
189
  const dataSubscription = child.onData((data) => {
140
- if (!filtering) {
190
+ if (phase === "raw") {
141
191
  stdout.write(data);
142
192
  return;
143
193
  }
144
194
  // inquirer 开始渲染(隐藏光标)→ 立即停止过滤,整体透传,保住交互菜单
145
195
  if (data.includes("\x1b[?25l")) {
146
- filtering = false;
196
+ phase = "raw";
147
197
  stdout.write(buf + data);
148
198
  buf = "";
149
199
  return;
@@ -152,16 +202,32 @@ export function runTrellisPty(args, cwd, opts = {}) {
152
202
  const parts = buf.split(/\r?\n/);
153
203
  buf = parts.pop(); // 末尾不完整行留待下次
154
204
  for (const line of parts) {
155
- const kind = classifyHeaderLine(line, bannerSet);
156
- if (kind === "skip") continue;
157
- if (kind === "keep") {
205
+ if (phase === "raw") {
206
+ stdout.write(line + "\r\n");
207
+ continue;
208
+ }
209
+ if (phase === "notice" && noticeBudget-- <= 0) {
210
+ phase = "raw";
158
211
  stdout.write(line + "\r\n");
159
212
  continue;
160
213
  }
161
- filtering = false; // 正文开始
214
+ const notice = rewriteUpstreamUpgradeNotice(line);
215
+ if (notice !== undefined) {
216
+ if (notice !== null) stdout.write(notice + "\r\n");
217
+ continue;
218
+ }
219
+ if (phase === "header") {
220
+ const kind = classifyHeaderLine(line, bannerSet);
221
+ if (kind === "skip") continue;
222
+ if (kind === "keep") {
223
+ stdout.write(line + "\r\n");
224
+ continue;
225
+ }
226
+ phase = "notice"; // 正文开始:banner 过滤结束,但仍在有限窗口内改写上游版本提示
227
+ }
162
228
  stdout.write(line + "\r\n");
163
229
  }
164
- if (!filtering && buf) {
230
+ if (phase === "raw" && buf) {
165
231
  stdout.write(buf);
166
232
  buf = "";
167
233
  }
@@ -185,6 +251,11 @@ export function runTrellisPty(args, cwd, opts = {}) {
185
251
  stdout.on("resize", onResize);
186
252
 
187
253
  child.onExit(({ exitCode, signal }) => {
254
+ // 过滤阶段可能扣着一行不完整输出(末尾无换行),退出前补发,避免最后一行被吞掉。
255
+ if (buf) {
256
+ stdout.write(buf);
257
+ buf = "";
258
+ }
188
259
  // 先停止接收子进程输出,再恢复宿主终端;否则迟到的 9001h 会重新污染父终端。
189
260
  dataSubscription.dispose();
190
261
  stdin.off("data", onStdin);