@frontera-sdk/cli 1.52.39 → 1.52.41

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 (235) 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 +49 -5
  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/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
package/src/exit.ts CHANGED
@@ -16,7 +16,12 @@ export const EXIT = {
16
16
  FAILURE: 1,
17
17
  /** Bad invocation or bad input. Fix the command. */
18
18
  USAGE: 2,
19
- /** Someone else changed it first. Re-fetch, reapply. */
19
+ /**
20
+ * Someone else changed it first, or the state of the record or the request
21
+ * refuses it (a governed Action's submission criteria, a request whose
22
+ * outcome is unknown). Re-read, then change the input or the record; the
23
+ * same request is refused again, so never retry it as it is.
24
+ */
20
25
  CONFLICT: 3,
21
26
  /** Credentials or permission. Neither retry nor rewording helps. */
22
27
  AUTH: 4,
@@ -58,8 +63,75 @@ const USAGE_CODES = new Set([
58
63
  // Authoring kit. Both are resolved by running a named command, not by retry.
59
64
  'KIT_VERSION_MISMATCH',
60
65
  'GENERATED_FILE_CONFLICT',
66
+ // The Idempotency-Key was used for a different request: a new key fixes it,
67
+ // re-reading does not. (An effect another request already made is a
68
+ // conflict: see CONFLICT_REASONS.)
69
+ 'ACTION_IDEMPOTENCY_CONFLICT',
70
+ // An Action that isn't there to submit, or a submit without the key it needs.
71
+ 'ACTION_NOT_FOUND',
72
+ 'ACTION_IDEMPOTENCY_KEY_REQUIRED',
61
73
  ])
62
- const CONFLICT_CODES = new Set(['DRAFT_CONFLICT', 'CONFLICT', 'VALIDATION_STALE'])
74
+ /**
75
+ * Someone changed it first: re-read, then decide or submit again. Each of
76
+ * these is fixed by re-reading the record or the request, which a retry of
77
+ * the same body never does.
78
+ */
79
+ const CONFLICT_CODES = new Set([
80
+ 'DRAFT_CONFLICT',
81
+ 'CONFLICT',
82
+ 'VALIDATION_STALE',
83
+ // The record moved past `expectedVersion`: read it again, submit with the new one.
84
+ 'ACTION_SOURCE_VERSION_STALE',
85
+ // The request changed under its approvals: read the request again.
86
+ 'ACTION_APPROVAL_STALE',
87
+ // The record already has an open request of this Action (`openRequests: one_per_subject`):
88
+ // read that request (`details.openRequest`) instead of submitting another.
89
+ 'ACTION_REQUEST_ALREADY_OPEN',
90
+ // Refused by the state of the record, the Action or the request: the same
91
+ // request is refused every time, so exit 1 ("retry") would send a caller
92
+ // round a loop. Each is fixed by re-reading and changing something first.
93
+ // The record or the input fails the Action's submission criteria.
94
+ 'ACTION_CRITERIA_FAILED',
95
+ // The Action is switched off, has no binding, its binding is revoked or
96
+ // suspended, or it has no deployment (`ACTION_NOT_DEPLOYED`, the CLI's word
97
+ // for that 409): nothing changes until someone turns it back on or deploys it.
98
+ 'ACTION_DISABLED',
99
+ 'ACTION_UNBOUND',
100
+ 'ACTION_BINDING_REVOKED',
101
+ 'ACTION_BINDING_SUSPENDED',
102
+ 'ACTION_NOT_DEPLOYED',
103
+ // It needs an approval first, or affects more records than the Action allows.
104
+ 'ACTION_APPROVAL_REQUIRED',
105
+ 'ACTION_EFFECT_LIMIT_EXCEEDED',
106
+ // The outcome is settled or unknown: read the request (`action requests`),
107
+ // never submit again — a second submit could make the change twice.
108
+ 'ACTION_CONFIRMED_NOT_APPLIED',
109
+ 'ACTION_OUTCOME_UNKNOWN',
110
+ 'ACTION_RESOLUTION_REQUIRED',
111
+ 'AUTOMATION_PUBLISH_UNCERTAIN',
112
+ // The dataset's rows are written somewhere else now.
113
+ 'DATASET_AUTHORITY_MOVED',
114
+ ])
115
+ /**
116
+ * Refusals the service sends under another code with a `reason` that is a
117
+ * conflict: the approval step moved on, or another request already made the
118
+ * effect. They exit 3 whatever their code.
119
+ */
120
+ const CONFLICT_REASONS = new Set([
121
+ 'ACTION_APPROVAL_STEP_CHANGED',
122
+ 'ACTION_LOGICAL_EFFECT_CONFLICT',
123
+ 'ACTION_SOURCE_VERSION_STALE',
124
+ 'ACTION_REQUEST_ALREADY_OPEN',
125
+ ])
126
+
127
+ /** A refusal's `reason`, at the top of its details or nested one level under `details`. */
128
+ export function reasonOf(details: unknown): string | undefined {
129
+ if (typeof details !== 'object' || details === null) return undefined
130
+ const body = details as { reason?: unknown; details?: unknown }
131
+ if (typeof body.reason === 'string') return body.reason
132
+ const nested = body.details
133
+ return typeof nested === 'object' && nested !== null && typeof (nested as { reason?: unknown }).reason === 'string' ? (nested as { reason: string }).reason : undefined
134
+ }
63
135
  const AUTH_CODES = new Set([
64
136
  'UNAUTHORIZED',
65
137
  'FORBIDDEN',
@@ -78,7 +150,9 @@ const AUTH_CODES = new Set([
78
150
  * an SDK release — which means this function must treat "never seen it" as
79
151
  * ordinary, not exceptional.
80
152
  */
81
- export function exitCodeFor(code: string): ExitCode {
153
+ export function exitCodeFor(code: string, details?: unknown): ExitCode {
154
+ const reason = reasonOf(details)
155
+ if (reason && CONFLICT_REASONS.has(reason)) return EXIT.CONFLICT
82
156
  if (USAGE_CODES.has(code)) return EXIT.USAGE
83
157
  if (CONFLICT_CODES.has(code)) return EXIT.CONFLICT
84
158
  if (AUTH_CODES.has(code)) return EXIT.AUTH
@@ -98,6 +172,10 @@ const SERVICE_HINTS: Record<string, string> = {
98
172
  // which case they are in throw a CliError with a specific hint instead.
99
173
  CONFLICT: 'the message above names what conflicted — resolve that, then retry',
100
174
  VALIDATION_STALE: 're-fetch the resource and re-run validation against the current revision',
175
+ ACTION_SOURCE_VERSION_STALE: 'the record changed first — read it again (`frontera blueprint query`), then submit with its current version as expectedVersion',
176
+ ACTION_APPROVAL_STALE: 'the request changed under its approvals — read it again (`frontera action requests`), then decide',
177
+ ACTION_IDEMPOTENCY_CONFLICT: 'that Idempotency-Key belongs to another request — submit with a new key',
178
+ ACTION_REQUEST_ALREADY_OPEN: 'this record already has an open request of this Action — follow it (`frontera action requests <requestId>`, the id in the details) instead of submitting another',
101
179
  // Names neither kind. Both are valid here — a workspace key for agents,
102
180
  // skills, plugins and knowledge; an organization key for Blueprint authoring,
103
181
  // datasets and Sources — and naming only one sent holders of the other
@@ -223,6 +301,19 @@ function hintFor(code: string, details: unknown): string | undefined {
223
301
  return SERVICE_HINTS[code]
224
302
  }
225
303
 
304
+ /**
305
+ * A refusal that names what it refused only in its details (`invalid_plugins`
306
+ * with `{ invalid: ['decision'] }`), said with the names: the message alone
307
+ * leaves nobody able to tell which entry to take out.
308
+ */
309
+ export function withRefusedNames(message: string, details: unknown): string {
310
+ if (!/^invalid_[a-z_]+$/.test(message) || typeof details !== 'object' || details === null) return message
311
+ const body = details as { invalid?: unknown; details?: { invalid?: unknown } }
312
+ const invalid = Array.isArray(body.invalid) ? body.invalid : Array.isArray(body.details?.invalid) ? body.details.invalid : null
313
+ const names = invalid?.filter((n): n is string => typeof n === 'string') ?? []
314
+ return names.length ? `${message}: ${names.join(', ')}` : message
315
+ }
316
+
226
317
  function isFronteraError(
227
318
  err: unknown,
228
319
  ): err is { code: string; message: string; details?: unknown } {
@@ -246,8 +337,16 @@ export function toEnvelope(err: unknown): ErrorEnvelope {
246
337
  }
247
338
 
248
339
  if (isFronteraError(err)) {
340
+ // Invoking an Action that has no deployment, through any client: said as that.
341
+ const details = err.details as { details?: { reason?: unknown; readinessReason?: unknown; deploymentStatus?: unknown } ; reason?: unknown; readinessReason?: unknown; deploymentStatus?: unknown } | undefined
342
+ const nd = details?.reason === 'ACTION_NOT_DEPLOYED' ? details : details?.details?.reason === 'ACTION_NOT_DEPLOYED' ? details.details : null
343
+ if (nd) {
344
+ const why = typeof nd.readinessReason === 'string' ? nd.readinessReason : typeof nd.deploymentStatus === 'string' ? `its deployment is ${nd.deploymentStatus}` : 'it has no active deployment'
345
+ return { error: true, code: 'ACTION_NOT_DEPLOYED', message: `${err.message.replace(/[.]$/, '')}: the Action isn't deployed: ${why}`, hint: '`frontera action list` shows why each Action cannot run; `frontera action deploy <name>` builds its write path' }
346
+ }
249
347
  const hint = hintFor(err.code, err.details)
250
- return { error: true, code: err.code, message: err.message, ...(hint ? { hint } : {}) }
348
+ const reason = reasonOf(err.details)
349
+ return { error: true, code: err.code, message: withRefusedNames(err.message, err.details), ...(hint ? { hint } : {}), ...(reason ? { details: { reason } } : {}) }
251
350
  }
252
351
 
253
352
  return {
package/src/flag-help.ts CHANGED
@@ -40,7 +40,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
40
40
  remove: 'comma-separated property API names to drop from the current set',
41
41
  // Blueprint authoring (organization API key). `notes` is described further down
42
42
  // — this map is keyed by flag name across every command, so one entry serves both.
43
- label: 'release label recorded on the published or rolled-back release',
43
+ label: 'release label recorded on the published or rolled-back release; for an App deployment, unique per App',
44
44
  report: 'validation report id to act on — the release publishes against it, the review is pinned to it',
45
45
  instruction: 'migration instruction applied to every change a rollback discards',
46
46
  // Global
@@ -71,7 +71,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
71
71
 
72
72
  // Writes
73
73
  file: 'read the document from this path, or `-` for stdin',
74
- force: 'proceed past a refusal — overwrite existing files, or a dirty tree',
74
+ force: 'proceed past a refusal — overwrite existing files, a dirty tree, an App\'s or a change request\'s required checks (with --reason), a running local runtime',
75
75
  'expect-revision':
76
76
  'refuse the write unless the resource is still at this revision; exit 3 if it moved',
77
77
  notes: 'note recorded against the published version',
@@ -91,6 +91,19 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
91
91
  registry: 'the registry host and namespace image mode pulls the platform images from, such as registry.example.com/frontera',
92
92
  reapply: 'apply every file again, including those unchanged since they were last applied',
93
93
  stop: 'stop the local runtime when the check ends',
94
+ 'keep-state': 'keep what the Agent and workflow tests wrote, instead of restoring the database as the apply left it',
95
+ exact: 'restore: put the baseline’s workspace secrets back too, instead of keeping the ones set now',
96
+ workflow: 'run only this workflow’s tests (workflows/tests/<slug>.yaml)',
97
+ skip: 'comma-separated check steps to leave out: typecheck, unit, apps, agents, workflows',
98
+ 'object-type': 'the object type a new action acts on; its primary key names the key input',
99
+ body: 'the request body to send: a file, or - for standard input',
100
+ path: 'which webhook of the workflow, by its trigger path; needed when it has several',
101
+ method: 'the HTTP method to send; POST when the webhook takes it',
102
+ header: 'one header the sender would send, `Name: value` (the webhook’s own auth)',
103
+ query: 'a query string to send with the delivery, such as source=portal&urgent=1',
104
+ 'fail-fast': 'stop the check at the first step that fails, instead of running and reporting every step',
105
+ strict: 'fail on inherited failures too: items and files unchanged since the pull, which otherwise are listed and do not fail the check',
106
+ concurrency: 'how many Agent test cases run at once (1–16, default 2); cases that change records for real still run one at a time. Lower it when the model provider refuses for requests in flight',
94
107
 
95
108
  // Apps
96
109
  framework: 'App scaffold to create: next (default) or react (legacy Vite)',
@@ -104,7 +117,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
104
117
  // choose which version to RUN, one chooses which to publish. The old text
105
118
  // described only the third, so `workflow run --help` and `function run
106
119
  // --help` both advertised package.json.
107
- version: 'the version to act on — the one to run, or on a deploy the one to publish',
120
+ version: 'the version to act on — the one to run (`workflow run` also takes draft: the newest version not retired), or on a deploy the one to publish',
108
121
  input: "JSON object of the declared inputs — inline ('{\"key\": 1}') or @file.json",
109
122
  // The primary key alone. A subject-bound workflow takes exactly one, so the
110
123
  // flag spends no characters on the `{"pk": …}` the route models it as.
@@ -112,8 +125,11 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
112
125
  // Action requests.
113
126
  'subject-version': 'record version of --subject the request expects, so a stale read is refused',
114
127
  'idempotency-key': 'reuse a key to retry a submit and get the same request back; generated if omitted',
115
- 'no-promote': 'publish the version without moving the live pointer',
116
- 'skip-build-check': 'deploy even when the build output is older than the source',
128
+ 'no-promote': 'publish the version without moving the live pointer; with app deploy --prod, stage it for production',
129
+ build: 'run bun run build first, so the deployment is built from the source as it is now',
130
+ 'skip-build-check': 'deploy even when the build output is older than the source, and skip bun run check',
131
+ prod: 'go live once the required checks pass (a deploy is otherwise a preview)',
132
+ undo: 'undo the rollback: production serves what it did before, and is unpinned',
117
133
  'no-wait': 'return as soon as the run is queued instead of waiting for it to finish',
118
134
  // Named for what it RUNS, not where it runs: an author reads this to decide
119
135
  // between their working copy and what is deployed.
@@ -129,8 +145,8 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
129
145
 
130
146
  // Blueprint scaffolding
131
147
  template:
132
- 'starting rule for `blueprint new semantic-type`: non-negative, percentage, status-enum '
133
- + 'or code-length (default)',
148
+ 'starting shape: a semantic type\'s rule (non-negative, percentage, status-enum, '
149
+ + 'code-length, the default), or an action\'s (edit, the default, or create)',
134
150
 
135
151
  // Packs
136
152
  'remove-apps': 'also uninstall the apps the pack installed',
@@ -146,8 +162,9 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
146
162
  // Blueprint reads
147
163
  select: 'comma-separated property API names to return (default: whatever the service returns)',
148
164
  where:
149
- 'filter as <property>:<value>, <property>:<op>:<value>, or a JSON where-node for anything '
150
- + 'with and/or in it',
165
+ 'filter as <property>:<value>, <property>:<op>:<value>, <property>:in:<a>,<b> (also notIn and between; '
166
+ + 'a backslash before a comma keeps it in a value, or give a JSON array), or a JSON where-node for an '
167
+ + 'or/not; repeat it for conditions that must all hold',
151
168
  order: 'sort as <property>:asc or <property>:desc, comma-separated for more than one',
152
169
  // No number here on purpose. This map is keyed by flag NAME across every
153
170
  // command, and the three surfaces that take `--limit` cap a page at 1000,
@@ -155,6 +172,22 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
155
172
  // command's own refusal names its real ceiling.
156
173
  limit: 'maximum rows to return in one page; each surface has its own ceiling',
157
174
  'page-token': 'cursor from the previous response’s nextPageToken; omit for the first page',
175
+ as:
176
+ 'on the local runtime only: act as a local persona (`submitter`, or an approval step such as '
177
+ + '`dispatch_lead`), as `frontera local status` lists them; refused anywhere else',
178
+ // A metric's query (`blueprint metric query`).
179
+ measure: 'comma-separated measures of the metric to return; all of them when absent',
180
+ 'group-by': 'comma-separated dimensions of the metric to break the numbers down by',
181
+ grain: 'a series by hour, day, week, month, quarter or year over the metric’s timeseries dimension (added to the group-by)',
182
+ range: 'a time window as an ISO-8601 duration (P7D, PT12H, P3M), back from the newest value of the metric’s timeseries in the data, not from now',
183
+ start: 'the window’s start, an ISO date or timestamp (inclusive; a date is its midnight UTC)',
184
+ end: 'the window’s end, an ISO date or timestamp (exclusive; a date is its midnight UTC)',
185
+ compare: 'add the window before the one given: <measure>_prev, <measure>_delta and <measure>_pct',
186
+ only: 'live: only live runs; test: only test runs (from `local test`, Studio or a draft run)',
187
+ npm: 'with use-local: switch the App back to the published @frontera-sdk packages from npm, deleting vendor/frontera-sdk/ and any SDK copy under src/frontera/ (the output lists each file)',
188
+ 'object-set':
189
+ 'read a saved object set’s rows: its name, its reference (objectSet/<ref>) or its id, '
190
+ + 'from `frontera blueprint object-sets`',
158
191
  // Named for the field the response carries, which differs by surface —
159
192
  // Blueprint answers `nextPageToken`, data-integration answers `nextCursor`.
160
193
  // Matching each flag to its own response beats one name that is wrong half
@@ -184,8 +217,9 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
184
217
  // permissive and 0 matches nothing at all. Described the wrong way round
185
218
  // first, which made `--threshold 0` look like "return everything".
186
219
  threshold:
187
- 'maximum cosine distance a chunk may have; higher is more permissive (default 0.9). '
188
- + 'Not the similarity score shown in the output — 0 matches nothing',
220
+ 'maximum cosine distance a chunk may have, 0 to 2; higher is more permissive (default: the '
221
+ + 'base’s own match threshold, as an Agent’s retrieval uses it). Not the score shown in the '
222
+ + 'output: a score is 1 − distance / 2, so 0.5 keeps scores above 0.75',
189
223
  strategy:
190
224
  'text extraction to use per file: auto (default), text, or ocr; ocr needs an OCR engine on the base',
191
225
 
@@ -214,7 +248,7 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
214
248
  'org-key': '`-` to read an organization key (sk-org-…) from stdin, after the workspace key; widens the Blueprint to the whole model',
215
249
  blueprint: 'workspace (the slice granted to the workspace, default) or organization (the whole model; needs an organization key)',
216
250
  relink: 'point a project already linked to another workspace at this one',
217
- preview: 'list what a pull would bring, or a push would propose, file by file, and change nothing',
251
+ preview: 'local sync: list what a pull would bring, or a push would propose, file by file, and change nothing; app deploy: make a preview deployment (the default), said explicitly as a pipeline must',
218
252
  kinds: 'comma-separated: blueprint, skills, functions, plugins, knowledge, workflows, agents, evaluations',
219
253
  drafts: 'comma-separated items (agent/<slug>, workflow/<slug>, function/<slug>, blueprint) to read as their draft instead of the live version; the Blueprint’s draft takes an organization key',
220
254
  'all-changed': 'pull every item the workspace changed since the last pull, and every clean merge; never one that conflicts or replaces local edits',
@@ -230,14 +264,25 @@ export const FLAG_HELP: Readonly<Record<string, string>> = {
230
264
  edited: 'take the files as you edited them; the workspace’s copies beside them go',
231
265
  title: 'the change request’s title; a push that opens a new one needs it',
232
266
  migration: 'for a Blueprint change that is not additive: what happens to existing records',
233
- documents: 'comma-separated knowledge bases whose documents a pull brings, or a push proposes (`*` for every one); without it they stay as they are',
267
+ documents: 'comma-separated knowledge bases whose documents a pull brings (a base pulled now brings its own anyway), or a push proposes (`*` for every one)',
268
+ 'no-documents': 'pull knowledge bases without their documents (by default a pulled base brings them)',
269
+ 'no-app-code': 'pull Custom Apps without their code (by default a pulled App brings its deployed version into apps/<slug>/, unless that folder is there)',
270
+ release: 'Custom Apps the change request releases, comma-separated slug=deployment (the newest deployment when only the slug is given); Apply promotes each one last',
271
+ evals: 'run each Agent’s gated cases (gate: true) against the local runtime and attach the results, compared with its last applied run',
272
+ renders: 'attach each released App’s last render (its screenshot and console summary)',
273
+ screenshots: 'attach the newest screenshots a Forge session saved (its id; this session’s when run inside Forge)',
274
+ trace: 'attach a trace link (an http(s) address) to the change request',
234
275
  lessons: 'bring each Agent’s reviewed rules from customer feedback into agents/<slug>/lessons.yaml (never the feedback itself); off by default',
235
276
  conflicts: 'the conflicts the picks were chosen against, as `diff --json` names them; refused if they moved',
236
277
  'from-workspace': 'start the project from a platform workspace instead of the examples',
237
278
  'no-sample-data': 'do not generate sample rows for the pulled Blueprint',
279
+ 'keep-sample-data': 'leave the sample datasets as they are; by default they are made again (same seed, rows and day) after a pull that brings Blueprint items, or when an older generator made them',
280
+ 'remake-sample-data': 'make the sample datasets again with the newest sample rules (same seed, rows and day), even the ones a pull wrote, which local dev otherwise keeps as pulled',
238
281
  synthetic: 'generate sample rows for the Blueprint: values fit each property, links stay consistent',
239
282
  rows: 'rows per object type (default 200; types others link to get half)',
240
- seed: 'the same seed writes the same rows (default 7)',
283
+ seed: 'the same seed writes the same rows on the same day; with --today, on any day (default 7)',
284
+ today: 'the day sample due dates are spread around, YYYY-MM-DD (default: today, UTC)',
285
+ region: 'where sample geopoints fall: a country code (ID, US, GB…) or `<lat>,<lon>[,<radius km>]` (default: the project’s `locale` in frontera.yaml, else the workspace’s timezone, else US)',
241
286
  }
242
287
 
243
288
  /**
@@ -259,6 +304,8 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
259
304
  // take an integer version number, and this map is keyed by flag name for all
260
305
  // three. `<version>` is the only placeholder true of every consumer.
261
306
  version: 'version',
307
+ body: 'file',
308
+ header: 'name: value',
262
309
  framework: 'next|react',
263
310
  port: 'number',
264
311
  origin: 'url',
@@ -279,6 +326,15 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
279
326
  order: 'prop:asc',
280
327
  limit: 'n',
281
328
  'page-token': 'cursor',
329
+ 'object-set': 'name|ref|id',
330
+ measure: 'a,b',
331
+ 'group-by': 'a,b',
332
+ grain: 'hour|day|week|month|quarter|year',
333
+ range: 'P7D',
334
+ start: 'iso',
335
+ end: 'iso',
336
+ only: 'live|test',
337
+ as: 'persona',
282
338
  cursor: 'cursor',
283
339
  lifecycle: 'state',
284
340
  actions: 'allow_all|read_only|custom',
@@ -288,7 +344,7 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
288
344
  markings: 'a,b',
289
345
  'max-distance': '0-2',
290
346
  permissions: 'read,write',
291
- template: 'non-negative|percentage|status-enum|code-length',
347
+ template: 'non-negative|percentage|status-enum|code-length|edit|create',
292
348
  platform: 'url',
293
349
  'workspace-key': '-',
294
350
  'org-key': '-',
@@ -299,6 +355,9 @@ const PLACEHOLDER: Readonly<Record<string, string>> = {
299
355
  'delete-here': 'kind/name,…',
300
356
  'keep-local': 'kind/name,…',
301
357
  documents: 'name,…',
358
+ release: 'slug=deployment,…',
359
+ screenshots: 'session',
360
+ trace: 'url',
302
361
  keep: 'workspace|mine',
303
362
  picks: 'n=side,…',
304
363
  rows: 'n',
@@ -315,6 +374,7 @@ export function flagUsage(name: string, type: 'string' | 'boolean', alias?: stri
315
374
  return `${short}--${name} <${PLACEHOLDER[name] ?? 'value'}>`
316
375
  }
317
376
 
318
- export function describeFlag(name: string): string {
319
- return FLAG_HELP[name] ?? ''
377
+ /** A command's own wording for a flag (`meta.flagHelp`) wins: the one place a name means something else there. */
378
+ export function describeFlag(name: string, own?: Readonly<Record<string, string>>): string {
379
+ return own?.[name] ?? FLAG_HELP[name] ?? ''
320
380
  }
@@ -59,12 +59,17 @@ async function probe(cwd: string, claudePath: string, log?: Logger): Promise<For
59
59
  const session = query({
60
60
  // Never yields: the initialization response is all this call is for, and
61
61
  // a prompt that resolved would send a turn to the model.
62
- prompt: (async function* () {
63
- await new Promise<void>((resolve) => {
64
- if (abort.signal.aborted) resolve()
65
- else abort.signal.addEventListener('abort', () => resolve(), { once: true })
66
- })
67
- })(),
62
+ prompt: {
63
+ [Symbol.asyncIterator]() {
64
+ return {
65
+ next: () => new Promise<IteratorResult<never>>((resolve) => {
66
+ const done = () => resolve({ done: true, value: undefined })
67
+ if (abort.signal.aborted) done()
68
+ else abort.signal.addEventListener('abort', done, { once: true })
69
+ }),
70
+ }
71
+ },
72
+ },
68
73
  options: {
69
74
  cwd,
70
75
  pathToClaudeCodeExecutable: claudePath,
@@ -58,7 +58,7 @@ function readSkillDir(dir: string): Array<{ dirName: string; path: string; conte
58
58
  return []
59
59
  }
60
60
  const out: Array<{ dirName: string; path: string; contents: string }> = []
61
- for (const entry of [...entries].sort()) {
61
+ for (const entry of [...entries].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
62
62
  const path = join(dir, entry, 'SKILL.md')
63
63
  try {
64
64
  out.push({ dirName: entry, path, contents: readFileSync(path, 'utf8') })
@@ -92,14 +92,14 @@ function pluginSkillRoots(configDir: string): Array<{ plugin: string; dir: strin
92
92
  } catch {
93
93
  return []
94
94
  }
95
- for (const marketplace of marketplaces.sort()) {
95
+ for (const marketplace of marketplaces.sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
96
96
  let plugins: string[]
97
97
  try {
98
98
  plugins = readdirSync(join(cache, marketplace))
99
99
  } catch {
100
100
  continue
101
101
  }
102
- for (const plugin of plugins.sort()) {
102
+ for (const plugin of plugins.sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))) {
103
103
  let versions: string[]
104
104
  try {
105
105
  versions = readdirSync(join(cache, marketplace, plugin))
@@ -107,7 +107,7 @@ function pluginSkillRoots(configDir: string): Array<{ plugin: string; dir: strin
107
107
  continue
108
108
  }
109
109
  // Newest version wins when a cache holds more than one.
110
- for (const version of versions.sort().reverse()) {
110
+ for (const version of versions.sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).reverse()) {
111
111
  const dir = join(cache, marketplace, plugin, version, 'skills')
112
112
  try {
113
113
  if (statSync(dir).isDirectory()) {
@@ -44,29 +44,31 @@ export const FORGE_ARTIFACT_KINDS = [
44
44
  * `search`, `runs`, `bindings` — is a read and is deliberately absent.
45
45
  */
46
46
  export const TOUCHING_VERBS: Record<ForgeArtifactKind, readonly string[]> = {
47
- agent: ['apply', 'create', 'publish', 'revert', 'discard', 'skill-attach', 'skill-detach'],
47
+ agent: ['apply', 'create', 'publish', 'revert', 'discard', 'skill-attach', 'skill-detach', 'archive', 'restore'],
48
48
  automation: ['deploy', 'promote', 'enable', 'disable', 'init', 'bind', 'unbind', 'run'],
49
- app: ['deploy', 'promote', 'save', 'add'],
49
+ app: ['deploy', 'promote', 'rollback', 'unpin', 'save', 'add'],
50
50
  blueprint: ['apply', 'publish', 'create', 'update', 'delete', 'rename', 'rollback', 'bind', 'grant', 'adopt', 'editable'],
51
51
  action: ['prepare', 'deploy', 'grant', 'review'],
52
52
  skill: ['push', 'delete'],
53
53
  source: ['create', 'update', 'revise', 'disable'],
54
- dataset: ['create', 'upload', 'test-source'],
54
+ // `revise` cuts the dataset's next revision from its CSV.
55
+ dataset: ['create', 'upload', 'revise', 'test-source'],
55
56
  knowledge: ['create', 'attach', 'detach', 'upload', 'retry', 'delete'],
57
+ // `webhook-test` starts a run on the draft through its webhook, as `run` does.
56
58
  // `get`, `lineage`, `list`, `runs` and `validate` are reads — `validate` is
57
59
  // `push --dry-run` and writes nothing. `cancel` is deliberately absent: it
58
60
  // stops one in-flight run and leaves the definition, the versions and the
59
61
  // live pointer exactly as they were, and its positional is a RUN id, not a
60
62
  // slug, so it could not name the workflow page anyway. Same call
61
63
  // `automation` makes, which lists `run` and not `cancel`.
62
- workflow: ['push', 'promote', 'run'],
64
+ workflow: ['push', 'promote', 'run', 'webhook-test', 'archive', 'restore'],
63
65
  }
64
66
 
65
67
  /** Verbs after which the artifact is LIVE, not merely changed. */
66
68
  const LIVE_VERBS: Partial<Record<ForgeArtifactKind, readonly string[]>> = {
67
69
  agent: ['publish'],
68
70
  automation: ['promote', 'enable'],
69
- app: ['promote'],
71
+ app: ['promote', 'rollback'],
70
72
  blueprint: ['publish'],
71
73
  workflow: ['promote'],
72
74
  }
@@ -97,9 +99,9 @@ const FILE_POSITIONAL_VERBS: Partial<Record<ForgeArtifactKind, readonly string[]
97
99
  automation: ['deploy', 'init'],
98
100
  skill: ['push'],
99
101
  blueprint: ['create'],
100
- // `app deploy` names nothing (the folder is the app); `app promote` names
101
- // a VERSION. The app itself is the project's, which the pane knows.
102
- app: ['deploy', 'promote', 'save', 'add'],
102
+ // `app deploy` names nothing (the folder is the app); `app promote` and
103
+ // `app rollback` name a DEPLOYMENT. The app itself is the project's, which the pane knows.
104
+ app: ['deploy', 'promote', 'rollback', 'unpin', 'save', 'add'],
103
105
  }
104
106
 
105
107
  /**
@@ -122,7 +124,7 @@ export const BOOLEAN_FLAGS = new Set([
122
124
  'accept-contract-change', 'accept-orphaned-knowledge', 'dry-run', 'end-user', 'no-activate',
123
125
  'no-components', 'no-git', 'no-input', 'no-install', 'no-open', 'no-promote', 'no-wait',
124
126
  'remove-apps', 'token-stdin', 'prune', 'dev', 'revoke', 'draft', 'check',
125
- 'skip-build-check',
127
+ 'skip-build-check', 'prod', 'preview', 'undo', 'build',
126
128
  ])
127
129
 
128
130
  /**
@@ -401,9 +403,10 @@ export function parseFronteraCommands(line: string): ParsedFronteraCommand[] {
401
403
  : (rest[0] ?? null)
402
404
  const live =
403
405
  (LIVE_VERBS[kind] ?? []).includes(verb)
404
- // `app deploy` and `function deploy` build and promote in one act
405
- // unless told not to.
406
- || ((kind === 'app' || kind === 'automation') && verb === 'deploy' && !flags.has('no-promote'))
406
+ // `function deploy` builds and promotes in one act unless told not to;
407
+ // `app deploy` makes a preview unless `--prod` (and `--prod --no-promote` stages it).
408
+ || (kind === 'automation' && verb === 'deploy' && !flags.has('no-promote'))
409
+ || (kind === 'app' && verb === 'deploy' && flags.has('prod') && !flags.has('no-promote'))
407
410
  out.push({ kind, verb, ref, live })
408
411
  }
409
412
  return out
@@ -0,0 +1,89 @@
1
+ import { existsSync, lstatSync, readFileSync } from 'node:fs'
2
+ import { dirname, join, posix, relative, resolve, sep } from 'node:path'
3
+
4
+ import { isExcluded, type SourceFile } from './packaging'
5
+
6
+ /**
7
+ * The files a Function imports from outside its own project folder.
8
+ *
9
+ * A Function deployed from its folder (`functions/<slug>/`) may import a
10
+ * helper beside it (`../../shared/eligibility` from `src/index.ts`). The
11
+ * bundle holds that code, but its source archive is the folder alone, so a
12
+ * pull would bring a Function that no longer builds. Each such file travels
13
+ * in the archive under `frontera-imports/`, by its path from the folder with
14
+ * every `..` as `__up__` (`frontera-imports/__up__/shared/eligibility.ts`),
15
+ * and a pull writes it back where the import finds it.
16
+ *
17
+ * Only files the code imports, by relative path, and never above the
18
+ * solution (the nearest folder with `frontera.yaml`, else two folders above
19
+ * the Function's): the bundle already carries their code.
20
+ */
21
+
22
+ export const OUTSIDE_DIR = 'frontera-imports'
23
+ const UP = '__up__'
24
+
25
+ const RELATIVE_IMPORT = /(?:from|import)\s*\(?\s*['"](\.{1,2}\/[^'"]+)['"]/g
26
+ const CODE = /\.(ts|tsx|mts|cts|js|mjs|cjs|json)$/
27
+ const EXTENSIONS = ['', '.ts', '.tsx', '.mts', '.js', '.mjs', '.json', '/index.ts', '/index.js']
28
+
29
+ /** `../shared/x.ts` (from the folder) → `frontera-imports/__up__/shared/x.ts`. */
30
+ export function outsidePath(fromFolder: string): string {
31
+ return `${OUTSIDE_DIR}/${posix.normalize(fromFolder).split('/').map((s) => (s === '..' ? UP : s)).join('/')}`
32
+ }
33
+
34
+ /** `frontera-imports/__up__/shared/x.ts` → `../shared/x.ts`; null for any other archive path. */
35
+ export function fromOutsidePath(archivePath: string): string | null {
36
+ if (!archivePath.startsWith(`${OUTSIDE_DIR}/`)) return null
37
+ const rel = archivePath.slice(OUTSIDE_DIR.length + 1).split('/').map((s) => (s === UP ? '..' : s)).join('/')
38
+ return rel.startsWith('../') ? rel : null
39
+ }
40
+
41
+ function solutionRoot(projectRoot: string): string {
42
+ for (let dir = projectRoot; ; dir = dirname(dir)) {
43
+ if (existsSync(join(dir, 'frontera.yaml'))) return dir
44
+ if (dirname(dir) === dir) break
45
+ }
46
+ return dirname(dirname(projectRoot))
47
+ }
48
+
49
+ const within = (root: string, path: string) => path === root || path.startsWith(`${root}${sep}`)
50
+
51
+ /**
52
+ * The files outside `projectRoot` that `entry` imports, followed through the
53
+ * files it imports inside it too, as archive entries under `frontera-imports/`.
54
+ */
55
+ export function outsideImports(projectRoot: string, entry: string): SourceFile[] {
56
+ const root = resolve(projectRoot)
57
+ const bound = resolve(solutionRoot(root))
58
+ const seen = new Set<string>()
59
+ const out: SourceFile[] = []
60
+ const read = (path: string): string | null => {
61
+ try {
62
+ return existsSync(path) && lstatSync(path).isFile() ? readFileSync(path, 'utf8') : null
63
+ } catch {
64
+ return null
65
+ }
66
+ }
67
+ const visit = (file: string) => {
68
+ if (seen.has(file)) return
69
+ const text = read(file)
70
+ if (text === null) return
71
+ seen.add(file)
72
+ if (!within(root, file)) {
73
+ const rel = relative(root, file).split(sep).join('/')
74
+ if (CODE.test(file) && !isExcluded(rel.replace(/^(\.\.\/)+/, ''))) out.push({ rel: outsidePath(rel), bytes: new TextEncoder().encode(text) })
75
+ }
76
+ for (const m of text.matchAll(RELATIVE_IMPORT)) {
77
+ const base = resolve(dirname(file), m[1]!)
78
+ if (!within(bound, base) || base.split(sep).includes('node_modules')) continue
79
+ const found = EXTENSIONS.map((ext) => `${base}${ext}`).find((c) => read(c) !== null)
80
+ if (!found) continue
81
+ visit(found)
82
+ // A JavaScript file's types beside it travel with it (a module a pull recovered from a
83
+ // deployed bundle is a `.js` typed by its `.d.ts`), so the next pull still typechecks.
84
+ if (found.endsWith('.js')) visit(`${found.slice(0, -'.js'.length)}.d.ts`)
85
+ }
86
+ }
87
+ visit(resolve(entry))
88
+ return out
89
+ }