@frontera-sdk/cli 1.52.39 → 1.52.40

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 (225) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +48 -4
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/index-commands.ts +69 -7
  33. package/src/commands/agent/resolve.ts +2 -0
  34. package/src/commands/app/checks.ts +44 -0
  35. package/src/commands/app/deploy.ts +243 -60
  36. package/src/commands/app/deployments.ts +97 -0
  37. package/src/commands/app/dev.ts +103 -11
  38. package/src/commands/app/init.ts +36 -3
  39. package/src/commands/app/promote.ts +45 -19
  40. package/src/commands/app/pull.ts +3 -1
  41. package/src/commands/app/rollback.ts +46 -0
  42. package/src/commands/app/sdk.ts +54 -8
  43. package/src/commands/app/unpin.ts +27 -0
  44. package/src/commands/app/versions.ts +1 -1
  45. package/src/commands/automation/bindings.ts +4 -2
  46. package/src/commands/automation/build-entry.ts +4 -1
  47. package/src/commands/automation/index-commands.ts +3 -1
  48. package/src/commands/automation/pull.ts +6 -1
  49. package/src/commands/automation/run.ts +34 -9
  50. package/src/commands/blueprint/bind.ts +2 -1
  51. package/src/commands/blueprint/declarative.ts +331 -31
  52. package/src/commands/blueprint/get.ts +6 -2
  53. package/src/commands/blueprint/grants.ts +64 -10
  54. package/src/commands/blueprint/metric.ts +108 -0
  55. package/src/commands/blueprint/object-sets.ts +71 -0
  56. package/src/commands/blueprint/query.ts +155 -31
  57. package/src/commands/dataset/index-commands.ts +156 -7
  58. package/src/commands/knowledge/index-commands.ts +122 -20
  59. package/src/commands/local/check.ts +657 -44
  60. package/src/commands/local/data.ts +33 -12
  61. package/src/commands/local/dev.ts +875 -88
  62. package/src/commands/local/docs-mcp.ts +10 -18
  63. package/src/commands/local/index-commands.ts +300 -63
  64. package/src/commands/local/init.ts +18 -5
  65. package/src/commands/local/key.ts +0 -3
  66. package/src/commands/local/providers.ts +0 -7
  67. package/src/commands/local/remove.ts +116 -0
  68. package/src/commands/local/schema.ts +3 -4
  69. package/src/commands/local/sdk-refresh.ts +59 -0
  70. package/src/commands/local/snapshot.ts +100 -0
  71. package/src/commands/local/sync-request.ts +320 -13
  72. package/src/commands/local/sync.ts +102 -5
  73. package/src/commands/mcp/serve.ts +34 -0
  74. package/src/commands/registry.ts +52 -8
  75. package/src/commands/types.ts +29 -0
  76. package/src/commands/workflow/archive.ts +46 -0
  77. package/src/commands/workflow/get.ts +2 -1
  78. package/src/commands/workflow/index-commands.ts +5 -0
  79. package/src/commands/workflow/push.ts +49 -2
  80. package/src/commands/workflow/run.ts +65 -7
  81. package/src/commands/workflow/runs.ts +9 -6
  82. package/src/commands/workflow/webhook-test.ts +166 -0
  83. package/src/config.ts +43 -6
  84. package/src/dev-broker.ts +52 -0
  85. package/src/exit.ts +103 -4
  86. package/src/flag-help.ts +77 -17
  87. package/src/forge/touches.ts +15 -12
  88. package/src/function-outside-imports.ts +89 -0
  89. package/src/harness.ts +42 -24
  90. package/src/heal.ts +112 -1
  91. package/src/local/fix-words.ts +12 -0
  92. package/src/local/function-bundle-recovery.ts +775 -0
  93. package/src/local/local-actions.ts +103 -10
  94. package/src/local/local-agent-action-grants.ts +210 -0
  95. package/src/local/local-agent-checks.ts +232 -0
  96. package/src/local/local-agent-grants.ts +29 -7
  97. package/src/local/local-agent-lean-plugins.ts +68 -0
  98. package/src/local/local-agent-missing-tools.ts +68 -0
  99. package/src/local/local-agent-name.ts +27 -0
  100. package/src/local/local-app-code.ts +191 -0
  101. package/src/local/local-app-dev-files.ts +186 -0
  102. package/src/local/local-app-dev-session.ts +88 -0
  103. package/src/local/local-app-lock-install.ts +114 -0
  104. package/src/local/local-app-pulled.ts +167 -0
  105. package/src/local/local-app-restore.ts +111 -0
  106. package/src/local/local-app-runtime.ts +68 -0
  107. package/src/local/local-app-sdk-paths.ts +149 -0
  108. package/src/local/local-app-sdk.ts +509 -0
  109. package/src/local/local-applied.ts +211 -21
  110. package/src/local/local-apply-history.ts +84 -0
  111. package/src/local/local-apply-summary.ts +86 -0
  112. package/src/local/local-apply-watchdog.ts +95 -0
  113. package/src/local/local-apps.ts +180 -7
  114. package/src/local/local-blueprint-errors.ts +198 -0
  115. package/src/local/local-blueprint-outline.ts +31 -12
  116. package/src/local/local-blueprint-schemas.ts +582 -0
  117. package/src/local/local-build-inputs.ts +48 -0
  118. package/src/local/local-build-summary.ts +495 -28
  119. package/src/local/local-case-evidence.ts +120 -0
  120. package/src/local/local-case-not-run.ts +153 -0
  121. package/src/local/local-chat-apps.ts +93 -16
  122. package/src/local/local-check-inherited.ts +83 -0
  123. package/src/local/local-cities.ts +89 -0
  124. package/src/local/local-component-health.ts +119 -0
  125. package/src/local/local-control.ts +34 -7
  126. package/src/local/local-db-snapshot.ts +348 -0
  127. package/src/local/local-dev-references.ts +346 -35
  128. package/src/local/local-dev-sync.ts +136 -7
  129. package/src/local/local-docker.ts +35 -1
  130. package/src/local/local-docs-search.ts +1 -1
  131. package/src/local/local-docs.ts +281 -2
  132. package/src/local/local-empty-workflows.ts +92 -0
  133. package/src/local/local-env.ts +16 -1
  134. package/src/local/local-file-checks.ts +104 -0
  135. package/src/local/local-function-ids.ts +141 -0
  136. package/src/local/local-function-recovery.ts +141 -0
  137. package/src/local/local-function-state.ts +87 -0
  138. package/src/local/local-keys.ts +7 -5
  139. package/src/local/local-knowledge-ingestion.ts +225 -0
  140. package/src/local/local-knowledge-uploads.ts +217 -0
  141. package/src/local/local-media-placeholders.ts +182 -0
  142. package/src/local/local-media-sets.ts +90 -10
  143. package/src/local/local-model-parameters.ts +89 -0
  144. package/src/local/local-object-sets.ts +18 -6
  145. package/src/local/local-operating.ts +35 -1
  146. package/src/local/local-org-settings.ts +205 -7
  147. package/src/local/local-orphans.ts +321 -0
  148. package/src/local/local-personas.ts +296 -0
  149. package/src/local/local-portability.ts +141 -16
  150. package/src/local/local-ports.ts +99 -7
  151. package/src/local/local-project-notices.ts +234 -0
  152. package/src/local/local-project-secrets.ts +133 -0
  153. package/src/local/local-project.ts +12 -0
  154. package/src/local/local-provider-refusal.ts +66 -0
  155. package/src/local/local-request-evidence.ts +112 -0
  156. package/src/local/local-runtime-health.ts +123 -0
  157. package/src/local/local-runtime-history.ts +50 -0
  158. package/src/local/local-runtime-target.ts +42 -0
  159. package/src/local/local-sample-data.ts +141 -0
  160. package/src/local/local-sample-remake.ts +138 -0
  161. package/src/local/local-scaffold.ts +336 -53
  162. package/src/local/local-schema-check.ts +148 -0
  163. package/src/local/local-schemas.ts +742 -25
  164. package/src/local/local-secret-carry.ts +107 -0
  165. package/src/local/local-secrets.ts +38 -8
  166. package/src/local/local-seed.ts +102 -26
  167. package/src/local/local-stand-ins.ts +1 -1
  168. package/src/local/local-state.ts +43 -2
  169. package/src/local/local-studio-server.ts +170 -7
  170. package/src/local/local-supervisor.ts +44 -6
  171. package/src/local/local-sync-names.ts +2 -0
  172. package/src/local/local-sync-plan.ts +11 -0
  173. package/src/local/local-sync-push.ts +10 -7
  174. package/src/local/local-sync-refs.ts +49 -3
  175. package/src/local/local-sync-remote.ts +343 -32
  176. package/src/local/local-sync-snapshot.ts +15 -10
  177. package/src/local/local-synthetic-data.ts +2116 -99
  178. package/src/local/local-test-baseline.ts +113 -0
  179. package/src/local/local-test-cases.ts +76 -1
  180. package/src/local/local-test-runner.ts +582 -91
  181. package/src/local/local-test-runs.ts +13 -5
  182. package/src/local/local-vendored-sdk.ts +183 -0
  183. package/src/local/local-workflow-state.ts +16 -3
  184. package/src/local/local-workflow-tests.ts +482 -0
  185. package/src/local/local-workspace-grants.ts +81 -0
  186. package/src/main.ts +50 -9
  187. package/src/mcp/a11y-scan.ts +55 -0
  188. package/src/mcp/browser/ax-snapshot.ts +181 -0
  189. package/src/mcp/browser/credentials.ts +78 -0
  190. package/src/mcp/browser/frames.ts +336 -0
  191. package/src/mcp/browser/headless-session.ts +413 -0
  192. package/src/mcp/browser/headless-tab.ts +575 -0
  193. package/src/mcp/browser/keys.ts +70 -0
  194. package/src/mcp/browser/page-scripts.ts +129 -0
  195. package/src/mcp/browser/wait.ts +73 -0
  196. package/src/mcp/forge-link.ts +126 -0
  197. package/src/mcp/headless.ts +199 -0
  198. package/src/mcp/mcp-apps.ts +435 -0
  199. package/src/mcp/mcp-chat.ts +226 -0
  200. package/src/mcp/mcp-cli.ts +53 -0
  201. package/src/mcp/mcp-protocol.ts +122 -0
  202. package/src/mcp/mcp-runtime.ts +113 -0
  203. package/src/mcp/mcp-server.ts +108 -0
  204. package/src/mcp/mcp-traces.ts +409 -0
  205. package/src/mcp/page-observation.ts +193 -0
  206. package/src/mcp/tools/agent-tools.ts +700 -0
  207. package/src/mcp/tools/app-tools.ts +241 -0
  208. package/src/mcp/tools/browser-tools.ts +310 -0
  209. package/src/mcp/tools/docs-tools.ts +32 -0
  210. package/src/mcp/tools/project-tools.ts +205 -0
  211. package/src/mcp/tools/test-tools.ts +339 -0
  212. package/src/mcp/tools/workflow-tools.ts +611 -0
  213. package/src/output.ts +1 -1
  214. package/src/pack.ts +8 -3
  215. package/src/packaging.ts +1 -1
  216. package/src/project-bootstrap.ts +107 -4
  217. package/src/project-context.ts +18 -1
  218. package/src/project.ts +27 -2
  219. package/src/provenance.ts +25 -0
  220. package/src/render-evidence.ts +65 -7
  221. package/src/template.ts +14 -8
  222. package/src/templates/next-app-files.ts +35 -6
  223. package/src/templates/next-skills.ts +61 -27
  224. package/src/vendor/kit-assets.json +6 -6
  225. package/src/vendor/sdk-sources.json +24 -21
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs'
2
2
  import { basename, dirname, join, relative, resolve, sep } from 'node:path'
3
3
 
4
4
  import { agentIdentity, createFlags } from './local-agent-name'
5
+ import { widerGrants, type WorkspaceGrant } from './local-workspace-grants'
5
6
 
6
7
  /**
7
8
  * What `frontera local dev` applies when a file in the solution changes.
@@ -196,7 +197,9 @@ export function allTargets(root: string): SyncTarget[] {
196
197
  if (depth > 6 || !existsSync(dir)) return
197
198
  let names: string[]
198
199
  try {
199
- names = readdirSync(dir)
200
+ // Sorted: the order a directory lists its files in is the file system's (macOS sorts, Linux does not),
201
+ // and items of one kind are applied, and listed, in this order.
202
+ names = readdirSync(dir).sort()
200
203
  } catch {
201
204
  return
202
205
  }
@@ -309,9 +312,11 @@ export interface Fallback {
309
312
  * as a new snapshot (`dataset create` refuses a name in use). A dataset with
310
313
  * no CSV that exists already has nothing more to load: an empty `then`.
311
314
  *
312
- * Only rows reach an existing dataset. A changed definition (columns, keys)
313
- * is a new revision of the dataset, which `local dev` does not make; after a
314
- * `frontera local reset` the definition is created as written.
315
+ * Only rows reach an existing dataset. A CSV with empty values a column of its
316
+ * revision does not allow is refused; `local dev` then cuts the dataset's next
317
+ * revision from it (`dataset revise`, which only widens nullability), binds the
318
+ * object types reading it to that revision and applies the Blueprint again. A
319
+ * column added, dropped or retyped is a new dataset, and stays refused.
315
320
  */
316
321
  export function fallbackFor(target: SyncTarget, profiles: SyncProfiles, root: string): Fallback | null {
317
322
  if (target.kind === 'agent') {
@@ -319,6 +324,8 @@ export function fallbackFor(target: SyncTarget, profiles: SyncProfiles, root: st
319
324
  const named = createFlags(agentIdentity(root, target.file))
320
325
  return { when: /NOT_FOUND/, then: [['agent', 'create', target.slug, ...named, '--profile', profiles.workspace], commandFor(target, profiles)] }
321
326
  }
327
+ // A workflow archived on the runtime takes no new version: restored, then pushed (the files say it is in service).
328
+ if (target.kind === 'workflow') return { when: /is archived, so it cannot/i, then: [['workflow', 'restore', target.slug, '--profile', profiles.workspace], commandFor(target, profiles)] }
322
329
  if (target.kind === 'dataset') {
323
330
  const doc = datasetDocument(root, target)
324
331
  const upload = doc?.csv ? [['dataset', 'upload', doc.apiName, doc.csv, '--profile', profiles.organization]] : []
@@ -342,7 +349,7 @@ export interface FollowUp {
342
349
  export function followUpsFor(
343
350
  target: SyncTarget,
344
351
  profiles: SyncProfiles,
345
- opts: { publish: boolean; workspaceId: string; objectTypes: string[] },
352
+ opts: { publish: boolean; workspaceId: string; objectTypes: string[]; grants?: WorkspaceGrant[] },
346
353
  ): FollowUp[] {
347
354
  if (target.kind !== 'blueprint' || !opts.publish) return []
348
355
  const steps: FollowUp[] = [{
@@ -357,8 +364,24 @@ export function followUpsFor(
357
364
  ],
358
365
  alreadyDone: /no unpublished changes/i,
359
366
  }]
360
- if (opts.objectTypes.length) {
361
- steps.push({ args: ['blueprint', 'grant', 'workspace', opts.workspaceId, ...opts.objectTypes, '--profile', profiles.organization] })
367
+ // `blueprint/workspace-grants.yaml`: a type granted more than `read` is granted that, after the
368
+ // plain grants (each grant sets its whole permission list, so the plain one would undo it). A
369
+ // plain grant keeps what the type's grant already holds; a type the file lists as `[read]` is
370
+ // granted exactly that, which is how the file takes `read_sensitive` away.
371
+ const wider = widerGrants(opts.grants ?? [])
372
+ const widened = new Set([...wider.values()].flat())
373
+ const onlyRead = new Set((opts.grants ?? []).filter((g) => g.permissions.length <= 1).map((g) => g.objectType))
374
+ const plain = opts.objectTypes.filter((t) => !widened.has(t) && !onlyRead.has(t))
375
+ if (plain.length) {
376
+ steps.push({ args: ['blueprint', 'grant', 'workspace', opts.workspaceId, ...plain, '--profile', profiles.organization] })
377
+ }
378
+ const exactlyRead = opts.objectTypes.filter((t) => onlyRead.has(t) && !widened.has(t))
379
+ if (exactlyRead.length) {
380
+ steps.push({ args: ['blueprint', 'grant', 'workspace', opts.workspaceId, ...exactlyRead, '--permissions', 'read', '--profile', profiles.organization] })
381
+ }
382
+ for (const [permissions, types] of wider) {
383
+ const present = types.filter((t) => opts.objectTypes.includes(t))
384
+ if (present.length) steps.push({ args: ['blueprint', 'grant', 'workspace', opts.workspaceId, ...present, '--permissions', permissions, '--profile', profiles.organization] })
362
385
  }
363
386
  return steps
364
387
  }
@@ -418,3 +441,109 @@ export function describeTarget(target: SyncTarget): string {
418
441
  return 'the library installations'
419
442
  }
420
443
  }
444
+
445
+ /** The Blueprint's folders: `blueprint apply` reads the model's, `local dev` saves the object sets. */
446
+ const BLUEPRINT_FOLDERS = new Set(['object-types', 'link-types', 'shared-fields', 'semantic-types', 'metrics', 'actions', 'object-sets'])
447
+
448
+ /** What an Agent's folder holds that something reads: `local dev` applies the files, `local test` the cases. */
449
+ const AGENT_FILES = new Set(['agent.json', 'operating.json', 'bindings.json', 'lessons.yaml'])
450
+
451
+ /**
452
+ * Why a file in one of the folders `local dev` applies is not applied, or null
453
+ * when it is (or is read by something else: test cases, fixtures, a
454
+ * Function's helpers, a knowledge base's documents). Said instead of ignoring
455
+ * the file in silence: `themes/acme.json` is not a theme, `themes/acme/theme.json` is.
456
+ */
457
+ export function unappliedReason(root: string, rel: string): string | null {
458
+ const parts = rel.split(sep)
459
+ if (parts.some((p) => IGNORED_DIRS.has(p) || isIgnoredName(p))) return null
460
+ const [top, second, third] = parts
461
+ const name = parts[parts.length - 1]!
462
+ if (/^(readme|agents|claude)\.md$/i.test(name)) return null
463
+ // Any YAML under blueprint/ queues a Blueprint apply, which reads only the kinds' folders.
464
+ if (top !== 'blueprint' && targetFor(root, join(root, rel))) return null
465
+ switch (top) {
466
+ case 'themes':
467
+ if (parts.length === 2) return `a slide theme is themes/<name>/theme.json, with its images beside it (\`frontera local schema theme\`): move it to themes/${name.replace(/\.(json|ya?ml)$/, '')}/theme.json`
468
+ return `themes/${second}/ has no theme.json, so nothing in it is applied (\`frontera local schema theme\`)`
469
+ case 'agents':
470
+ if (parts.length === 2) return 'an Agent is a folder: agents/<slug>/agent.json'
471
+ if (third === 'evals' || third === 'fixtures') return null
472
+ if (parts.length === 3 && !AGENT_FILES.has(name)) {
473
+ return `an Agent's files are agent.json, operating.json, bindings.json (the Functions and workflows it may start, the object types it may read) and lessons.yaml, with its test cases in evals/`
474
+ }
475
+ return null
476
+ case 'blueprint':
477
+ // The workspace's own grants beyond read, applied after each publish (`local-workspace-grants.ts`).
478
+ if (parts.length === 2 && name === 'workspace-grants.yaml') return null
479
+ if (parts.length === 2) return 'a Blueprint file lives in its kind’s folder: blueprint/object-types/, link-types/, shared-fields/, semantic-types/, metrics/, actions/ or object-sets/, or is blueprint/workspace-grants.yaml (`frontera local schema` lists each)'
480
+ if (!BLUEPRINT_FOLDERS.has(second!)) return `blueprint/${second}/ is not a Blueprint folder; the folders are ${[...BLUEPRINT_FOLDERS].join(', ')}`
481
+ if (!/\.(ya?ml|json)$/.test(name)) return 'a Blueprint file is YAML or JSON'
482
+ if (parts.length > 3) return `a Blueprint file sits directly in blueprint/${second}/`
483
+ return null
484
+ case 'workflows':
485
+ if (second === 'tests') return null
486
+ if (parts.length > 2) return 'a workflow is workflows/<slug>.yaml, directly in workflows/ (its tests go in workflows/tests/)'
487
+ return 'a workflow is workflows/<slug>.yaml (or .yml, .json)'
488
+ case 'datasets':
489
+ if (parts.length > 2) return 'a dataset is datasets/<apiName>.json with its CSV beside it, directly in datasets/'
490
+ return 'a dataset is datasets/<apiName>.json, with datasets/<apiName>.csv beside it for a file dataset'
491
+ case 'knowledge':
492
+ if (parts.length === 2) return 'a knowledge base is a folder: knowledge/<name>/knowledge.json ({ "name": "…", "description": "…" }) with its documents beside it'
493
+ return `knowledge/${second}/ has no knowledge.json ({ "name": "…", "description": "…" }), so its documents are not uploaded`
494
+ case 'skills':
495
+ if (parts.length === 2) return 'a skill is a folder: skills/<name>/SKILL.md'
496
+ return `skills/${second}/ has no SKILL.md, so it is not pushed`
497
+ case 'chat-apps':
498
+ return 'a Chat App is chat-apps/<slug>.json, directly in chat-apps/'
499
+ case 'mcp':
500
+ return 'a Plugin is mcp/<name>.plugin.json (a stand-in mcp/<name>.stand-in.json)'
501
+ default:
502
+ return null
503
+ }
504
+ }
505
+
506
+ /** Every file in the applied folders that `local dev` does not apply, with why: one line each. */
507
+ export function unappliedFiles(root: string): string[] {
508
+ const out: string[] = []
509
+ const walk = (dir: string, depth: number) => {
510
+ if (depth > 4 || !existsSync(dir)) return
511
+ let names: string[]
512
+ try {
513
+ names = readdirSync(dir).sort()
514
+ } catch {
515
+ return
516
+ }
517
+ for (const name of names) {
518
+ if (IGNORED_DIRS.has(name) || isIgnoredName(name)) continue
519
+ const path = join(dir, name)
520
+ let stat
521
+ try {
522
+ stat = lstatSync(path)
523
+ } catch {
524
+ continue
525
+ }
526
+ if (stat.isDirectory()) {
527
+ const rel = relative(root, path)
528
+ const [top, second] = rel.split(sep)
529
+ // Folders whose files are not each applied: a Function's helpers, a knowledge base's documents, an App's code.
530
+ if (top === 'functions' || top === 'apps' || (top === 'knowledge' && second && existsSync(join(root, 'knowledge', second, 'knowledge.json')))) continue
531
+ if (top === 'skills' && second && existsSync(join(root, 'skills', second, 'SKILL.md'))) continue
532
+ if (top === 'themes' && second && existsSync(join(root, 'themes', second, 'theme.json'))) continue
533
+ if (top === 'knowledge' || top === 'skills' || top === 'themes') {
534
+ // One line for the folder, not one per document in it.
535
+ const why = unappliedReason(root, join(rel, '_'))
536
+ if (why && rel.split(sep).length === 2) out.push(`${rel}/: ${why}`)
537
+ continue
538
+ }
539
+ walk(path, depth + 1)
540
+ } else if (stat.isFile()) {
541
+ const rel = relative(root, path)
542
+ const why = unappliedReason(root, rel)
543
+ if (why) out.push(`${rel}: ${why}`)
544
+ }
545
+ }
546
+ }
547
+ for (const top of ['agents', 'blueprint', 'chat-apps', 'datasets', 'knowledge', 'mcp', 'skills', 'themes', 'workflows']) walk(join(root, top), 1)
548
+ return out
549
+ }
@@ -29,7 +29,18 @@ export interface DockerRunSpec {
29
29
 
30
30
  export class DockerError extends Error {}
31
31
 
32
- function docker(args: string[], opts: { input?: string } = {}): { ok: boolean; out: string; err: string } {
32
+ export type DockerResult = { ok: boolean; out: string; err: string }
33
+ export type DockerExec = (args: string[], opts: { input?: string }) => DockerResult
34
+
35
+ let dockerExec: DockerExec | null = null
36
+
37
+ /** Answer every docker call with `exec` instead of the docker CLI; null restores it. For tests. */
38
+ export function setDockerExecForTests(exec: DockerExec | null): void {
39
+ dockerExec = exec
40
+ }
41
+
42
+ function docker(args: string[], opts: { input?: string } = {}): DockerResult {
43
+ if (dockerExec) return dockerExec(args, opts)
33
44
  const res = spawnSync('docker', args, { encoding: 'utf8', input: opts.input })
34
45
  if (res.error) {
35
46
  throw new DockerError(
@@ -133,6 +144,12 @@ export function execInContainer(name: string, cmd: string[]): boolean {
133
144
  return docker(['exec', name, ...cmd]).ok
134
145
  }
135
146
 
147
+ /** As `execInContainer`, with `input` on the command's stdin when given, and what it printed. */
148
+ export function queryInContainer(name: string, cmd: string[], input?: string): { ok: boolean; out: string } {
149
+ const res = docker(['exec', ...(input === undefined ? [] : ['-i']), name, ...cmd], input === undefined ? {} : { input })
150
+ return { ok: res.ok, out: res.out }
151
+ }
152
+
136
153
  /** Follow a container's logs into a file for as long as the container lives. */
137
154
  export function followLogs(name: string, logFile: string): void {
138
155
  const fd = openSync(logFile, 'a')
@@ -144,3 +161,20 @@ export function containersForProject(projectId: string): string[] {
144
161
  const r = docker(['ps', '-a', '--filter', `label=${PROJECT_LABEL}=${projectId}`, '--format', '{{.Names}}'])
145
162
  return r.ok && r.out ? r.out.split('\n') : []
146
163
  }
164
+
165
+ /** A local runtime's database volume, by its id (the key of its port block). */
166
+ export const dbVolumeName = (projectId: string) => `frontera-${projectId}-db`
167
+
168
+ /** A local runtime's file storage volume, by its id. */
169
+ export const storageVolumeName = (projectId: string) => `frontera-${projectId}-storage`
170
+
171
+ /**
172
+ * Delete a local runtime's containers and volumes, by its id: the containers
173
+ * labelled for it, running or not, and its database and storage volumes. Its
174
+ * data is gone afterwards.
175
+ */
176
+ export function removeRuntimeResources(projectId: string): void {
177
+ for (const name of containersForProject(projectId)) removeContainer(name)
178
+ removeVolume(dbVolumeName(projectId))
179
+ removeVolume(storageVolumeName(projectId))
180
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Full-text search over a documentation publication, for `local docs-mcp`.
2
+ * Full-text search over a documentation publication, for `frontera mcp`.
3
3
  * The same ranking the platform's documentation search uses (title, aliases,
4
4
  * description, the best matching section, prefix matches), kept here because
5
5
  * the CLI is published on its own and cannot import the service.
@@ -1,4 +1,5 @@
1
- import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs'
1
+ import { spawn } from 'node:child_process'
2
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
2
3
  import { dirname, join } from 'node:path'
3
4
  import { createInterface } from 'node:readline'
4
5
 
@@ -10,7 +11,7 @@ import { isSchemaKind, SCHEMA_FILES, SCHEMA_KINDS, type SchemaKind } from './loc
10
11
  * Frontera's documentation for the coding agents working in a project
11
12
  * (Claude Code, Codex, Cursor). `local start` copies the customer publication
12
13
  * of the release the runtime runs into the runtime's state, and
13
- * `frontera local docs-mcp` serves it over MCP on stdio, straight from that
14
+ * `frontera mcp` serves it over MCP on stdio, straight from that
14
15
  * file: no key, no request to the runtime, and it keeps working while the
15
16
  * runtime is stopped. The formats of the project's files (`local-schemas.ts`)
16
17
  * are served the same way, from the copies `local start` keeps.
@@ -28,6 +29,284 @@ export function customerDocsBundle(platformSource: string | undefined): string |
28
29
  return existsSync(path) ? path : null
29
30
  }
30
31
 
32
+ /** What `local status` says about the documentation a project's coding agents read. */
33
+ export interface DocsStatus {
34
+ /** The copy is there and readable. */
35
+ present: boolean
36
+ /** The product version the copy documents (`productVersion`). */
37
+ version: string | null
38
+ /** The runtime's version: the checkout's in source mode, the release's in image mode. */
39
+ runtimeVersion: string
40
+ builtAt: string | null
41
+ /** The copy documents another version, or (source mode) the checkout's guides changed since it was built. */
42
+ stale: boolean
43
+ /** `local start` is rebuilding the checkout's documentation now; the copy is refreshed when it is done. */
44
+ rebuilding?: boolean
45
+ /** The last rebuild at start failed: the copy stays as it was. */
46
+ rebuildFailed?: boolean
47
+ /** In words, when there is something to say: why it is stale, or that there is none. */
48
+ note: string | null
49
+ }
50
+
51
+ /**
52
+ * The version a platform checkout is at (its root `package.json`): what a
53
+ * runtime started from it runs, recorded as the runtime's version at start.
54
+ */
55
+ export function checkoutVersion(platformSource: string): string | null {
56
+ return readVersion(join(platformSource, 'package.json'))
57
+ }
58
+
59
+ /**
60
+ * The version a runtime records at start, which the Runtime page, `local
61
+ * status` and the Docs note all say: from a checkout, the checkout's version
62
+ * at this start, never one kept from an earlier start (run 8: started from a
63
+ * 1.52.33 checkout, the runtime said 1.52.31, its first start's version); from
64
+ * a release image, the release asked for, else the one an earlier image start
65
+ * used, else this CLI's.
66
+ */
67
+ export function versionAtStart(o: { checkout: string | null; flag: string | undefined; previous: { mode: 'source' | 'image'; platformVersion: string } | null; cli: string }): string {
68
+ if (o.checkout) return o.checkout
69
+ return o.flag ?? (o.previous?.mode === 'image' ? o.previous.platformVersion : undefined) ?? o.cli
70
+ }
71
+
72
+ function readVersion(path: string): string | null {
73
+ try {
74
+ const v = (JSON.parse(readFileSync(path, 'utf8')) as { version?: unknown }).version
75
+ return typeof v === 'string' ? v : null
76
+ } catch {
77
+ return null
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The guides' sources the customer publication is built from. The internal
83
+ * pages (`content/internal`) ship only in the internal distribution, so a
84
+ * change there never makes the customer copy out of date.
85
+ */
86
+ const GUIDE_PATHS = ['packages/docs-content/content', 'packages/docs-content/catalog', 'packages/docs-content/identity', ':(exclude)packages/docs-content/content/internal']
87
+
88
+ /**
89
+ * Whether the guides in a platform checkout changed since the commit a
90
+ * publication was built from (committed or not, new files included). A
91
+ * build reads the working tree, not the commit, so with `builtAt` a file
92
+ * that differs from the commit counts only when it changed after the build
93
+ * (by its modification time): an edit already in the build is not a reason
94
+ * to say the copy is behind. Null when it cannot tell (no git, a commit the
95
+ * checkout does not have).
96
+ */
97
+ export function guidesChangedSince(platformSource: string, commit: string, builtAt?: string | null): boolean | null {
98
+ if (!/^[0-9a-f]{7,64}$/i.test(commit)) return null
99
+ const known = Bun.spawnSync(['git', '-C', platformSource, 'cat-file', '-e', `${commit}^{commit}`], { stdout: 'ignore', stderr: 'ignore' })
100
+ if (known.exitCode !== 0) return null
101
+ const diff = Bun.spawnSync(['git', '-C', platformSource, 'diff', '--name-only', commit, '--', ...GUIDE_PATHS], { stdout: 'pipe', stderr: 'ignore' })
102
+ if (diff.exitCode !== 0) return null
103
+ const added = Bun.spawnSync(['git', '-C', platformSource, 'ls-files', '--others', '--exclude-standard', '--', ...GUIDE_PATHS], { stdout: 'pipe', stderr: 'ignore' })
104
+ const paths = [...diff.stdout.toString().split('\n'), ...(added.exitCode === 0 ? added.stdout.toString().split('\n') : [])].map((l) => l.trim()).filter(Boolean)
105
+ if (!paths.length) return false
106
+ const built = builtAt ? Date.parse(builtAt) : NaN
107
+ if (!Number.isFinite(built)) return true
108
+ return paths.some((rel) => changedAfter(join(platformSource, rel), built))
109
+ }
110
+
111
+ /** A file changed after `at` (ms): its modification time, or, removed, its folder's. */
112
+ function changedAfter(path: string, at: number): boolean {
113
+ try {
114
+ return statSync(path).mtimeMs > at
115
+ } catch {
116
+ try {
117
+ return statSync(dirname(path)).mtimeMs > at
118
+ } catch {
119
+ return true
120
+ }
121
+ }
122
+ }
123
+
124
+ /** Where `local start` says how its rebuild of the checkout's documentation is going: building, or failed. */
125
+ export function docsBuildMarkerPath(project: LocalProject): string {
126
+ return join(project.stateDir, 'docs', 'rebuild.json')
127
+ }
128
+
129
+ export interface DocsBuildMarker {
130
+ state: 'building' | 'failed'
131
+ /** When the rebuild started, or failed. */
132
+ at: string
133
+ /** The process building it (the runtime's supervisor): a marker whose process is gone is not building. */
134
+ pid?: number
135
+ /** Why it was rebuilt. */
136
+ why?: string
137
+ }
138
+
139
+ export function readDocsBuildMarker(project: LocalProject): DocsBuildMarker | null {
140
+ try {
141
+ const m = JSON.parse(readFileSync(docsBuildMarkerPath(project), 'utf8')) as DocsBuildMarker
142
+ return m && (m.state === 'building' || m.state === 'failed') && typeof m.at === 'string' ? m : null
143
+ } catch {
144
+ return null
145
+ }
146
+ }
147
+
148
+ /** Say a rebuild is under way or failed; null clears it (it built). Never throws: the marker is only for what status says. */
149
+ export function writeDocsBuildMarker(project: LocalProject, marker: DocsBuildMarker | null): void {
150
+ const path = docsBuildMarkerPath(project)
151
+ try {
152
+ if (!marker) return rmSync(path, { force: true })
153
+ mkdirSync(dirname(path), { recursive: true })
154
+ writeFileSync(path, `${JSON.stringify(marker)}\n`)
155
+ } catch {
156
+ // Status falls back to what the copy says.
157
+ }
158
+ }
159
+
160
+ function processAlive(pid: number | undefined): boolean {
161
+ if (!pid) return false
162
+ try {
163
+ process.kill(pid, 0)
164
+ return true
165
+ } catch (err) {
166
+ return (err as NodeJS.ErrnoException).code === 'EPERM'
167
+ }
168
+ }
169
+
170
+ /**
171
+ * A current build in the checkout that is newer than the project's copy
172
+ * (someone ran `bun run docs:build` there after the runtime started): copy
173
+ * it in, as the next start would, so the copy and its status follow the
174
+ * checkout. Returns whether it copied.
175
+ */
176
+ export function refreshDocsFromCheckout(project: LocalProject, platformSource: string | undefined, changedSince: typeof guidesChangedSince = guidesChangedSince): boolean {
177
+ const bundle = customerDocsBundle(platformSource)
178
+ if (!bundle || !platformSource) return false
179
+ const builtAtOf = (path: string): number => {
180
+ try {
181
+ const at = (JSON.parse(readFileSync(path, 'utf8')) as { builtAt?: unknown }).builtAt
182
+ return typeof at === 'string' ? Date.parse(at) : NaN
183
+ } catch {
184
+ return NaN
185
+ }
186
+ }
187
+ const theirs = builtAtOf(bundle)
188
+ const ours = builtAtOf(docsCopyPath(project))
189
+ if (!Number.isFinite(theirs) || (Number.isFinite(ours) && theirs <= ours)) return false
190
+ if (checkoutDocsStale(platformSource, changedSince)) return false
191
+ return copyDocs({ project, bundle }) === null
192
+ }
193
+
194
+ /**
195
+ * The documentation copy against the runtime it was copied for. `local
196
+ * start` copies what the checkout last built and, when that build is
197
+ * behind the checkout, rebuilds it in the background and copies it again;
198
+ * this says plainly when the copy documents another version, or guides
199
+ * changed since, instead of serving it silently, and whether that rebuild
200
+ * is running or failed. A newer current build in the checkout is copied in
201
+ * first (`refreshDocsFromCheckout`). The fix it names fits the runtime: a
202
+ * platform checkout's build only in source mode. Versions compare like with
203
+ * like: the copy's against the version the running runtime recorded at start.
204
+ */
205
+ export function docsStatus(project: LocalProject, runtime: { mode: 'source' | 'image'; platformVersion: string; platformSource?: string }, changedSince: typeof guidesChangedSince = guidesChangedSince): DocsStatus {
206
+ // The runtime's version is the one it recorded when it started (`state.platformVersion`), as the Runtime page and
207
+ // `local status` say it: never the checkout's version now, which a pull can move while the runtime runs (run 8 said
208
+ // "the runtime is 1.52.33" beside a Runtime page saying 1.52.31).
209
+ const runtimeVersion = runtime.platformVersion
210
+ if (runtime.mode === 'source') refreshDocsFromCheckout(project, runtime.platformSource, changedSince)
211
+ const publication = loadDocs(docsCopyPath(project)) as (DocsPublication & { productVersion?: string; sourceCommit?: string; builtAt?: string }) | null
212
+ const marker = runtime.mode === 'source' ? readDocsBuildMarker(project) : null
213
+ const rebuilding = marker?.state === 'building' && processAlive(marker.pid)
214
+ const rebuildFailed = marker?.state === 'failed'
215
+ const restart = '`frontera local stop`, then `frontera local start`'
216
+ if (!publication) {
217
+ return {
218
+ present: false,
219
+ version: null,
220
+ runtimeVersion,
221
+ builtAt: null,
222
+ stale: false,
223
+ ...(rebuilding ? { rebuilding: true } : {}),
224
+ note: rebuilding
225
+ ? 'rebuilding — `frontera local start` is building it in the background; it is here when that is done'
226
+ : runtime.mode === 'source'
227
+ ? `none: build it in the platform checkout (\`bun run docs:build\`), then ${restart}`
228
+ : 'none: this release’s service image carries no documentation',
229
+ }
230
+ }
231
+ const version = typeof publication.productVersion === 'string' ? publication.productVersion : null
232
+ const builtAt = typeof publication.builtAt === 'string' ? publication.builtAt : null
233
+ const reasons: string[] = []
234
+ if (version && version !== runtimeVersion) reasons.push(`it documents ${version}, and the runtime is ${runtimeVersion}`)
235
+ if (runtime.mode === 'source' && runtime.platformSource && typeof publication.sourceCommit === 'string' && changedSince(runtime.platformSource, publication.sourceCommit, builtAt)) {
236
+ reasons.push(`the checkout’s guides changed since it was built (from ${publication.sourceCommit.slice(0, 9)})`)
237
+ }
238
+ const stale = reasons.length > 0
239
+ if (stale && rebuilding) {
240
+ return { present: true, version, runtimeVersion, builtAt, stale, rebuilding: true, note: `rebuilding — ${reasons.join('; ')}: \`frontera local start\` is building it in the background and refreshes the copy when it is done; the copy here serves until then` }
241
+ }
242
+ const fix =
243
+ runtime.mode === 'image'
244
+ ? `: ${restart} copies this release’s documentation`
245
+ : rebuildFailed
246
+ ? `: the rebuild at start failed (\`frontera local logs service\` shows why); run \`bun run docs:build\` in the platform checkout, then ${restart}`
247
+ : `: ${restart} rebuilds it in the background and refreshes the copy (see \`frontera local logs service\`), or run \`bun run docs:build\` in the platform checkout`
248
+ return { present: true, version, runtimeVersion, builtAt, stale, ...(stale && rebuildFailed ? { rebuildFailed: true } : {}), note: stale ? `out of date — ${reasons.join('; ')}${fix}` : null }
249
+ }
250
+
251
+ /**
252
+ * Why a platform checkout's customer publication is behind the checkout, or
253
+ * null when it is current (or that cannot be told): no build yet, guides
254
+ * changed since the commit it was built from, or another version.
255
+ */
256
+ export function checkoutDocsStale(platformSource: string, changedSince: typeof guidesChangedSince = guidesChangedSince): string | null {
257
+ const bundle = join(platformSource, 'packages', 'docs-content', 'dist', 'customer', 'publication.json')
258
+ if (!existsSync(bundle)) return 'the checkout has no documentation build'
259
+ let publication: { productVersion?: unknown; sourceCommit?: unknown; builtAt?: unknown } | null = null
260
+ try {
261
+ publication = JSON.parse(readFileSync(bundle, 'utf8')) as { productVersion?: unknown; sourceCommit?: unknown; builtAt?: unknown }
262
+ } catch {
263
+ return 'the checkout’s documentation build could not be read'
264
+ }
265
+ const version = readVersion(join(platformSource, 'package.json'))
266
+ if (typeof publication?.productVersion === 'string' && version && publication.productVersion !== version) return `the checkout’s documentation build documents ${publication.productVersion}, and the checkout is ${version}`
267
+ if (typeof publication?.sourceCommit === 'string' && changedSince(platformSource, publication.sourceCommit, typeof publication.builtAt === 'string' ? publication.builtAt : null)) return `the checkout’s guides changed since its documentation was built (from ${publication.sourceCommit.slice(0, 9)})`
268
+ return null
269
+ }
270
+
271
+ /** Marks a documentation build in a checkout, so two runtimes started from it don't build at once. */
272
+ const DOCS_BUILD_LOCK = ['packages', 'docs-content', 'dist', '.frontera-docs-build']
273
+
274
+ function lockHolder(path: string): number | null {
275
+ try {
276
+ const pid = Number(readFileSync(path, 'utf8').trim())
277
+ if (!Number.isInteger(pid) || pid <= 0) return null
278
+ process.kill(pid, 0)
279
+ return pid
280
+ } catch {
281
+ return null
282
+ }
283
+ }
284
+
285
+ /**
286
+ * Build the checkout's documentation at low priority (`nice -n 10 bun run
287
+ * docs:build`), logging to `logFile`; resolves whether it built. Resolves
288
+ * false at once when another build of this checkout is running.
289
+ */
290
+ export function buildDocs(platformSource: string, logFile: string): Promise<boolean> {
291
+ const lock = join(platformSource, ...DOCS_BUILD_LOCK)
292
+ if (lockHolder(lock)) {
293
+ appendFileSync(logFile, '[docs] another runtime is building this checkout’s documentation; the copy is refreshed at the next start\n')
294
+ return Promise.resolve(false)
295
+ }
296
+ mkdirSync(dirname(lock), { recursive: true })
297
+ return new Promise((done) => {
298
+ const fd = openSync(logFile, 'a')
299
+ const child = spawn('nice', ['-n', '10', 'bun', 'run', 'docs:build'], { cwd: platformSource, stdio: ['ignore', fd, fd] })
300
+ if (child.pid) writeFileSync(lock, String(child.pid))
301
+ const finish = (ok: boolean) => {
302
+ rmSync(lock, { force: true })
303
+ done(ok)
304
+ }
305
+ child.on('error', () => finish(false))
306
+ child.on('exit', (code) => finish(code === 0))
307
+ })
308
+ }
309
+
31
310
  /** Where the service image carries its customer publication. */
32
311
  export const IMAGE_DOCS_PATH = '/app/documentation/publication.json'
33
312
 
@@ -0,0 +1,92 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { allTargets } from './local-dev-sync'
7
+ import { readSnapshot } from './local-sync-snapshot'
8
+
9
+ /**
10
+ * Workflows with no steps. One applies (the runtime takes an empty workflow
11
+ * as written) and every surface would mark it Applied, but a run does
12
+ * nothing: a placeholder made in Studio and never filled in, or a pull of
13
+ * one. Said as a warning beside its state, with what fixes it, by
14
+ * `local status`, `local dev`, Forge's home and Activity alike.
15
+ */
16
+ export function emptyWorkflows(root: string): string[] {
17
+ const slugs = new Set<string>()
18
+ for (const target of allTargets(root)) {
19
+ if (target.kind !== 'workflow') continue
20
+ try {
21
+ const text = readFileSync(join(root, target.file), 'utf8')
22
+ const doc = (target.file.endsWith('.json') ? JSON.parse(text) : parseYaml(text)) as { nodes?: unknown; steps?: unknown } | null
23
+ if (!doc || typeof doc !== 'object') continue
24
+ const steps = doc.nodes ?? doc.steps
25
+ if (steps === undefined || steps === null || (Array.isArray(steps) && steps.length === 0)) slugs.add(target.slug)
26
+ } catch {
27
+ // A file that doesn't parse fails its apply, which says why.
28
+ }
29
+ }
30
+ return [...slugs].sort()
31
+ }
32
+
33
+ /**
34
+ * The workflows of the workspace this project was pulled from, as its pull
35
+ * index (`.frontera/sync/remote.json`) knows them at the last fetch: how
36
+ * many it has (archived ones aside) and which are not in the project. Null
37
+ * when there is no index, or it never read workflows: then nothing is said.
38
+ */
39
+ export interface WorkspaceWorkflows {
40
+ total: number
41
+ pulled: number
42
+ /** How many workflows the project holds now, pulled or made here: the count its home lists. */
43
+ here: number
44
+ /** Listed by the index without their file here, by slug; with no definition to file (`empty`) or not pulled. */
45
+ notHere: Array<{ slug: string; why: 'not-pulled' | 'no-definition' }>
46
+ fetchedAt: string
47
+ }
48
+
49
+ export function workspaceWorkflows(root: string): WorkspaceWorkflows | null {
50
+ const snap = readSnapshot(root)
51
+ if (!snap || !snap.kinds?.includes('workflow')) return null
52
+ const notHere: WorkspaceWorkflows['notHere'] = []
53
+ let pulled = 0
54
+ for (const [ref, item] of Object.entries(snap.items)) {
55
+ if (item.kind !== 'workflow') continue
56
+ if (item.files.length && item.files.every((f) => existsSync(join(root, f)))) pulled++
57
+ else notHere.push({ slug: ref.replace(/^workflow\//, ''), why: 'not-pulled' })
58
+ }
59
+ for (const ref of Object.keys(snap.skipped ?? {})) if (ref.startsWith('workflow/') && !snap.items[ref]) notHere.push({ slug: ref.slice('workflow/'.length), why: 'no-definition' })
60
+ const here = Math.max(pulled, new Set(allTargets(root).filter((t) => t.kind === 'workflow').map((t) => t.slug)).size)
61
+ return { total: pulled + notHere.length, pulled, here, notHere: notHere.sort((a, b) => a.slug.localeCompare(b.slug)), fetchedAt: snap.fetchedAt }
62
+ }
63
+
64
+ /**
65
+ * Where the project's workflows came from, counted from the project's side so
66
+ * the number matches the home's ("Workflows 2"): "1 of this project's 2
67
+ * workflows came from the workspace it was pulled from (at the last fetch,
68
+ * …); the other was made here, and the workspace has no other workflow to
69
+ * pull." Run 8 read "has 1 workflow …; 1 is in this project" as a wrong count
70
+ * beside a home listing 2.
71
+ */
72
+ export function workspaceWorkflowsWords(w: WorkspaceWorkflows): string {
73
+ const fetched = `(at the last fetch, ${w.fetchedAt.slice(0, 10)})`
74
+ const made = w.here - w.pulled
75
+ const from = 'came from the workspace it was pulled from'
76
+ const lead =
77
+ w.pulled === w.here
78
+ ? `${w.here === 1 ? 'This project’s workflow' : `All ${w.here} of this project’s workflows`} ${from} ${fetched}`
79
+ : `${w.pulled === 0 ? 'None' : w.pulled} of this project’s ${w.here} workflows ${from} ${fetched}; ${made === 1 ? 'the other was' : `the other ${made} were`} made here`
80
+ if (!w.notHere.length) return `${lead}, and the workspace has no other workflow to pull.`
81
+ const notPulled = w.notHere.filter((n) => n.why === 'not-pulled').map((n) => n.slug)
82
+ const empty = w.notHere.filter((n) => n.why === 'no-definition').map((n) => n.slug)
83
+ return `${lead}. ${[
84
+ notPulled.length ? `Not pulled from the workspace: ${notPulled.join(', ')} (\`frontera local sync pull ${notPulled.map((s) => `workflow/${s}`).join(' ')}\` brings ${notPulled.length === 1 ? 'it' : 'them'})` : '',
85
+ empty.length ? `${notPulled.length ? 'listed' : 'Listed'} there without a definition to file: ${empty.join(', ')}` : '',
86
+ ].filter(Boolean).join('; ')}.`
87
+ }
88
+
89
+ /** The warning for one, as a row's warning line: "workflow/x: … Fix: …", with the workspace's other workflows when the pull index knows them. */
90
+ export function emptyWorkflowLine(slug: string, workspace: WorkspaceWorkflows | null = null): string {
91
+ return `workflow/${slug}: has no steps, so it does nothing when it runs. Fix: add its steps (Studio's Workflows page, or nodes: in its file under workflows/), or delete the file if it is a placeholder${workspace ? `. ${workspaceWorkflowsWords(workspace)}` : ''}`
92
+ }