@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
@@ -1,20 +1,29 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
1
4
  import { AppsApi } from '../../api/apps-api'
2
5
  import { PlatformApi } from '../../api/platform-api'
3
- import { createDevSessionBroker } from '../../dev-broker'
6
+ import { createDevSessionBroker, freeLoopbackPort, isLoopbackPortFree } from '../../dev-broker'
4
7
  import { CliError, UsageError } from '../../errors'
8
+ import { DEV_OWNER_ENV, enclosingSolution, forgetDevServer, registerDevServer, watchOwner } from '../../mcp/mcp-apps'
9
+ import { ensureAppPackages } from '../../project-bootstrap'
5
10
  import { detectAppFramework } from '../../provenance'
6
11
  import { writeAppId } from '../../project'
12
+ import { mintDevSession, writeDevSessionState } from '../../local/local-app-dev-session'
13
+ import { keepDevWrittenFiles, leftLine, putBackLine } from '../../local/local-app-dev-files'
14
+ import { setAsideCopyPaths } from '../../local/local-app-sdk-paths'
7
15
  import { requireProjectFrom } from './shared'
8
16
  import { flagBool, flagString, type Command } from '../types'
9
17
 
10
18
  const DEFAULT_PORT = 3000
11
19
  const READY_TIMEOUT_MS = 30_000
12
20
 
13
- function appPort(raw: string | undefined): number {
21
+ /** The port `--port` asks for; 0 is any free one, picked when the server starts. */
22
+ export function appPort(raw: string | undefined): number {
14
23
  if (raw === undefined) return DEFAULT_PORT
15
24
  const port = Number(raw)
16
- if (!Number.isInteger(port) || port < 1 || port > 65_535) {
17
- throw new UsageError('--port must be an integer from 1 to 65535', 'for example: frontera app dev --port 3210')
25
+ if (raw.trim() === '' || !Number.isInteger(port) || port < 0 || port > 65_535) {
26
+ throw new UsageError('--port must be an integer from 0 to 65535 (0: any free port)', 'for example: frontera app dev --port 3210, or --port 0')
18
27
  }
19
28
  return port
20
29
  }
@@ -55,6 +64,16 @@ function openBrowser(url: string): void {
55
64
  }
56
65
  }
57
66
 
67
+ /** How often a dev server an MCP session or Forge started checks that its starter is still there. */
68
+ const OWNER_POLL_MS = 5_000
69
+ /** How long a stopping dev server is given to exit before what it wrote is put back anyway. */
70
+ const STOP_WAIT_MS = 10_000
71
+
72
+ function withoutOwner(env: NodeJS.ProcessEnv): Record<string, string> {
73
+ const { [DEV_OWNER_ENV]: _owner, ...rest } = env
74
+ return rest as Record<string, string>
75
+ }
76
+
58
77
  export const appDev: Command = {
59
78
  meta: {
60
79
  noun: 'app',
@@ -62,18 +81,40 @@ export const appDev: Command = {
62
81
  args: [],
63
82
  flags: { port: 'string', 'no-open': 'boolean' },
64
83
  summary: 'Run an App locally with short-lived authenticated Blueprint access',
65
- examples: ['frontera app dev', 'frontera app dev --port 3210 --no-open'],
84
+ examples: ['frontera app dev', 'frontera app dev --port 3210 --no-open', 'frontera app dev --port 0'],
66
85
  needsProject: true,
67
86
  },
68
87
 
69
88
  async run(ctx) {
70
89
  const project = requireProjectFrom(ctx)
71
- const port = appPort(flagString(ctx, 'port'))
90
+ const asked = appPort(flagString(ctx, 'port'))
91
+ // `--port 0`: one free on every loopback, said with the URL below.
92
+ const port = asked === 0 ? await freeLoopbackPort() : asked
93
+ // The App binds 127.0.0.1; a process holding the port on ::1 or a wildcard would answer `localhost` instead.
94
+ if (asked !== 0 && !(await isLoopbackPortFree(port))) {
95
+ throw new CliError(`port ${port} is already in use`, {
96
+ code: 'VALIDATION_ERROR',
97
+ hint: `something listens on ${port} (on 127.0.0.1, ::1 or every address); stop it, or choose another: frontera app dev --port ${port + 1}`,
98
+ })
99
+ }
72
100
  const appOrigin = `http://127.0.0.1:${port}`
73
101
  const framework = detectAppFramework(project.root)
102
+ // A pulled App comes without its packages: installed first, and said, or its dev command isn't there (`next: command not found`).
103
+ if (!existsSync(join(project.root, 'node_modules'))) ctx.output.note('[dev] no node_modules here: installing the App\'s packages first (bun install)')
104
+ const installed = await ensureAppPackages(project.root)
105
+ if (installed?.lockMismatch) throw new CliError(`the App's packages weren't installed: ${installed.summary}`, { code: 'FAILURE', hint: 'bun.lock was left as it is; the message above says which file to change' })
106
+ if (installed && !installed.installed) {
107
+ // From its own SDK copy, the install that keeps package.json and bun.lock as pulled is the one to retry.
108
+ throw new CliError(`the App's packages could not be installed: ${installed.summary}`, { code: 'FAILURE', hint: installed.fromCopy ? 'run `bun install --no-save` in the App folder (it writes neither package.json nor bun.lock) and read its error' : 'run `bun install` in the App folder and read its error' })
109
+ }
110
+ if (installed) ctx.output.note(`[dev] ${installed.summary}`)
74
111
  const apps = new AppsApi(ctx.apiUrl, ctx.token)
75
112
  const app = await apps.ensureApp(project.slug, project.displayName, project.appId)
76
113
  writeAppId(project.root, app.id)
114
+ // The solution this App is in, if any: where status reads how this server's sessions fare.
115
+ const solution = enclosingSolution(project.root)
116
+ // The id sessions are minted for: a restore to a baseline from before the App existed takes it away.
117
+ let appId = app.id
77
118
 
78
119
  let broker: ReturnType<typeof createDevSessionBroker> | null = null
79
120
  if (framework === 'next') {
@@ -87,13 +128,26 @@ export const appDev: Command = {
87
128
  broker = createDevSessionBroker({
88
129
  appOrigin,
89
130
  loadSession: async () => {
90
- const session = await apps.appToken(app.id)
131
+ const minted = await mintDevSession({
132
+ appId,
133
+ mint: (id) => apps.appToken(id),
134
+ // Gone from the runtime (`local snapshot restore`): registered again by its slug, as `app dev` started.
135
+ reRegister: async () => (await apps.ensureApp(project.slug, project.displayName, null)).id,
136
+ onReRegistered: (id) => {
137
+ if (id === appId) return
138
+ ctx.output.note(`[dev] the runtime no longer had ${app.slug} (a restore of its database?): registered it again as ${id}; \`frontera app deploy\` puts its code back`)
139
+ appId = id
140
+ writeAppId(project.root, id)
141
+ },
142
+ record: solution ? (state) => writeDevSessionState(solution.stateDir, project.slug, state) : undefined,
143
+ })
144
+ const session = minted.session
91
145
  return {
92
146
  init: {
93
147
  type: 'frontera:init',
94
148
  token: session.token,
95
149
  apiBaseUrl: ctx.apiUrl.replace(/\/+$/, ''),
96
- appId: app.id,
150
+ appId: minted.appId,
97
151
  version: 'dev',
98
152
  orgId: identity.orgId,
99
153
  workspaceId: identity.workspaceId,
@@ -116,6 +170,13 @@ export const appDev: Command = {
116
170
 
117
171
  const targetUrl = framework === 'vite' ? `${appOrigin}/dev-host.html` : appOrigin
118
172
  ctx.output.note(`[dev] ${app.slug} at ${targetUrl}`)
173
+ // Installed as its bun.lock records while tsconfig.json still sends the SDK to the older copy under src/frontera/:
174
+ // the server runs with those paths set aside, so it serves the code against the release it was written for.
175
+ const aside = setAsideCopyPaths(project.root)
176
+ if (aside) ctx.output.note(`[dev] ${aside.line}`)
177
+ // The tracked files the dev server writes (`next dev` rewrites next-env.d.ts when it starts) go back as they
178
+ // were once it is ready, and again when it stops: a preview is not a change. Never a change of the person's.
179
+ const kept = keepDevWrittenFiles(project.root)
119
180
 
120
181
  let child: Bun.Subprocess
121
182
  try {
@@ -125,9 +186,10 @@ export const appDev: Command = {
125
186
  : ['bun', 'run', 'dev', '--', '--hostname', '127.0.0.1', '--port', String(port)],
126
187
  {
127
188
  cwd: project.root,
189
+ // The owner is this command's to watch, not the framework's: nothing under it inherits the lifetime.
128
190
  env: broker
129
- ? { ...process.env, NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT: broker.endpoint }
130
- : process.env,
191
+ ? { ...withoutOwner(process.env), NEXT_PUBLIC_FRONTERA_DEV_SESSION_ENDPOINT: broker.endpoint }
192
+ : withoutOwner(process.env),
131
193
  stdin: 'inherit',
132
194
  stdout: 'inherit',
133
195
  stderr: 'inherit',
@@ -135,6 +197,7 @@ export const appDev: Command = {
135
197
  )
136
198
  } catch (error) {
137
199
  broker?.server.stop(true)
200
+ aside?.restore()
138
201
  throw error
139
202
  }
140
203
 
@@ -148,10 +211,32 @@ export const appDev: Command = {
148
211
  const onSignal = () => stop()
149
212
  process.on('SIGINT', onSignal)
150
213
  process.on('SIGTERM', onSignal)
214
+ // Started by an MCP session (`app_dev`, `app_render`) or Forge (its Start): it stops when that process ends,
215
+ // however it ends, so a server an agent or Forge started never outlives it. The starter recorded the server itself.
216
+ const owner = Number(process.env[DEV_OWNER_ENV]) || null
217
+ const unwatch = owner
218
+ ? watchOwner(
219
+ owner,
220
+ () => {
221
+ ctx.output.note(`[dev] the process that started this server (pid ${owner}) ended, so the server stops`)
222
+ // The whole group this server leads (it was started detached): the framework's server under `bun run dev` too.
223
+ try {
224
+ process.kill(-process.pid, 'SIGTERM')
225
+ } catch {
226
+ stop()
227
+ }
228
+ },
229
+ OWNER_POLL_MS,
230
+ )
231
+ : null
151
232
 
152
233
  try {
153
234
  await waitForApp(targetUrl, child)
235
+ const back = putBackLine(kept.afterReady(), 'started')
236
+ if (back) ctx.output.note(`[dev] ${back}`)
154
237
  ctx.output.note(`[dev] ready: ${targetUrl}`)
238
+ // In a solution, say so where its agents look, so they render this server instead of starting another.
239
+ if (solution && !owner) registerDevServer(solution, { slug: project.slug, pid: process.pid, port, url: targetUrl, startedAt: new Date().toISOString(), log: 'the terminal running frontera app dev' })
155
240
  if (!flagBool(ctx, 'no-open')) openBrowser(targetUrl)
156
241
  const exitCode = await child.exited
157
242
  if (!stopped && exitCode !== 0) {
@@ -161,13 +246,20 @@ export const appDev: Command = {
161
246
  })
162
247
  }
163
248
  return {
164
- data: { appId: app.id, origin: targetUrl, exitCode },
249
+ data: { appId, origin: targetUrl, exitCode },
165
250
  text: `Stopped ${app.slug} local development server.`,
166
251
  }
167
252
  } finally {
253
+ unwatch?.()
168
254
  process.off('SIGINT', onSignal)
169
255
  process.off('SIGTERM', onSignal)
256
+ if (solution) forgetDevServer(solution, project.slug, process.pid)
170
257
  stop()
258
+ // Once the framework's server has exited (or after a while, whatever it does), what it wrote goes back.
259
+ await Promise.race([child.exited, Bun.sleep(STOP_WAIT_MS)])
260
+ const { putBack, left } = kept.atStop()
261
+ for (const line of [putBackLine(putBack, 'stopped'), leftLine(left)]) if (line) ctx.output.note(`[dev] ${line}`)
262
+ if (aside) ctx.output.note(`[dev] ${aside.restore()}`)
171
263
  }
172
264
  },
173
265
  }
@@ -13,6 +13,8 @@ import { BASELINE_COMPONENTS, addShadcnComponents, shadcnAddCommand } from '../.
13
13
  import { scaffold, scaffoldFiles, type AppFramework } from '../../template'
14
14
  import { writeHarnessFiles } from '../../harness'
15
15
  import { DEV_ENV_FILE, writeDevEnv } from '../../dev-env'
16
+ import { checkoutSdkFor, LOCAL_SDK_DIR, useCheckoutSdk } from '../../local/local-app-sdk'
17
+ import { findSolutionRoot, PROJECT_FILES } from '../../local/local-project'
16
18
  import { flagBool, flagString, type Command, type CommandContext, type CommandResult } from '../types'
17
19
 
18
20
  /**
@@ -122,6 +124,21 @@ async function adoptExistingProject(ctx: CommandContext, root: string): Promise<
122
124
  }
123
125
  }
124
126
 
127
+ /**
128
+ * Where `frontera app init <name>` puts a new App: inside a Frontera
129
+ * solution (a folder with `frontera.yaml` at or above it), its `apps/`
130
+ * folder, where `local dev`, `local check` and the MCP tools find Apps; any
131
+ * other folder, beside where it runs. `--dir` always wins, and an existing
132
+ * project named here is adopted where it is.
133
+ */
134
+ export function appParentDir(cwd: string, name: string, dir: string | undefined): { base: string; inSolution: string | null } {
135
+ if (dir) return { base: dir, inSolution: null }
136
+ if (existsSync(join(cwd, name, 'package.json'))) return { base: cwd, inSolution: null }
137
+ const root = findSolutionRoot(cwd)
138
+ if (!PROJECT_FILES.some((f) => existsSync(join(root, f)))) return { base: cwd, inSolution: null }
139
+ return { base: join(root, 'apps'), inSolution: root }
140
+ }
141
+
125
142
  export const appInit: Command = {
126
143
  meta: {
127
144
  noun: 'app',
@@ -154,7 +171,8 @@ export const appInit: Command = {
154
171
 
155
172
  async run(ctx) {
156
173
  const name = ctx.positional[0]
157
- const base = flagString(ctx, 'dir') ?? ctx.cwd
174
+ const placed = name ? appParentDir(ctx.cwd, name, flagString(ctx, 'dir')) : { base: flagString(ctx, 'dir') ?? ctx.cwd, inSolution: null }
175
+ const base = placed.base
158
176
 
159
177
  /**
160
178
  * Which of the two jobs this is, decided the way `shadcn init` decides it:
@@ -179,7 +197,8 @@ export const appInit: Command = {
179
197
  throw new UsageError('unsupported framework', 'use --framework next or --framework react')
180
198
  }
181
199
  const framework: AppFramework = (requested as AppFramework | undefined) ?? 'next'
182
- const target = resolve(join(flagString(ctx, 'dir') ?? ctx.cwd, name))
200
+ const target = resolve(join(base, name))
201
+ if (placed.inSolution) ctx.output.note(` in the solution at ${placed.inSolution}: the App goes under apps/`)
183
202
 
184
203
  // Both checks precede the first write. A scaffold that fails halfway is
185
204
  // worse than one that never started, because the caller cannot tell which
@@ -236,6 +255,20 @@ export const appInit: Command = {
236
255
  ctx.output.note('')
237
256
  }
238
257
 
258
+ // A local runtime that runs from a platform checkout has that checkout's SDK, which can be ahead of
259
+ // every npm release: the App installs the checkout's packages (`frontera app sdk use-local`).
260
+ const checkout = framework === 'next' ? checkoutSdkFor(target) : null
261
+ if (checkout) {
262
+ try {
263
+ const used = useCheckoutSdk(target, checkout)
264
+ ctx.output.note(` SDK source the platform checkout the local runtime runs from (${checkout.version}): ${used.packages.map((p) => p.pkg).join(', ')} packed into ${LOCAL_SDK_DIR}/`)
265
+ ctx.output.note(' `frontera app sdk use-local --npm` switches to the published packages')
266
+ } catch (err) {
267
+ ctx.output.note(` SDK source npm: the checkout's SDK could not be packed (${(err as Error).message}); \`frontera app sdk use-local\` tries again`)
268
+ }
269
+ ctx.output.note('')
270
+ }
271
+
239
272
  let installed = false
240
273
  if (!flagBool(ctx, 'no-install')) {
241
274
  const result = await ctx.output.task('Installing dependencies with bun', () =>
@@ -289,7 +322,7 @@ export const appInit: Command = {
289
322
  ...(framework === 'react'
290
323
  ? [' bun run dev', ' bun run typecheck', ' bun run build']
291
324
  : [' frontera app dev', ' bun run check']),
292
- ' frontera app deploy --no-promote',
325
+ ' frontera app deploy',
293
326
  ]
294
327
 
295
328
  return {
@@ -1,36 +1,62 @@
1
1
  import { AppsApi } from '../../api/apps-api'
2
- import { UsageError } from '../../errors'
2
+ import { CliError, UsageError } from '../../errors'
3
3
  import { projectApp, requireProjectFrom } from './shared'
4
- import type { Command } from '../types'
4
+ import { flagBool, flagString, type Command } from '../types'
5
5
 
6
+ /**
7
+ * Point production at an existing deployment. No rebuild: the reviewed
8
+ * artifact is what goes live. Required checks hold it; `--force` skips them
9
+ * with a reason, audited. A pinned production refuses until the rollback is
10
+ * undone or the pin lifted.
11
+ */
6
12
  export const appPromote: Command = {
7
13
  meta: {
8
14
  noun: 'app',
9
15
  verb: 'promote',
10
- args: [{
11
- name: 'version',
12
- required: true,
13
- description: 'version to make live, from `frontera app versions`',
14
- producer: 'frontera app versions — then `frontera app promote <version>`',
15
- }],
16
- flags: {},
17
- summary: 'Move the live pointer to an already-published version',
18
- examples: ['frontera app promote 1.4.0'],
16
+ args: [
17
+ {
18
+ name: 'deployment',
19
+ required: true,
20
+ description: 'the deployment id or release label, from `frontera app deployments`',
21
+ producer: 'frontera app deployments — then `frontera app promote <deployment>`',
22
+ },
23
+ ],
24
+ flags: { force: 'boolean', reason: 'string' },
25
+ summary: 'Point production at an existing deployment, once its required checks pass',
26
+ examples: ['frontera app promote dpl_01j…', 'frontera app promote 1.4.0', 'frontera app promote 1.4.0 --force --reason "hotfix for the outage"'],
19
27
  needsProject: true,
20
28
  },
21
29
 
22
30
  async run(ctx) {
23
- const version = ctx.positional[0]
24
- if (!version) throw new UsageError('missing <version>', 'frontera app promote <version>')
25
-
31
+ const deployment = ctx.positional[0]
32
+ if (!deployment) throw new UsageError('missing <deployment>', 'frontera app promote <deployment>')
33
+ const force = flagBool(ctx, 'force')
34
+ const reason = flagString(ctx, 'reason')
35
+ if (force && !reason?.trim()) throw new UsageError('--force needs --reason', 'frontera app promote <deployment> --force --reason "why the checks can wait"')
26
36
  const project = requireProjectFrom(ctx)
27
37
  const client = new AppsApi(ctx.apiUrl, ctx.token)
28
38
  const app = await projectApp(client, project)
29
- const res = await client.deploy(app.id, version)
30
-
31
- return {
32
- data: { app: app.slug, appId: app.id, deployedVersion: res.deployedVersion },
33
- text: `Promoted ${app.slug}@${res.deployedVersion}`,
39
+ try {
40
+ const res = await client.promote(app.id, { deployment, ...(force ? { force: true, reason } : {}) })
41
+ const name = res.label ?? res.promoted
42
+ return {
43
+ data: { app: app.slug, appId: app.id, ...res },
44
+ text: `Promoted ${app.slug}@${name}${res.forced.length ? ` (skipped required checks: ${res.forced.join(', ')})` : ''}${res.liveUrl ? `\n\nLive at ${res.liveUrl}` : ''}`,
45
+ }
46
+ } catch (err) {
47
+ const details = (err as { details?: { code?: string; checks?: Array<{ label: string; detail: string; required: boolean; status: string }> } }).details
48
+ if (details?.code === 'CHECKS_FAILED') {
49
+ const blocking = (details.checks ?? []).filter((c) => c.required && c.status !== 'passed')
50
+ throw new CliError(`${deployment} did not go live: required checks did not pass`, {
51
+ code: 'CONFLICT',
52
+ hint: `${blocking.map((c) => `${c.label} — ${c.detail}`).join('; ')}. Fix them and deploy again, or --force --reason "…"`,
53
+ details,
54
+ })
55
+ }
56
+ if (details?.code === 'PRODUCTION_PINNED') {
57
+ throw new CliError((err as Error).message, { code: 'CONFLICT', hint: 'frontera app rollback --undo returns to what served before the rollback; unpin on the App\'s Deployments keeps what serves now' })
58
+ }
59
+ throw err
34
60
  }
35
61
  },
36
62
  }
@@ -94,7 +94,9 @@ export const appPull: Command = {
94
94
  }
95
95
 
96
96
  const count = unpackTo(dir, bytes)
97
- writeState(dir, { appId: app.id, parentVersion })
97
+ // What was pulled, apart from a deployment from here: the folder is not read as deployed until `app deploy`,
98
+ // whose parent is this version (`readProject`). A deployment recorded before this pull no longer describes the tree.
99
+ writeState(dir, { appId: app.id, parentVersion: null, pulledFrom: app.id, pulledVersion: parentVersion })
98
100
 
99
101
  // Pull's contract is "give me a working copy", and a copy that cannot
100
102
  // `bun install` is not one. Apps stored by an older toolchain carry
@@ -0,0 +1,46 @@
1
+ import { AppsApi } from '../../api/apps-api'
2
+ import { UsageError } from '../../errors'
3
+ import { projectApp, requireProjectFrom } from './shared'
4
+ import { flagBool, flagString, type Command } from '../types'
5
+
6
+ /**
7
+ * Point production at an earlier production deployment (the previous one
8
+ * when none is named) and pin it: nothing promotes until `--undo` or an
9
+ * audited Unpin, so the next routine deploy cannot replace the rollback.
10
+ */
11
+ export const appRollback: Command = {
12
+ meta: {
13
+ noun: 'app',
14
+ verb: 'rollback',
15
+ args: [{ name: 'deployment', required: false, description: 'an earlier production deployment (id or label); the previous one by default' }],
16
+ flags: { reason: 'string', undo: 'boolean' },
17
+ summary: 'Roll production back and pin it; --undo restores what served before',
18
+ examples: ['frontera app rollback --reason "checkout broke"', 'frontera app rollback 1.3.0 --reason "back to the last good release"', 'frontera app rollback --undo'],
19
+ needsProject: true,
20
+ },
21
+
22
+ async run(ctx) {
23
+ const undo = flagBool(ctx, 'undo')
24
+ const reason = flagString(ctx, 'reason')
25
+ if (!undo && !reason?.trim()) throw new UsageError('a rollback needs --reason', 'frontera app rollback --reason "what went wrong"')
26
+ const project = requireProjectFrom(ctx)
27
+ const client = new AppsApi(ctx.apiUrl, ctx.token)
28
+ const app = await projectApp(client, project)
29
+ if (undo) {
30
+ const res = await client.undoRollback(app.id)
31
+ return {
32
+ data: { app: app.slug, appId: app.id, ...res },
33
+ // A platform from before the undo refused a missing origin answers with nothing restored: say what happened.
34
+ text: res.restored
35
+ ? `Undid the rollback of ${app.slug}: production serves ${res.label ?? res.restored} again and is no longer pinned`
36
+ : `Lifted the pin on ${app.slug}, but nothing was restored: the deployment production served before the rollback is gone, so production still serves what it does now`,
37
+ }
38
+ }
39
+ const deployment = ctx.positional[0]
40
+ const res = await client.rollback(app.id, { ...(deployment ? { deployment } : {}), reason: reason! })
41
+ return {
42
+ data: { app: app.slug, appId: app.id, ...res },
43
+ text: `Rolled ${app.slug} back to ${res.label ?? res.to}${res.from ? ` from ${res.fromLabel ?? res.from}` : ''}. Production is pinned: new deployments won't go live until \`frontera app rollback --undo\` or an Unpin.`,
44
+ }
45
+ },
46
+ }
@@ -1,26 +1,72 @@
1
- import { UsageError } from '../../errors'
1
+ import { CliError, UsageError } from '../../errors'
2
+ import { checkoutSdkFor, LOCAL_SDK_DIR, sdkSwitchChangeLines, useCheckoutSdk, useNpmSdk } from '../../local/local-app-sdk'
3
+ import { installDependencies } from '../../project-bootstrap'
2
4
  import { syncVendoredAppSdk } from '../../sdk-sync'
3
5
  import { requireProjectFrom } from './shared'
4
- import type { Command } from '../types'
6
+ import { flagBool, type Command } from '../types'
5
7
 
6
8
  export const appSdk: Command = {
7
9
  meta: {
8
10
  noun: 'app',
9
11
  verb: 'sdk',
10
- args: [{ name: 'action', required: true, description: 'sync', producer: 'frontera app sdk sync' }],
11
- flags: {},
12
- summary: 'Refresh the generated SDK tree in a legacy vendored App',
13
- examples: ['frontera app sdk sync'],
12
+ args: [{ name: 'action', required: true, description: 'sync | use-local', producer: 'frontera app sdk use-local' }],
13
+ flags: { npm: 'boolean' },
14
+ summary: 'Use the SDK of the platform checkout the local runtime runs from (use-local), or refresh a legacy vendored SDK (sync)',
15
+ details: [
16
+ `\`use-local\`: when the local runtime runs in source mode (a platform checkout), the App's @frontera-sdk packages are packed from that checkout into ${LOCAL_SDK_DIR}/ and package.json depends on them (\`file:./${LOCAL_SDK_DIR}/<tarball>\`, a relative path), then \`bun install\` runs. It writes package.json and a tarball per package in ${LOCAL_SDK_DIR}/, deletes an older tarball of the same package, and the install writes node_modules and updates bun.lock. Commit package.json, bun.lock and ${LOCAL_SDK_DIR}/: a clone, CI and \`app deploy\` build the same SDK. Run it again after the checkout's SDK changes.`,
17
+ `\`use-local --npm\`: back to the published packages, then \`bun install\` runs. Each @frontera-sdk dependency on a checkout tarball or on the SDK copy under src/frontera/ is set to the release the App's lockfile installed (\`^<that version>\`, and the lockfile's own record of the range is rewritten to match), else to the CLI's \`^<version>\`. It deletes ${LOCAL_SDK_DIR}/ and each SDK copy under src/frontera/ (src/frontera/core/, src/frontera/blueprint/, and src/frontera/ when that leaves it empty) with the tsconfig.json paths that point at them. A Vite App whose config aliases its copy keeps it. The output lists every file it changed or deleted: review them before you commit.`,
18
+ '`sync`: refreshes the SDK tree a legacy (Vite) App vendors under src/frontera/.',
19
+ ],
20
+ examples: ['frontera app sdk use-local', 'frontera app sdk use-local --npm', 'frontera app sdk sync'],
14
21
  needsProject: true,
22
+ // Local files and a local install: no credential.
23
+ offline: true,
15
24
  },
16
25
  async run(ctx) {
17
- if (ctx.positional[0] !== 'sync') throw new UsageError('expected sync', 'frontera app sdk sync')
26
+ const action = ctx.positional[0]
18
27
  const project = requireProjectFrom(ctx)
28
+ if (action === 'use-local') {
29
+ const npm = flagBool(ctx, 'npm')
30
+ const checkout = npm ? null : checkoutSdkFor(project.root)
31
+ if (!npm && !checkout) {
32
+ throw new UsageError(
33
+ 'the local runtime does not run from a platform checkout here, so there is no local SDK to use',
34
+ 'source mode is set by `frontera local start --source <checkout>`; in image mode the App uses the published packages (`frontera app sdk use-local --npm`)',
35
+ )
36
+ }
37
+ let switched
38
+ try {
39
+ switched = npm ? useNpmSdk(project.root) : useCheckoutSdk(project.root, checkout!)
40
+ } catch (err) {
41
+ throw new CliError((err as Error).message, { code: 'FAILURE', hint: 'check that bun is on PATH and the checkout has packages/sdk-core and packages/sdk-blueprint' })
42
+ }
43
+ const install = await installDependencies(project.root)
44
+ if (!install.installed) throw new CliError(`package.json now names the ${npm ? 'published' : 'checkout'} SDK, but bun install failed: ${install.summary}`, { code: 'FAILURE', hint: 'run `bun install` in the App' })
45
+ // What package.json now says, and where a range comes from: the release its lockfile installed, or this CLI's SDK.
46
+ const lines = switched.packages.map((p) => ` ${p.pkg.padEnd(26)} ${p.spec}${npm ? (p.locked ? ` (the release its lockfile installed, ${p.locked}, recorded there with the same range)` : ` (this CLI's SDK range: no lockfile recorded a published release)`) : ''}`)
47
+ // Every file the switch wrote or deleted, by name, then what the install changed.
48
+ const changes = sdkSwitchChangeLines(switched.changes)
49
+ return {
50
+ data: { source: npm ? 'npm' : 'checkout', ...(checkout ? { checkout: checkout.platformSource, version: checkout.version } : {}), packages: switched.packages, changes: switched.changes, installed: install.installed },
51
+ text: [
52
+ switched.packages.length
53
+ ? npm ? 'The App uses the published SDK again. package.json now names:' : `The App uses the SDK of the platform checkout (${checkout!.version}):`
54
+ : 'The App already used the published SDK.',
55
+ ...lines,
56
+ ...(changes.length ? ['Changed in the App:', ...changes.map((c) => ` ${c}`)] : []),
57
+ ` bun install: ${install.summary} (node_modules, and bun.lock where what it resolves changed)`,
58
+ npm
59
+ ? changes.length ? 'Review these changes, then commit them.' : ''
60
+ : `Commit package.json, bun.lock and ${LOCAL_SDK_DIR}/. Run this again after the checkout's SDK changes; \`--npm\` switches back.`,
61
+ ].filter(Boolean).join('\n'),
62
+ }
63
+ }
64
+ if (action !== 'sync') throw new UsageError('expected use-local or sync', 'frontera app sdk use-local')
19
65
  const result = syncVendoredAppSdk(project.root)
20
66
  if (result.kind === 'published') {
21
67
  return {
22
68
  data: result,
23
- text: 'This App uses published @frontera-sdk packages; update them with the package manager.',
69
+ text: 'This App uses published @frontera-sdk packages; update them with the package manager, or `frontera app sdk use-local` for the local runtime\'s checkout.',
24
70
  }
25
71
  }
26
72
  return {
@@ -0,0 +1,27 @@
1
+ import { AppsApi } from '../../api/apps-api'
2
+ import { UsageError } from '../../errors'
3
+ import { projectApp, requireProjectFrom } from './shared'
4
+ import { flagString, type Command } from '../types'
5
+
6
+ /** Lift the pin a rollback left, keeping what serves: new deployments may go live again. Audited. */
7
+ export const appUnpin: Command = {
8
+ meta: {
9
+ noun: 'app',
10
+ verb: 'unpin',
11
+ args: [],
12
+ flags: { reason: 'string' },
13
+ summary: 'Unpin production after a rollback, keeping what serves now',
14
+ examples: ['frontera app unpin --reason "fixed forward in 1.4.1"'],
15
+ needsProject: true,
16
+ },
17
+
18
+ async run(ctx) {
19
+ const reason = flagString(ctx, 'reason')
20
+ if (!reason?.trim()) throw new UsageError('unpinning needs --reason', 'frontera app unpin --reason "why production may move again"')
21
+ const project = requireProjectFrom(ctx)
22
+ const client = new AppsApi(ctx.apiUrl, ctx.token)
23
+ const app = await projectApp(client, project)
24
+ const res = await client.unpin(app.id, reason)
25
+ return { data: { app: app.slug, appId: app.id, ...res }, text: `Unpinned ${app.slug}: production keeps serving ${res.productionLabel ?? res.production ?? 'what it serves'}, and promotion works again` }
26
+ },
27
+ }
@@ -9,7 +9,7 @@ export const appVersions: Command = {
9
9
  verb: 'versions',
10
10
  args: [],
11
11
  flags: {},
12
- summary: 'List published versions, newest first; * marks the live one',
12
+ summary: 'List deployments, newest first; * marks production (see app deployments)',
13
13
  examples: ['frontera app versions', 'frontera app versions --json'],
14
14
  needsProject: true,
15
15
  },
@@ -161,7 +161,8 @@ const bindings: Command = {
161
161
  // still has a row, and a list that showed only the row would
162
162
  // read as a working permission.
163
163
  b.projects ? 'yes' : 'no',
164
- b.requiresApproval ? 'required' : 'auto',
164
+ // `requiresApproval: false` is not "never asks": in an attended chat the person's approval mode still asks.
165
+ b.requiresApproval ? 'required' : 'unattended (no card)',
165
166
  `${b.waitSeconds}s`,
166
167
  b.inertReason ?? '',
167
168
  ]),
@@ -181,7 +182,8 @@ const bindings: Command = {
181
182
  b.agentSlug,
182
183
  b.agentName,
183
184
  String(b.enabled),
184
- b.requiresApproval ? 'required' : 'auto',
185
+ // `requiresApproval: false` is not "never asks": in an attended chat the person's approval mode still asks.
186
+ b.requiresApproval ? 'required' : 'unattended (no card)',
185
187
  `${b.waitSeconds}s`,
186
188
  b.createdBy,
187
189
  ]),
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os'
3
3
  import { join, resolve } from 'node:path'
4
4
 
5
5
  import { UsageError } from '../../errors'
6
+ import { bundleIdMapFromEnv, mapBundleIds } from '../../local/local-function-ids'
6
7
 
7
8
  /**
8
9
  * Building an automation entry file, extracted from `index-commands`.
@@ -51,7 +52,9 @@ export async function buildAndExtract(file: string): Promise<{
51
52
  )
52
53
  }
53
54
 
54
- const code = await built.outputs[0]!.text()
55
+ // `local dev` deploying a pulled Function to the local runtime: the workspace's object type ids
56
+ // its code hard-codes become this runtime's, in the bundle only (`local-function-ids.ts`).
57
+ const code = mapBundleIds(await built.outputs[0]!.text(), bundleIdMapFromEnv())
55
58
 
56
59
  const dir = mkdtempSync(join(tmpdir(), 'frontera-automation-'))
57
60
  try {
@@ -6,6 +6,7 @@ import { validateManifest } from '@frontera-sdk/functions'
6
6
 
7
7
  import { AutomationApi } from '../../api/automation-api'
8
8
  import { UsageError } from '../../errors'
9
+ import { outsideImports } from '../../function-outside-imports'
9
10
  import { packDirectory } from '../../pack'
10
11
  import { automationInit } from './init'
11
12
  import { automationPull } from './pull'
@@ -118,7 +119,8 @@ const deploy: Command = {
118
119
  const projectRoot = findAutomationProjectRoot(resolve(file))
119
120
  let source: Uint8Array | undefined
120
121
  if (projectRoot) {
121
- source = packDirectory(projectRoot, ctx.output, 'source').tgz
122
+ // With the files it imports from outside its folder, so a pull brings a Function that builds.
123
+ source = packDirectory(projectRoot, ctx.output, 'source', outsideImports(projectRoot, resolve(file))).tgz
122
124
  } else {
123
125
  // Degrade honestly rather than refuse. Authoring inside this monorepo is
124
126
  // still supported and simply cannot retain source — there is no
@@ -1,8 +1,9 @@
1
1
  import { existsSync, mkdirSync, readdirSync } from 'node:fs'
2
- import { resolve } from 'node:path'
2
+ import { join, resolve } from 'node:path'
3
3
 
4
4
  import { AutomationApi } from '../../api/automation-api'
5
5
  import { UsageError } from '../../errors'
6
+ import { OUTSIDE_DIR } from '../../function-outside-imports'
6
7
  import { unpackTo } from '../../untar'
7
8
  import { flagBool, type Command } from '../types'
8
9
 
@@ -109,11 +110,15 @@ export const automationPull: Command = {
109
110
  const gz = await client.downloadSource(slug, resolved)
110
111
  mkdirSync(target, { recursive: true })
111
112
  const count = unpackTo(target, gz)
113
+ // Files it imports from beside its folder travel under `frontera-imports/` (`function-outside-imports.ts`):
114
+ // they are said, since this folder alone has nowhere beside it to put them.
115
+ const beside = existsSync(join(target, OUTSIDE_DIR))
112
116
 
113
117
  return {
114
118
  data: { slug, version: resolved, path: target, files: count },
115
119
  text: [
116
120
  `Pulled ${slug} v${resolved} into ${target} (${count} files)`,
121
+ ...(beside ? [` ${OUTSIDE_DIR}/ holds the files it imports from beside its folder, by their path from it (each __up__ is ..): put them there before deploying, or pull the project with \`frontera local sync pull\`, which does`] : []),
117
122
  '',
118
123
  ` cd ${target}`,
119
124
  ' bun install',