@frontera-sdk/cli 1.52.38 → 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
@@ -5,14 +5,20 @@ import type { LocalCredentials } from './local-state'
5
5
  * An Agent's Context and data on the local runtime: the object types it may
6
6
  * read through its Blueprint tools, from the `blueprint` list in its
7
7
  * `agents/<slug>/bindings.json` (`{ "objectType": "objectType/Incident",
8
- * "permissions": ["read"] }`). On the platform these are the Agent's
8
+ * "permissions": ["read"] }`), and the types it may submit governed Actions
9
+ * on (`"act"`, which implies read). On the platform these are the Agent's
9
10
  * Blueprint grants; without them it has no Blueprint tools at all.
10
11
  *
11
12
  * Granting is a Steward's call on the organization's Blueprint, so it is made
12
13
  * with the runtime's organization key, after the Blueprint is applied and
13
14
  * published (bindings apply after it). The runtime ends up with the file's
14
15
  * grants: missing ones are granted, changed permissions replaced, and grants
15
- * the file does not list revoked. An object type the runtime leaves off
16
+ * the file does not list revoked. An entry that names no `permissions` (and
17
+ * every grant assumed when the pull couldn't read them, `keepHeld`) is read
18
+ * plus what the runtime's grant already holds, so `act` or `read_sensitive`
19
+ * is taken away only by a file that lists the type's permissions without it
20
+ * (for `act`, the grant is sent with `removePermissions`: a re-grant on the
21
+ * runtime otherwise keeps it). An object type the runtime leaves off
16
22
  * (media files) is skipped with a warning; one it does not hold yet waits
17
23
  * for the Blueprint to build, so the Agent's tools read as blocked on it.
18
24
  */
@@ -41,6 +47,8 @@ export async function applyAgentGrants(input: {
41
47
  leftOff: LeftOff
42
48
  /** Whether the project's Blueprint has a file for this object type. */
43
49
  inProject: (apiName: string) => boolean
50
+ /** Every grant keeps what the runtime's grant holds beyond read (the file's permissions are assumed, not read). */
51
+ keepHeld?: boolean
44
52
  fetch?: typeof fetch
45
53
  }): Promise<GrantOutcome> {
46
54
  const { slug, creds } = input
@@ -71,13 +79,17 @@ export async function applyAgentGrants(input: {
71
79
 
72
80
  // Wanted: by API name, the file's permissions (read when it names none).
73
81
  const wanted = new Map<string, string[]>()
82
+ // The types whose permissions the file doesn't state: read, plus what the runtime's grant holds.
83
+ const keep = new Set<string>()
74
84
  for (const g of input.grants) {
75
85
  const ref = typeof g?.objectType === 'string' ? parseRef(g.objectType) : null
76
86
  if (!ref || ref.kind !== 'objectType') {
77
87
  failures.push(` ${String(g?.objectType)}: not an object type reference (objectType/<apiName>)`)
78
88
  continue
79
89
  }
80
- wanted.set(ref.name, [...(Array.isArray(g.permissions) && g.permissions.length ? g.permissions : ['read'])].sort())
90
+ // `read` is implied by the others (`read_sensitive`, `act`), as the runtime stores the grant.
91
+ wanted.set(ref.name, [...new Set(['read', ...(Array.isArray(g.permissions) ? g.permissions : [])])].sort())
92
+ if (input.keepHeld || !Array.isArray(g.permissions)) keep.add(ref.name)
81
93
  }
82
94
 
83
95
  // The workspace's own list, which holds Agents never published too; the organization's
@@ -91,10 +103,15 @@ export async function applyAgentGrants(input: {
91
103
  const onRuntime = new Set(held.ok ? list<{ apiName?: string }>(held.data).flatMap((t) => (t.apiName ? [t.apiName] : [])) : [])
92
104
  const current = await call(creds.orgKey, `/v1/blueprint/grants/agent/${encodeURIComponent(agent.id)}`)
93
105
  if (!current.ok) return { failures: [...failures, ` agent/${slug}: its Blueprint access could not be read: ${brief(current)}`], notes }
94
- const has = new Map(list<{ objectTypeApiName: string; permissions?: string[] }>(current.data).map((g) => [g.objectTypeApiName, [...(g.permissions ?? ['read'])].sort()]))
106
+ const has = new Map(list<{ objectTypeApiName: string; permissions?: string[] }>(current.data).map((g) => [g.objectTypeApiName, [...new Set(['read', ...(g.permissions ?? [])])].sort()]))
95
107
 
96
108
  let granted = 0
97
- for (const [apiName, permissions] of wanted) {
109
+ const acting: string[] = []
110
+ const kept: string[] = []
111
+ for (const [apiName, stated] of wanted) {
112
+ const held = has.get(apiName) ?? []
113
+ const extras = keep.has(apiName) ? held.filter((p) => !stated.includes(p)) : []
114
+ const permissions = extras.length ? [...stated, ...extras].sort() : stated
98
115
  const why = input.leftOff(apiName)
99
116
  if (why) {
100
117
  notes.push(` warning: Context and data: ${apiName} is left off here (${why}), so ${slug} can’t read it locally`)
@@ -106,13 +123,17 @@ export async function applyAgentGrants(input: {
106
123
  continue
107
124
  }
108
125
  if (has.get(apiName)?.join(',') !== permissions.join(',')) {
109
- const res = await call(creds.orgKey, '/v1/blueprint/grants/agent', 'POST', { agentId: agent.id, objectTypeApiName: apiName, permissions })
126
+ // A re-grant keeps a held `act` unless it is named as removed, and here the file's list is the whole grant.
127
+ const removePermissions = held.includes('act') && !permissions.includes('act') ? ['act'] : []
128
+ const res = await call(creds.orgKey, '/v1/blueprint/grants/agent', 'POST', { agentId: agent.id, objectTypeApiName: apiName, permissions, ...(removePermissions.length ? { removePermissions } : {}) })
110
129
  if (!res.ok) {
111
130
  failures.push(` objectType/${apiName}: ${brief(res)}`)
112
131
  continue
113
132
  }
114
133
  }
115
134
  granted += 1
135
+ if (extras.length) kept.push(`${extras.join(' and ')} on ${apiName}`)
136
+ if (permissions.includes('act')) acting.push(apiName)
116
137
  }
117
138
  // The file is what the Agent may read: a grant it does not list is withdrawn.
118
139
  for (const apiName of has.keys()) {
@@ -120,6 +141,7 @@ export async function applyAgentGrants(input: {
120
141
  const res = await call(creds.orgKey, `/v1/blueprint/grants/agent/${encodeURIComponent(agent.id)}/${encodeURIComponent(apiName)}`, 'DELETE')
121
142
  if (!res.ok) failures.push(` objectType/${apiName}: could not be revoked: ${brief(res)}`)
122
143
  }
123
- if (granted) notes.push(` Context and data: ${granted} object type${granted === 1 ? '' : 's'}`)
144
+ if (granted) notes.push(` Context and data: ${granted} object type${granted === 1 ? '' : 's'}${acting.length ? `; ${slug} can submit Actions on ${acting.sort().join(', ')} (act)` : ''}`)
145
+ if (kept.length) notes.push(` Context and data: kept what ${slug}'s grants already held where its bindings.json names no permissions: ${kept.join('; ')}`)
124
146
  return { failures, notes }
125
147
  }
@@ -0,0 +1,68 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { unchangedSincePull } from './local-check-inherited'
5
+
6
+ /**
7
+ * Agents whose Plugins are not offered: an Agent with behavior set up
8
+ * (`agents/<slug>/operating.json`) and no computer runs on the lean profile,
9
+ * which leaves out the platform tools and `activate_capability`, so the
10
+ * Plugins its agent.json lists (`config.plugins`: artifacts, image, charts…)
11
+ * are never offered, here and on the platform alike. By design, but nothing
12
+ * in the apply says so: said as a warning on the Agent, with the fix.
13
+ *
14
+ * Only when all three hold: behavior exists, the Agent has no computer
15
+ * (`config.features.computerAvailability` is not `on-demand` or `eager`; an
16
+ * Agent with behavior and no setting of its own gets none), and Plugins are
17
+ * listed.
18
+ */
19
+
20
+ export interface AgentLeanPlugins {
21
+ agent: string
22
+ plugins: string[]
23
+ /** agent.json and operating.json are as the last pull wrote them: the workspace's Agent is the same. */
24
+ inherited: boolean
25
+ }
26
+
27
+ /** The Computer availability values that give an Agent a computer (the service's `AGENT_COMPUTER_AVAILABILITY`, less `none`). */
28
+ const WITH_COMPUTER = new Set(['on-demand', 'eager'])
29
+
30
+ function readJson(path: string): Record<string, unknown> | null {
31
+ try {
32
+ const v = JSON.parse(readFileSync(path, 'utf8')) as unknown
33
+ return v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null
34
+ } catch {
35
+ return null
36
+ }
37
+ }
38
+
39
+ export function agentLeanPlugins(root: string): AgentLeanPlugins[] {
40
+ let slugs: string[] = []
41
+ try {
42
+ slugs = readdirSync(join(root, 'agents')).filter((n) => !n.startsWith('.')).sort()
43
+ } catch {
44
+ return []
45
+ }
46
+ const out: AgentLeanPlugins[] = []
47
+ let unchanged: ReturnType<typeof unchangedSincePull> | null = null
48
+ for (const slug of slugs) {
49
+ const operating = join(root, 'agents', slug, 'operating.json')
50
+ if (!existsSync(operating) || !readJson(operating)) continue
51
+ const agent = readJson(join(root, 'agents', slug, 'agent.json'))
52
+ const config = (agent?.config ?? null) as { plugins?: unknown; features?: { computerAvailability?: unknown } } | null
53
+ if (!config) continue
54
+ const plugins = Array.isArray(config.plugins) ? config.plugins.filter((p): p is string => typeof p === 'string' && !!p) : []
55
+ if (!plugins.length) continue
56
+ if (WITH_COMPUTER.has(String(config.features?.computerAvailability ?? ''))) continue
57
+ unchanged ??= unchangedSincePull(root)
58
+ out.push({ agent: slug, plugins, inherited: [`agents/${slug}/agent.json`, `agents/${slug}/operating.json`].every((f) => unchanged!.files.has(f)) })
59
+ }
60
+ return out
61
+ }
62
+
63
+ const list = (names: string[]) => (names.length <= 2 ? names.join(' and ') : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`)
64
+
65
+ /** One Agent in a line, with its fix: what `local dev`, `local status`, the MCP status and Forge's builds say. */
66
+ export function agentLeanPluginsLine(g: AgentLeanPlugins): string {
67
+ return `agent/${g.agent}: its Plugins (${list(g.plugins)}) aren’t offered once behavior is set up (operating.json) without a computer, so it runs without them, here and on the platform. Fix: give it a computer ("computerAvailability": "on-demand" under config.features in agents/${g.agent}/agent.json), or remove the Plugins from agent.json${g.inherited ? ' (inherited: these files are as the pull wrote them, so the workspace’s Agent is the same)' : ''}`
68
+ }
@@ -0,0 +1,68 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import type { ItemBuild } from './local-applied'
5
+
6
+ /**
7
+ * An Agent whose tools didn't build: its `bindings.json` names Functions or
8
+ * workflows whose apply failed (or that wait on something), so the runtime
9
+ * gives it the rest and it runs without those. That is not an adaptation
10
+ * for local, it is a gap: said as a warning on the Agent's row, naming what
11
+ * failed, on every surface that lists it (`local status`, Forge's home and
12
+ * Activity, Studio's Agents page).
13
+ */
14
+
15
+ export interface MissingTools {
16
+ agent: string
17
+ /** `function/dispatch-request`, `workflow/intake`: what it names that isn't on the runtime. */
18
+ refs: string[]
19
+ }
20
+
21
+ /** The tools an Agent's bindings name and enable: Functions and workflows, by reference. */
22
+ function boundTools(root: string, slug: string): string[] {
23
+ try {
24
+ const doc = JSON.parse(readFileSync(join(root, 'agents', slug, 'bindings.json'), 'utf8')) as { functions?: unknown; workflows?: unknown }
25
+ const refs: string[] = []
26
+ for (const list of [doc.functions, doc.workflows]) {
27
+ if (!Array.isArray(list)) continue
28
+ for (const b of list) {
29
+ if (!b || typeof b !== 'object' || (b as { enabled?: unknown }).enabled === false) continue
30
+ const ref = (b as { function?: unknown; workflow?: unknown }).function ?? (b as { workflow?: unknown }).workflow
31
+ if (typeof ref === 'string' && /^(function|workflow)\/[\w.-]+$/.test(ref)) refs.push(ref)
32
+ }
33
+ }
34
+ return refs
35
+ } catch {
36
+ return []
37
+ }
38
+ }
39
+
40
+ /** Each Agent whose bound Functions or workflows failed to apply (or wait on what they need), by its slug. */
41
+ export function agentMissingTools(root: string, agents: string[], builds: Readonly<Record<string, ItemBuild>>): MissingTools[] {
42
+ const out: MissingTools[] = []
43
+ for (const agent of agents) {
44
+ const refs = [...new Set(boundTools(root, agent))].filter((ref) => {
45
+ const state = builds[ref]?.state
46
+ return state === 'failed' || state === 'blocked'
47
+ })
48
+ if (refs.length) out.push({ agent, refs: refs.sort() })
49
+ }
50
+ return out
51
+ }
52
+
53
+ const list = (names: string[]) => (names.length <= 1 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} and ${names.at(-1)}`)
54
+
55
+ /** The warning on the Agent's row: "agent/nova: missing tools: … failed to apply, … Fix: …". */
56
+ export function missingToolsLine(m: MissingTools): string {
57
+ const names = m.refs.map((r) => r.slice(r.indexOf('/') + 1))
58
+ const one = names.length === 1
59
+ const kinds = new Set(m.refs.map((r) => r.slice(0, r.indexOf('/'))))
60
+ const noun = kinds.size === 1 ? (kinds.has('function') ? 'Function' : 'workflow') : 'tool'
61
+ const what = one ? noun : `${noun}s`
62
+ return `agent/${m.agent}: missing tools: the ${what} ${list(names)} failed to apply, so it runs without ${one ? 'it' : 'them'} here. Fix: fix ${one ? `that ${noun}` : `those ${what}`} (${one ? 'its' : 'each one’s'} row says why); the Agent gets ${one ? 'it' : 'them'} on the next apply after ${one ? 'it builds' : 'they build'}`
63
+ }
64
+
65
+ /** An adaptation the tools' apply recorded for the same gap ("nova goes without the Function x here until it builds"): said as the warning instead. */
66
+ export function isMissingToolAdaptation(line: string): boolean {
67
+ return /\bgoes without the (Function|workflow) [\w.-]+ here until it builds\b/i.test(line)
68
+ }
@@ -69,3 +69,30 @@ export async function syncAgentName(input: { slug: string; identity: AgentIdenti
69
69
  return ` warning: ${input.slug}'s name could not be set: ${(err as Error).message}`
70
70
  }
71
71
  }
72
+
73
+ /**
74
+ * Whether the runtime holds a draft of this Agent: true or false, or null when
75
+ * it could not be told (no such Agent yet, the runtime did not answer). Its
76
+ * files may be unchanged since they were applied while the draft is gone (a
77
+ * publish turns the draft into the live version), and the Agent's test cases
78
+ * and Studio's Draft chat run the draft, so `local dev` stages it again.
79
+ */
80
+ export async function hasAgentDraft(input: { slug: string; serviceUrl: string; creds: LocalCredentials; fetch?: typeof fetch }): Promise<boolean | null> {
81
+ const doFetch = input.fetch ?? fetch
82
+ const headers = { authorization: `Bearer ${input.creds.workspaceKey ?? ''}` }
83
+ try {
84
+ const found = await doFetch(new URL(`/v1/config/agents/${encodeURIComponent(input.slug)}`, input.serviceUrl), { headers })
85
+ if (!found.ok) return null
86
+ const row = (await found.json()) as { data?: { id?: string }; id?: string }
87
+ const id = row.data?.id ?? row.id
88
+ if (!id) return null
89
+ const res = await doFetch(new URL(`/v1/config/agents/${encodeURIComponent(id)}/draft-v2`, input.serviceUrl), { headers })
90
+ if (!res.ok) return null
91
+ // Wrapped twice: `ok({ data: … })` around the draft, which is null when nothing is staged.
92
+ const body = (await res.json()) as { data?: { data?: unknown } | null } | null
93
+ const inner = body?.data && typeof body.data === 'object' && 'data' in body.data ? body.data.data : body?.data
94
+ return inner !== null && inner !== undefined
95
+ } catch {
96
+ return null
97
+ }
98
+ }
@@ -0,0 +1,191 @@
1
+ import { existsSync, readFileSync, renameSync, rmSync } from 'node:fs'
2
+ import { basename, dirname, join } from 'node:path'
3
+
4
+ import { CliError } from '../errors'
5
+ import { healProject } from '../heal'
6
+ import { writeState } from '../project'
7
+ import { unpackTo } from '../untar'
8
+ import { appPath } from './local-apps'
9
+ import { vendoredShadows } from './local-app-sdk'
10
+ import { appPulledAsIs, recordPulledFiles } from './local-app-pulled'
11
+ import type { SyncClient } from './local-sync-client'
12
+
13
+ /**
14
+ * A pulled Custom App's code, brought beside its file: the source of the
15
+ * version the workspace deployed, unpacked into `apps/<slug>/`, as
16
+ * `frontera app pull` does for a folder of its own. `local dev` then builds
17
+ * it like any App folder.
18
+ *
19
+ * Never over a folder that is there: an `apps/<slug>/` is the project's own
20
+ * code, edited or not. No `.env.local` is written: a dev host given the
21
+ * workspace's credential would read the workspace's records, and `app_dev`
22
+ * (or `frontera app dev`) points the App at the local runtime when it runs.
23
+ */
24
+ /**
25
+ * Each `@frontera-sdk` package an App's package.json takes from the copy under
26
+ * src/frontera/ while its bun.lock installed a published release
27
+ * (`vendoredShadows`): a frozen install refuses that App until one of the two
28
+ * files changes.
29
+ */
30
+ export type SdkShadow = { pkg: string; version: string }
31
+
32
+ export type AppCodePulled =
33
+ | { slug: string; version: string; files: number; repairs: string[]; shadows?: SdkShadow[]; replaced?: SdkShadow[] }
34
+ | { slug: string; version: string; kept: true; shadows?: SdkShadow[] }
35
+ | { slug: string; version: string; missing: true }
36
+ | { slug: string; version: string; failed: string }
37
+
38
+ /** The version an App's file names, else the pulled item's (`v1.4.0`). */
39
+ export function appCodeVersion(root: string, slug: string, itemVersion?: string | null): string | null {
40
+ try {
41
+ const doc = JSON.parse(readFileSync(join(root, appPath(slug)), 'utf8')) as { version?: unknown }
42
+ if (typeof doc.version === 'string' && doc.version) return doc.version
43
+ } catch {
44
+ // No file: the item's version, below.
45
+ }
46
+ return itemVersion ? itemVersion.replace(/^v/, '') : null
47
+ }
48
+
49
+ /**
50
+ * The SDK packages an App folder's package.json takes from the copy under
51
+ * src/frontera/ while its bun.lock installed a published release, when the
52
+ * folder is otherwise all as an earlier pull left it ([] when it isn't, or
53
+ * nothing disagrees). That is an older CLI's repair, not the person's work:
54
+ * before pull run 10 the pull vendored the CLI's SDK copy over the published
55
+ * range the workspace's source declared and left its bun.lock as it was, so
56
+ * the two disagree and a frozen install refuses the folder. A pull brings the
57
+ * deployed source again in its place. Read it before the pull writes its
58
+ * frontera.lock entry: an older pull's folder is told from that entry
59
+ * (`appPulledAsIs`).
60
+ */
61
+ export function earlierPullShadows(root: string, slug: string): SdkShadow[] {
62
+ const dir = join(root, 'apps', slug)
63
+ if (!existsSync(dir)) return []
64
+ const shadows = vendoredShadows(dir)
65
+ return shadows.length && appPulledAsIs(root, join('apps', slug)) === true ? shadows : []
66
+ }
67
+
68
+ /** Unpack an App's source into `dir`, record its lineage, repair it, and record it as pulled. */
69
+ function unpackPulled(dir: string, bytes: Uint8Array, app: { id: string; version: string }): { files: number; repairs: string[] } {
70
+ const files = unpackTo(dir, bytes)
71
+ // Its lineage: the workspace's App and the version this code is. Not a deployment from here
72
+ // (`parentVersion`), so status says it was pulled, not deployed; a deploy still names it as its parent.
73
+ writeState(dir, { pulledFrom: app.id, pulledVersion: app.version })
74
+ const { repairs } = healProject(dir)
75
+ // Its source as pulled (repairs included): `local check` counts a failure while it is still so as inherited.
76
+ recordPulledFiles(dir)
77
+ return { files, repairs }
78
+ }
79
+
80
+ /**
81
+ * `replaceEarlierPull`: the folder is there, as an earlier pull left it, with
82
+ * its package.json and bun.lock disagreeing (`earlierPullShadows`, read by the
83
+ * caller before the pull's frontera.lock entry changes). Its source is
84
+ * brought again and takes the folder's place, so the folder is the deployed
85
+ * code with the package.json and bun.lock the workspace deployed, which
86
+ * agree; the folder's own `.frontera/` (its state and render evidence) is
87
+ * kept. Without the flag a folder that is there is always kept.
88
+ */
89
+ export async function pullAppCode(root: string, client: SyncClient, app: { slug: string; id: string; version: string; replaceEarlierPull?: boolean }): Promise<AppCodePulled> {
90
+ const dir = join(root, 'apps', app.slug)
91
+ const base = { slug: app.slug, version: app.version }
92
+ const replacing = existsSync(dir) ? (app.replaceEarlierPull ? vendoredShadows(dir) : []) : null
93
+ // The project's own code, kept; but a package.json and bun.lock that disagree (an earlier pull, before the
94
+ // pull went back to the release its bun.lock installed) are said now, with the fix, not at the first render.
95
+ if (replacing && !replacing.length) {
96
+ const shadows = vendoredShadows(dir)
97
+ return { ...base, kept: true, ...(shadows.length ? { shadows } : {}) }
98
+ }
99
+ let bytes: Uint8Array
100
+ try {
101
+ bytes = await client.getBytes(`/v1/platform-apps/${encodeURIComponent(app.id)}/versions/${encodeURIComponent(app.version)}/source`)
102
+ } catch (err) {
103
+ // The earlier pull's folder stays as it is, said with the fix that still applies.
104
+ if (replacing) return { ...base, kept: true, shadows: replacing }
105
+ if (err instanceof CliError && err.code === 'NOT_FOUND') return { ...base, missing: true }
106
+ return { ...base, failed: (err as Error).message.slice(0, 200) }
107
+ }
108
+ // Into a folder of its own first: the earlier pull's folder goes only once the source is unpacked and repaired.
109
+ const target = replacing ? join(root, 'apps', `.${app.slug}.pulling-${process.pid}-${Date.now()}`) : dir
110
+ try {
111
+ const { files, repairs } = unpackPulled(target, bytes, app)
112
+ if (replacing) {
113
+ // The folder's state (its local deployment, its render evidence) stays with it; the pull's lineage and record win.
114
+ const own = join(dir, '.frontera')
115
+ const pulledState = readFileSync(join(target, '.frontera', 'state.json'), 'utf8')
116
+ rmSync(join(target, '.frontera'), { recursive: true, force: true })
117
+ if (existsSync(own)) renameSync(own, join(target, '.frontera'))
118
+ writeState(target, JSON.parse(pulledState) as Parameters<typeof writeState>[1])
119
+ rmSync(dir, { recursive: true, force: true })
120
+ renameSync(target, dir)
121
+ }
122
+ // The repair prefers what the workspace stored: its bun.lock's published release over a copy package.json
123
+ // names. One it couldn't make leaves them disagreeing, said with the command that settles it.
124
+ const shadows = vendoredShadows(dir)
125
+ return { ...base, files, repairs, ...(shadows.length ? { shadows } : {}), ...(replacing ? { replaced: replacing } : {}) }
126
+ } catch (err) {
127
+ // A half-unpacked folder would read as the project's own code next time: it goes.
128
+ rmSync(target, { recursive: true, force: true })
129
+ if (replacing) return { ...base, kept: true, shadows: vendoredShadows(dir) }
130
+ return { ...base, failed: `its source could not be unpacked (${(err as Error).message.slice(0, 160)})` }
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Why an App folder won't install as it is, and the command that settles it:
136
+ * its package.json names the SDK copy under src/frontera/ where its bun.lock
137
+ * installed a published release, the one its code was written against.
138
+ */
139
+ export function sdkShadowLine(dir: string, shadows: readonly SdkShadow[], opts: { repull?: string } = {}): string {
140
+ const pkgs = shadows.map((s) => s.pkg).join(' and ')
141
+ const released = shadows.map((s) => `${s.pkg} ${s.version}`).join(' and ')
142
+ const why = `its package.json names the SDK copy under src/frontera/ for ${pkgs}, where its bun.lock installed the published ${released}, so an install from its lockfile refuses it`
143
+ // An earlier pull's folder, unchanged since: pulling it again brings the deployed source, whose two files agree.
144
+ if (opts.repull) return `${why}. An earlier pull left it so, and nothing in it has changed since: \`frontera local sync pull ${opts.repull}\` brings the deployed source again in its place, or \`frontera app sdk use-local --npm\` in ${dir} goes back to that release where it is, deleting the copy (review and commit either change)`
145
+ return `${why}: run \`frontera app sdk use-local --npm\` in ${dir} to go back to that release (review and commit the change)`
146
+ }
147
+
148
+ /**
149
+ * The pull that brings an App folder's deployed source back, when it is an
150
+ * earlier pull's folder still as that pull left it (`apps/<slug>/` of a
151
+ * project, `earlierPullShadows`): `app/<slug>`, else null.
152
+ */
153
+ export function repullRefFor(appRoot: string): string | null {
154
+ const parent = dirname(appRoot)
155
+ if (basename(parent) !== 'apps') return null
156
+ const slug = basename(appRoot)
157
+ return earlierPullShadows(dirname(parent), slug).length ? `app/${slug}` : null
158
+ }
159
+
160
+ /**
161
+ * What an install from the App's own SDK copy did (`bun install --no-save`),
162
+ * for `app dev`, `app_render`/`app_dev` and `local check`: installed as its
163
+ * package.json says, the SDK from its copy under src/frontera/; bun.lock kept
164
+ * as pulled (it still records the published release) and package.json
165
+ * unchanged; and what brings the deployed source back.
166
+ */
167
+ export function copyInstallLine(shadows: readonly SdkShadow[], summary: string, opts: { repull?: string | null } = {}): string {
168
+ const pkgs = shadows.map((s) => s.pkg).join(' and ')
169
+ const released = shadows.map((s) => `${s.pkg} ${s.version}`).join(' and ')
170
+ const back = opts.repull
171
+ ? `An earlier pull left package.json and bun.lock disagreeing: \`frontera local sync pull ${opts.repull}\` brings the deployed source back`
172
+ : '`frontera app sdk use-local --npm` in the App goes back to that release (it rewrites package.json and deletes the copy)'
173
+ return `installed its packages from the App’s own SDK copy under src/frontera/ for ${pkgs}, as its package.json says, with \`bun install --no-save\` (${summary}); bun.lock was kept as pulled (it records the published ${released}) and package.json is unchanged. ${back}`
174
+ }
175
+
176
+ /** One line per App, for the pull's text. */
177
+ export function appCodeLine(p: AppCodePulled): string {
178
+ const shadows = 'shadows' in p ? p.shadows : undefined
179
+ const shadow = shadows?.length ? `; ${sdkShadowLine(`apps/${p.slug}`, shadows)}` : ''
180
+ if ('files' in p) {
181
+ // A repair back to the bun.lock's release means the workspace's source disagreed with itself: said as that.
182
+ const restored = p.repairs.some((r) => r.startsWith('restored '))
183
+ const instead = p.replaced?.length
184
+ ? `, in place of the folder an earlier pull left unchanged, whose package.json named the SDK copy under src/frontera/ for ${p.replaced.map((s) => s.pkg).join(' and ')} where its bun.lock installed the published ${p.replaced.map((s) => `${s.pkg} ${s.version}`).join(' and ')} (that copy is gone; its package.json and bun.lock are the workspace’s again; review the change before you commit)`
185
+ : ''
186
+ return ` ${p.slug}: version ${p.version} into apps/${p.slug}/ (${p.files} files${p.repairs.length ? `; ${restored ? 'the workspace’s source has package.json and bun.lock disagreeing, so it was repaired to what its bun.lock installed' : 'repaired legacy packaging'}: ${p.repairs.join('; ')}` : ''})${instead}${shadow}`
187
+ }
188
+ if ('kept' in p) return ` ${p.slug}: apps/${p.slug}/ is already here, so it was kept as it is${shadow}`
189
+ if ('missing' in p) return ` ${p.slug}: the platform holds no source for version ${p.version}; apps/${p.slug}.json is its description only`
190
+ return ` ${p.slug}: its code could not be brought (${p.failed}); \`frontera local sync pull app/${p.slug}\` tries again`
191
+ }
@@ -0,0 +1,186 @@
1
+ import { spawnSync } from 'node:child_process'
2
+ import { existsSync, readdirSync, readFileSync, statSync, utimesSync, writeFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ /**
6
+ * What an App's dev server writes into its folder, put back.
7
+ *
8
+ * `next dev` rewrites `next-env.d.ts` when it starts (Next 16 points it at
9
+ * `.next/dev/types/routes.d.ts` where `next build` wrote `.next/types/…`) and
10
+ * may reconfigure `tsconfig.json`. A pulled App tracks both, so a preview
11
+ * would leave them modified. `frontera app dev` (which `app_dev`,
12
+ * `app_render` and Forge start) puts each back once the server is ready, and
13
+ * again when it stops.
14
+ *
15
+ * Only files that are the project's, not the person's work in progress: a
16
+ * file git tracks, or one the pull recorded (`pulledFiles` in the App's
17
+ * `.frontera/state.json`), that the dev server changed. A change made while
18
+ * the server runs is put back only when it is what the dev server wrote (the
19
+ * content it wrote at startup, or for `next-env.d.ts` and build info, their
20
+ * generated form); anything else is left as it is, and named.
21
+ */
22
+
23
+ /** A file as it was: its bytes and its times, for putting it back as if nothing had touched it. */
24
+ export interface FileAsWas {
25
+ bytes: Buffer
26
+ atimeMs: number
27
+ mtimeMs: number
28
+ }
29
+
30
+ /** A file's bytes and times now; null when it can't be read. */
31
+ export function fileAsWas(path: string): FileAsWas | null {
32
+ try {
33
+ const stat = statSync(path)
34
+ return { bytes: readFileSync(path), atimeMs: stat.atimeMs, mtimeMs: stat.mtimeMs }
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Put a file back as it was, its modification time too. A file the tooling
42
+ * rewrote for a run and put back byte for byte is no change, and whether a
43
+ * render is fresh (`render-evidence.ts`) is read from the source's mtimes: a
44
+ * put-back with a new mtime made the last render stale although nothing in
45
+ * the App had changed. Returns whether the bytes had to be written (a file
46
+ * the tool rewrote with the same bytes only gets its time back).
47
+ */
48
+ export function putBackAsWas(path: string, was: FileAsWas): boolean {
49
+ const now = existsSync(path) ? readFileSync(path) : null
50
+ const rewrite = !now || !now.equals(was.bytes)
51
+ if (rewrite) writeFileSync(path, was.bytes)
52
+ try {
53
+ if (rewrite || statSync(path).mtimeMs !== was.mtimeMs) utimesSync(path, was.atimeMs / 1000, was.mtimeMs / 1000)
54
+ } catch {
55
+ // The bytes are back; a time that can't be set only makes a render look stale.
56
+ }
57
+ return rewrite
58
+ }
59
+
60
+ /** Written by a framework's dev server or a typecheck into the App folder's top. */
61
+ const isDevWritten = (name: string) => name === 'next-env.d.ts' || name === 'tsconfig.json' || name.endsWith('.tsbuildinfo')
62
+
63
+ /** `next-env.d.ts` as Next writes it: references, an import of its route types, comments. Never a person's code. */
64
+ export function isNextGenerated(name: string, text: string): boolean {
65
+ if (name.endsWith('.tsbuildinfo')) return true
66
+ if (name !== 'next-env.d.ts') return false
67
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean)
68
+ return lines.length > 0 && lines.every((l) => /^\/\/\/ <reference (types|path)=".*" \/>$/.test(l) || /^import ["']\.\/\.next\/[^"']+["'];?$/.test(l) || l.startsWith('//'))
69
+ }
70
+
71
+ /** The names in `names` that git tracks in `dir`; none outside a repository. */
72
+ function gitTracked(dir: string, names: string[]): Set<string> {
73
+ if (!names.length) return new Set()
74
+ const res = spawnSync('git', ['ls-files', '-z', '--', ...names], { cwd: dir, encoding: 'utf8' })
75
+ if (res.status !== 0 || res.error) return new Set()
76
+ return new Set(res.stdout.split('\0').filter(Boolean))
77
+ }
78
+
79
+ /** The files the pull recorded in the App folder (`pulledFiles`), by path in the folder. */
80
+ function pulledRecorded(dir: string): Set<string> {
81
+ try {
82
+ const state = JSON.parse(readFileSync(join(dir, '.frontera', 'state.json'), 'utf8')) as { pulledFiles?: Record<string, string> }
83
+ return new Set(Object.keys(state.pulledFiles ?? {}))
84
+ } catch {
85
+ return new Set()
86
+ }
87
+ }
88
+
89
+ /** The App folder's files a dev server may write that are the project's: tracked by git, or recorded by the pull. */
90
+ export function trackedDevWrittenFiles(dir: string): string[] {
91
+ if (!existsSync(dir)) return []
92
+ const present = readdirSync(dir).filter(isDevWritten).sort()
93
+ const tracked = gitTracked(dir, present)
94
+ const pulled = pulledRecorded(dir)
95
+ return present.filter((name) => tracked.has(name) || pulled.has(name))
96
+ }
97
+
98
+ export interface DevWrittenFiles {
99
+ /** The files watched, as they were before the dev server started. */
100
+ readonly watched: string[]
101
+ /** Once the server is ready: each it changed since it started, put back. The names put back. */
102
+ afterReady(): string[]
103
+ /** When it has stopped: each it changed again, put back; the names put back, and those changed otherwise (left). */
104
+ atStop(): { putBack: string[]; left: string[] }
105
+ }
106
+
107
+ /** Keep the App folder's tracked files a dev server writes as they were before it started (call before starting it). */
108
+ export function keepDevWrittenFiles(dir: string): DevWrittenFiles {
109
+ const before = new Map<string, FileAsWas>()
110
+ for (const name of trackedDevWrittenFiles(dir)) {
111
+ const was = fileAsWas(join(dir, name))
112
+ // Unreadable: left as the server leaves it.
113
+ if (was) before.set(name, was)
114
+ }
115
+ // What the server wrote at startup: the same content again later is its doing too.
116
+ const wrote = new Map<string, Buffer>()
117
+ const read = (name: string) => {
118
+ try {
119
+ return readFileSync(join(dir, name))
120
+ } catch {
121
+ return null
122
+ }
123
+ }
124
+ const putBack = (name: string): boolean => {
125
+ try {
126
+ putBackAsWas(join(dir, name), before.get(name)!)
127
+ return true
128
+ } catch {
129
+ return false
130
+ }
131
+ }
132
+ // Rewritten with the same bytes: only its time goes back, and nothing is said.
133
+ const retime = (name: string) => {
134
+ try {
135
+ putBackAsWas(join(dir, name), before.get(name)!)
136
+ } catch {
137
+ // Only a render's freshness reads the time.
138
+ }
139
+ }
140
+ return {
141
+ watched: [...before.keys()],
142
+ afterReady() {
143
+ const back: string[] = []
144
+ for (const [name, was] of before) {
145
+ const now = read(name)
146
+ if (!now) continue
147
+ if (now.equals(was.bytes)) {
148
+ retime(name)
149
+ continue
150
+ }
151
+ wrote.set(name, now)
152
+ if (putBack(name)) back.push(name)
153
+ }
154
+ return back
155
+ },
156
+ atStop() {
157
+ const back: string[] = []
158
+ const left: string[] = []
159
+ for (const [name, was] of before) {
160
+ const now = read(name)
161
+ if (!now) continue
162
+ if (now.equals(was.bytes)) {
163
+ retime(name)
164
+ continue
165
+ }
166
+ const theServers = wrote.get(name)?.equals(now) || isNextGenerated(name, now.toString('utf8'))
167
+ if (theServers && putBack(name)) back.push(name)
168
+ else left.push(name)
169
+ }
170
+ return { putBack: back, left }
171
+ },
172
+ }
173
+ }
174
+
175
+ /** One line for what was put back, for the dev server's output (`[dev] …`), or null when nothing was. */
176
+ export function putBackLine(names: string[], when: 'started' | 'stopped'): string | null {
177
+ if (!names.length) return null
178
+ const files = names.join(' and ')
179
+ return `put back ${files} as tracked: the dev server rewrote ${names.length === 1 ? 'it' : 'them'} ${when === 'started' ? 'when it started' : 'while it ran'}`
180
+ }
181
+
182
+ /** One line for tracked files changed while the server ran that weren't its doing, or null. */
183
+ export function leftLine(names: string[]): string | null {
184
+ if (!names.length) return null
185
+ return `left ${names.join(' and ')} as ${names.length === 1 ? 'it is' : 'they are'}: changed while the dev server ran, not to what it writes (an edit of yours is never put back)`
186
+ }