@frontera-sdk/cli 1.52.39 → 1.52.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +9 -0
  2. package/package.json +6 -4
  3. package/src/adopt.ts +6 -2
  4. package/src/api/apps-api.ts +86 -4
  5. package/src/api/blueprint-authoring-api.ts +35 -11
  6. package/src/api/dataset-api.ts +29 -0
  7. package/src/api/governed-action-api.ts +90 -2
  8. package/src/api/platform-api.ts +87 -2
  9. package/src/api/workflow-api.ts +33 -2
  10. package/src/app-contract.ts +156 -0
  11. package/src/app-lock.ts +125 -0
  12. package/src/args.ts +14 -3
  13. package/src/auth-verify.ts +10 -0
  14. package/src/automation-template.ts +12 -1
  15. package/src/blueprint/action-rules.ts +516 -0
  16. package/src/blueprint/action-semantic-fit.ts +205 -0
  17. package/src/blueprint/compile.ts +51 -2
  18. package/src/blueprint/diff.ts +34 -2
  19. package/src/blueprint/metric-query.ts +215 -0
  20. package/src/blueprint/metric-rules.ts +122 -0
  21. package/src/blueprint/object-sets.ts +116 -0
  22. package/src/blueprint/ontology-edit-plan.ts +49 -5
  23. package/src/blueprint/projection.ts +24 -1
  24. package/src/blueprint/scaffold.ts +84 -42
  25. package/src/commands/action/available.ts +1 -1
  26. package/src/commands/action/criteria-refusal.ts +84 -0
  27. package/src/commands/action/decide.ts +5 -2
  28. package/src/commands/action/deploy.ts +13 -4
  29. package/src/commands/action/list.ts +134 -19
  30. package/src/commands/action/requests.ts +34 -6
  31. package/src/commands/action/submit.ts +28 -13
  32. package/src/commands/agent/draft-changes.ts +1 -1
  33. package/src/commands/agent/index-commands.ts +69 -7
  34. package/src/commands/agent/resolve.ts +2 -0
  35. package/src/commands/app/checks.ts +44 -0
  36. package/src/commands/app/deploy.ts +243 -60
  37. package/src/commands/app/deployments.ts +97 -0
  38. package/src/commands/app/dev.ts +103 -11
  39. package/src/commands/app/init.ts +36 -3
  40. package/src/commands/app/promote.ts +45 -19
  41. package/src/commands/app/pull.ts +3 -1
  42. package/src/commands/app/rollback.ts +46 -0
  43. package/src/commands/app/sdk.ts +54 -8
  44. package/src/commands/app/unpin.ts +27 -0
  45. package/src/commands/app/versions.ts +1 -1
  46. package/src/commands/automation/bindings.ts +4 -2
  47. package/src/commands/automation/build-entry.ts +4 -1
  48. package/src/commands/automation/index-commands.ts +3 -1
  49. package/src/commands/automation/pull.ts +6 -1
  50. package/src/commands/automation/run.ts +34 -9
  51. package/src/commands/blueprint/bind.ts +2 -1
  52. package/src/commands/blueprint/declarative.ts +332 -32
  53. package/src/commands/blueprint/editable.ts +3 -3
  54. package/src/commands/blueprint/get.ts +6 -2
  55. package/src/commands/blueprint/grants.ts +64 -10
  56. package/src/commands/blueprint/metric.ts +108 -0
  57. package/src/commands/blueprint/object-sets.ts +71 -0
  58. package/src/commands/blueprint/query.ts +155 -31
  59. package/src/commands/dataset/index-commands.ts +156 -7
  60. package/src/commands/knowledge/index-commands.ts +122 -20
  61. package/src/commands/local/check.ts +657 -44
  62. package/src/commands/local/data.ts +33 -12
  63. package/src/commands/local/dev.ts +876 -89
  64. package/src/commands/local/docs-mcp.ts +10 -18
  65. package/src/commands/local/index-commands.ts +300 -63
  66. package/src/commands/local/init.ts +18 -5
  67. package/src/commands/local/key.ts +0 -3
  68. package/src/commands/local/providers.ts +0 -7
  69. package/src/commands/local/remove.ts +116 -0
  70. package/src/commands/local/schema.ts +3 -4
  71. package/src/commands/local/sdk-refresh.ts +59 -0
  72. package/src/commands/local/snapshot.ts +100 -0
  73. package/src/commands/local/sync-request.ts +321 -14
  74. package/src/commands/local/sync.ts +103 -6
  75. package/src/commands/mcp/serve.ts +34 -0
  76. package/src/commands/registry.ts +54 -10
  77. package/src/commands/types.ts +29 -0
  78. package/src/commands/workflow/archive.ts +46 -0
  79. package/src/commands/workflow/get.ts +2 -1
  80. package/src/commands/workflow/index-commands.ts +5 -0
  81. package/src/commands/workflow/push.ts +49 -2
  82. package/src/commands/workflow/run.ts +65 -7
  83. package/src/commands/workflow/runs.ts +9 -6
  84. package/src/commands/workflow/webhook-test.ts +166 -0
  85. package/src/config.ts +43 -6
  86. package/src/dev-broker.ts +52 -0
  87. package/src/exit.ts +103 -4
  88. package/src/flag-help.ts +77 -17
  89. package/src/forge/claude-commands.ts +11 -6
  90. package/src/forge/claude-skills.ts +4 -4
  91. package/src/forge/touches.ts +15 -12
  92. package/src/function-outside-imports.ts +89 -0
  93. package/src/harness.ts +42 -24
  94. package/src/heal.ts +113 -2
  95. package/src/kit.ts +1 -1
  96. package/src/local/fix-words.ts +12 -0
  97. package/src/local/function-bundle-recovery.ts +775 -0
  98. package/src/local/local-actions.ts +103 -10
  99. package/src/local/local-agent-action-grants.ts +210 -0
  100. package/src/local/local-agent-checks.ts +232 -0
  101. package/src/local/local-agent-grants.ts +29 -7
  102. package/src/local/local-agent-lean-plugins.ts +68 -0
  103. package/src/local/local-agent-missing-tools.ts +68 -0
  104. package/src/local/local-agent-name.ts +27 -0
  105. package/src/local/local-app-code.ts +191 -0
  106. package/src/local/local-app-dev-files.ts +186 -0
  107. package/src/local/local-app-dev-session.ts +88 -0
  108. package/src/local/local-app-lock-install.ts +114 -0
  109. package/src/local/local-app-pulled.ts +167 -0
  110. package/src/local/local-app-restore.ts +111 -0
  111. package/src/local/local-app-runtime.ts +68 -0
  112. package/src/local/local-app-sdk-paths.ts +149 -0
  113. package/src/local/local-app-sdk.ts +509 -0
  114. package/src/local/local-applied.ts +212 -22
  115. package/src/local/local-apply-history.ts +84 -0
  116. package/src/local/local-apply-summary.ts +86 -0
  117. package/src/local/local-apply-watchdog.ts +95 -0
  118. package/src/local/local-apps.ts +181 -8
  119. package/src/local/local-blueprint-errors.ts +198 -0
  120. package/src/local/local-blueprint-outline.ts +31 -12
  121. package/src/local/local-blueprint-schemas.ts +582 -0
  122. package/src/local/local-build-inputs.ts +48 -0
  123. package/src/local/local-build-summary.ts +495 -28
  124. package/src/local/local-case-evidence.ts +120 -0
  125. package/src/local/local-case-not-run.ts +153 -0
  126. package/src/local/local-chat-apps.ts +93 -16
  127. package/src/local/local-check-inherited.ts +83 -0
  128. package/src/local/local-cities.ts +89 -0
  129. package/src/local/local-component-health.ts +119 -0
  130. package/src/local/local-control.ts +34 -7
  131. package/src/local/local-db-snapshot.ts +348 -0
  132. package/src/local/local-dev-references.ts +347 -36
  133. package/src/local/local-dev-sync.ts +141 -12
  134. package/src/local/local-docker.ts +35 -1
  135. package/src/local/local-docs-search.ts +1 -1
  136. package/src/local/local-docs.ts +281 -2
  137. package/src/local/local-empty-workflows.ts +92 -0
  138. package/src/local/local-env.ts +16 -1
  139. package/src/local/local-file-checks.ts +104 -0
  140. package/src/local/local-function-ids.ts +141 -0
  141. package/src/local/local-function-recovery.ts +141 -0
  142. package/src/local/local-function-state.ts +87 -0
  143. package/src/local/local-keys.ts +7 -5
  144. package/src/local/local-knowledge-documents.ts +1 -1
  145. package/src/local/local-knowledge-ingestion.ts +225 -0
  146. package/src/local/local-knowledge-uploads.ts +217 -0
  147. package/src/local/local-lessons.ts +1 -1
  148. package/src/local/local-library.ts +1 -1
  149. package/src/local/local-media-placeholders.ts +182 -0
  150. package/src/local/local-media-sets.ts +90 -10
  151. package/src/local/local-model-parameters.ts +89 -0
  152. package/src/local/local-object-sets.ts +18 -6
  153. package/src/local/local-operating.ts +35 -1
  154. package/src/local/local-org-settings.ts +206 -8
  155. package/src/local/local-orphans.ts +321 -0
  156. package/src/local/local-personas.ts +296 -0
  157. package/src/local/local-portability.ts +143 -18
  158. package/src/local/local-ports.ts +99 -7
  159. package/src/local/local-project-notices.ts +234 -0
  160. package/src/local/local-project-secrets.ts +133 -0
  161. package/src/local/local-project.ts +12 -0
  162. package/src/local/local-provider-refusal.ts +66 -0
  163. package/src/local/local-request-evidence.ts +112 -0
  164. package/src/local/local-runtime-health.ts +123 -0
  165. package/src/local/local-runtime-history.ts +50 -0
  166. package/src/local/local-runtime-target.ts +42 -0
  167. package/src/local/local-sample-data.ts +141 -0
  168. package/src/local/local-sample-remake.ts +138 -0
  169. package/src/local/local-scaffold.ts +336 -53
  170. package/src/local/local-schema-check.ts +148 -0
  171. package/src/local/local-schemas.ts +742 -25
  172. package/src/local/local-secret-carry.ts +107 -0
  173. package/src/local/local-secrets.ts +41 -11
  174. package/src/local/local-seed.ts +102 -26
  175. package/src/local/local-stand-ins.ts +3 -3
  176. package/src/local/local-state.ts +43 -2
  177. package/src/local/local-studio-server.ts +170 -7
  178. package/src/local/local-supervisor.ts +44 -6
  179. package/src/local/local-sync-merge.ts +1 -1
  180. package/src/local/local-sync-names.ts +2 -0
  181. package/src/local/local-sync-plan.ts +19 -8
  182. package/src/local/local-sync-push.ts +15 -12
  183. package/src/local/local-sync-refs.ts +49 -3
  184. package/src/local/local-sync-remote.ts +349 -38
  185. package/src/local/local-sync-snapshot.ts +18 -13
  186. package/src/local/local-synthetic-data.ts +2116 -99
  187. package/src/local/local-test-baseline.ts +113 -0
  188. package/src/local/local-test-cases.ts +76 -1
  189. package/src/local/local-test-runner.ts +582 -91
  190. package/src/local/local-test-runs.ts +13 -5
  191. package/src/local/local-vendored-sdk.ts +183 -0
  192. package/src/local/local-workflow-state.ts +16 -3
  193. package/src/local/local-workflow-tests.ts +482 -0
  194. package/src/local/local-workspace-grants.ts +81 -0
  195. package/src/main.ts +50 -9
  196. package/src/mcp/a11y-scan.ts +55 -0
  197. package/src/mcp/browser/ax-snapshot.ts +181 -0
  198. package/src/mcp/browser/credentials.ts +78 -0
  199. package/src/mcp/browser/frames.ts +336 -0
  200. package/src/mcp/browser/headless-session.ts +413 -0
  201. package/src/mcp/browser/headless-tab.ts +575 -0
  202. package/src/mcp/browser/keys.ts +70 -0
  203. package/src/mcp/browser/page-scripts.ts +129 -0
  204. package/src/mcp/browser/wait.ts +73 -0
  205. package/src/mcp/forge-link.ts +126 -0
  206. package/src/mcp/headless.ts +199 -0
  207. package/src/mcp/mcp-apps.ts +435 -0
  208. package/src/mcp/mcp-chat.ts +226 -0
  209. package/src/mcp/mcp-cli.ts +53 -0
  210. package/src/mcp/mcp-protocol.ts +122 -0
  211. package/src/mcp/mcp-runtime.ts +113 -0
  212. package/src/mcp/mcp-server.ts +108 -0
  213. package/src/mcp/mcp-traces.ts +409 -0
  214. package/src/mcp/page-observation.ts +193 -0
  215. package/src/mcp/tools/agent-tools.ts +700 -0
  216. package/src/mcp/tools/app-tools.ts +241 -0
  217. package/src/mcp/tools/browser-tools.ts +310 -0
  218. package/src/mcp/tools/docs-tools.ts +32 -0
  219. package/src/mcp/tools/project-tools.ts +205 -0
  220. package/src/mcp/tools/test-tools.ts +339 -0
  221. package/src/mcp/tools/workflow-tools.ts +611 -0
  222. package/src/output.ts +1 -1
  223. package/src/pack.ts +8 -3
  224. package/src/packaging.ts +1 -1
  225. package/src/project-bootstrap.ts +107 -4
  226. package/src/project-context.ts +18 -1
  227. package/src/project.ts +27 -2
  228. package/src/provenance.ts +25 -0
  229. package/src/render-evidence.ts +65 -7
  230. package/src/scopes.ts +1 -1
  231. package/src/template.ts +14 -8
  232. package/src/templates/next-app-files.ts +35 -6
  233. package/src/templates/next-skills.ts +61 -27
  234. package/src/vendor/kit-assets.json +6 -6
  235. package/src/vendor/sdk-sources.json +17 -14
@@ -0,0 +1,107 @@
1
+ import { randomBytes } from 'node:crypto'
2
+
3
+ import { POSTGRES_DB, POSTGRES_USER } from './local-env'
4
+
5
+ /**
6
+ * The workspace secrets carried across a database restore.
7
+ *
8
+ * A restore (`frontera local snapshot restore`, `local check`'s rollback)
9
+ * throws away what was written since the baseline: test data. Workspace
10
+ * secrets are configuration, set by the person (`frontera secret set`), not
11
+ * by a test, so a restore keeps them as they are now unless asked to put the
12
+ * baseline's back (`--exact`).
13
+ *
14
+ * The rows are copied as stored, never decrypted: a value is `enc:v2`
15
+ * ciphertext under its workspace's data key version, and that key is a
16
+ * wrapped row in `workspace_deks` that only the service can unwrap. So the
17
+ * key versions the restored database lacks come with the secrets, and the
18
+ * value decrypts there exactly as before. A value under a key version the
19
+ * restored database holds as a different key would not decrypt: it is left
20
+ * as the baseline holds it and named, to be set again. Values never leave
21
+ * the database container as plain text, and nothing here is written to disk.
22
+ */
23
+
24
+ export type QueryFn = (container: string, cmd: string[], input?: string) => { ok: boolean; out: string }
25
+
26
+ /** What the current database holds, read before it is dropped: its workspaces, their secrets and data keys, as JSON. */
27
+ export interface SecretsHeld {
28
+ json: string
29
+ count: number
30
+ }
31
+
32
+ export interface SecretsCarried {
33
+ ok: boolean
34
+ /** How many secrets are as they were before the restore. */
35
+ kept: number
36
+ /** Secrets whose value would not decrypt in the restored database, by name: set them again. */
37
+ notKept: string[]
38
+ /** Why nothing was carried, when it failed. */
39
+ problem?: string
40
+ }
41
+
42
+ const psqlArgs = (extra: string[]) => ['psql', '-U', POSTGRES_USER, '-d', POSTGRES_DB, '-v', 'ON_ERROR_STOP=1', '-q', '-At', ...extra]
43
+
44
+ export const READ_SECRETS_SQL = `SELECT json_build_object(
45
+ 'workspaces', coalesce((SELECT json_agg(id) FROM workspaces), '[]'::json),
46
+ 'secrets', coalesce((SELECT json_agg(s) FROM workspace_secrets s), '[]'::json),
47
+ 'deks', coalesce((SELECT json_agg(d) FROM workspace_deks d), '[]'::json))`
48
+
49
+ /** The secrets and data keys as the database holds them now, or why they could not be read. */
50
+ export function readSecrets(query: QueryFn, container: string): SecretsHeld | { problem: string } {
51
+ const res = query(container, psqlArgs(['-c', READ_SECRETS_SQL.replace(/\s+/g, ' ')]))
52
+ if (!res.ok) return { problem: 'the current secrets could not be read' }
53
+ try {
54
+ const parsed = JSON.parse(res.out) as { secrets?: unknown[] }
55
+ return { json: res.out, count: Array.isArray(parsed.secrets) ? parsed.secrets.length : 0 }
56
+ } catch {
57
+ return { problem: 'the current secrets could not be read' }
58
+ }
59
+ }
60
+
61
+ /**
62
+ * The SQL that puts the held secrets into the restored database, in one
63
+ * transaction: the data key versions it lacks are added, each workspace's
64
+ * secrets replaced by the held ones (a secret deleted since the baseline
65
+ * stays deleted), except those under a key version it holds differently.
66
+ * Answers one JSON line: how many were kept, and the names that weren't.
67
+ */
68
+ export function carrySql(json: string, tag = `carry_${randomBytes(6).toString('hex')}`): string {
69
+ if (json.includes(`$${tag}$`)) throw new Error('the held secrets contain the quoting tag')
70
+ const q = `$${tag}$`
71
+ return [
72
+ 'BEGIN;',
73
+ `CREATE TEMP TABLE carry ON COMMIT DROP AS SELECT ${q}${json}${q}::json AS j;`,
74
+ // Only workspaces both databases have: one made after the baseline is gone with it.
75
+ `CREATE TEMP TABLE carry_ws ON COMMIT DROP AS SELECT (value #>> '{}')::uuid AS id FROM json_array_elements((SELECT j->'workspaces' FROM carry)) WHERE (value #>> '{}')::uuid IN (SELECT id FROM workspaces);`,
76
+ `CREATE TEMP TABLE carry_deks ON COMMIT DROP AS SELECT * FROM json_populate_recordset(NULL::workspace_deks, (SELECT j->'deks' FROM carry)) WHERE workspace_id IN (SELECT id FROM carry_ws);`,
77
+ `CREATE TEMP TABLE carry_secrets ON COMMIT DROP AS SELECT * FROM json_populate_recordset(NULL::workspace_secrets, (SELECT j->'secrets' FROM carry)) WHERE workspace_id IN (SELECT id FROM carry_ws);`,
78
+ // A key version the restored database holds as another key: a value under it would not decrypt there.
79
+ `CREATE TEMP TABLE carry_skip ON COMMIT DROP AS SELECT s.workspace_id, s.name FROM carry_secrets s JOIN carry_deks c ON c.workspace_id = s.workspace_id AND s.encrypted_value LIKE 'enc:v2:dek.' || c.version || ':%' JOIN workspace_deks d ON d.workspace_id = c.workspace_id AND d.version = c.version AND d.wrapped_dek <> c.wrapped_dek;`,
80
+ `INSERT INTO workspace_deks SELECT c.* FROM carry_deks c WHERE NOT EXISTS (SELECT 1 FROM workspace_deks d WHERE d.workspace_id = c.workspace_id AND d.version = c.version) ON CONFLICT DO NOTHING;`,
81
+ `DELETE FROM workspace_secrets t WHERE t.workspace_id IN (SELECT id FROM carry_ws) AND NOT EXISTS (SELECT 1 FROM carry_skip k WHERE k.workspace_id = t.workspace_id AND k.name = t.name);`,
82
+ `INSERT INTO workspace_secrets SELECT s.* FROM carry_secrets s WHERE NOT EXISTS (SELECT 1 FROM carry_skip k WHERE k.workspace_id = s.workspace_id AND k.name = s.name) ON CONFLICT DO NOTHING;`,
83
+ `SELECT json_build_object('kept', (SELECT count(*) FROM workspace_secrets t JOIN carry_secrets s ON s.id = t.id AND s.encrypted_value = t.encrypted_value), 'notKept', coalesce((SELECT json_agg(name ORDER BY name) FROM carry_skip), '[]'::json));`,
84
+ 'COMMIT;',
85
+ '',
86
+ ].join('\n')
87
+ }
88
+
89
+ /** Put the held secrets into the restored database (`carrySql`), and say what became of them. */
90
+ export function carrySecrets(query: QueryFn, container: string, held: SecretsHeld): SecretsCarried {
91
+ let sql: string
92
+ try {
93
+ sql = carrySql(held.json)
94
+ } catch (err) {
95
+ return { ok: false, kept: 0, notKept: [], problem: (err as Error).message }
96
+ }
97
+ const res = query(container, psqlArgs(['-f', '-']), sql)
98
+ if (!res.ok) return { ok: false, kept: 0, notKept: [], problem: 'the restored database refused them' }
99
+ try {
100
+ const line = res.out.split('\n').map((l) => l.trim()).filter(Boolean).at(-1) ?? '{}'
101
+ const said = JSON.parse(line) as { kept?: number; notKept?: string[] }
102
+ return { ok: true, kept: Number(said.kept ?? 0), notKept: Array.isArray(said.notKept) ? said.notKept.filter((n) => typeof n === 'string') : [] }
103
+ } catch {
104
+ // Committed, but its answer could not be read: what the runtime lists says the rest.
105
+ return { ok: true, kept: held.count, notKept: [] }
106
+ }
107
+ }
@@ -55,7 +55,7 @@ export function pluginSecretName(plugin: string, field: string): string {
55
55
 
56
56
  /** The `secret:NAME` grants a Function's source names. */
57
57
  export function functionSecretNames(source: string): string[] {
58
- return [...new Set([...source.matchAll(/['"`]secret:([A-Z][A-Z0-9_]*)['"`]/g)].map((m) => m[1]!))].sort()
58
+ return [...new Set([...source.matchAll(/['"`]secret:([A-Z][A-Z0-9_]*)['"`]/g)].map((m) => m[1]!))].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
59
59
  }
60
60
 
61
61
  /** The secrets a workflow's HTTP steps and webhook trigger name in their `auth`, at any depth. */
@@ -70,7 +70,7 @@ export function workflowSecretNames(doc: unknown): string[] {
70
70
  }
71
71
  }
72
72
  visit(doc)
73
- return [...out].sort()
73
+ return [...out].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
74
74
  }
75
75
 
76
76
  /** A Plugin setting declared empty in its file: a header or a config key, and the local secret that fills it. */
@@ -115,27 +115,57 @@ export function needsValueLine(name: string, who: string): string {
115
115
  /** The build's lines for the secrets `names` that the local runtime has no value for. */
116
116
  export async function missingSecretLines(names: string[], who: string, serviceUrl: string, creds: LocalCredentials): Promise<string[]> {
117
117
  if (!names.length) return []
118
- const have = await localSecretNames(serviceUrl, creds)
119
- if (!have) return []
120
- return names.filter((n) => !have.has(n)).map((n) => needsValueLine(n, who))
118
+ const presence = await localSecretPresence(serviceUrl, creds)
119
+ if (!presence) return []
120
+ return names.filter((n) => !hasSecretValue(presence, n)).map((n) => needsValueLine(n, who))
121
121
  }
122
122
 
123
- /** The secret names the local runtime holds (never their values); `null` when it could not say. */
124
- export async function localSecretNames(serviceUrl: string, creds: LocalCredentials): Promise<Set<string> | null> {
123
+ /**
124
+ * What the local runtime holds of its secrets, never their values: each name
125
+ * the secrets list route lists, and those of them it says have no value
126
+ * (`hasValue: false` on a row; a runtime that does not say counts a listed
127
+ * secret as having one).
128
+ */
129
+ export interface SecretPresence {
130
+ names: Set<string>
131
+ withoutValue: Set<string>
132
+ }
133
+
134
+ /** Whether the runtime has a value for `name`: listed, and not said to be without one. */
135
+ export function hasSecretValue(presence: SecretPresence, name: string): boolean {
136
+ return presence.names.has(name) && !presence.withoutValue.has(name)
137
+ }
138
+
139
+ /** The secrets list route's answer, read as `SecretPresence`; `null` when it could not say. */
140
+ export async function localSecretPresence(serviceUrl: string, creds: LocalCredentials, deps: { fetch?: (url: string | URL, init?: RequestInit) => Promise<Response> } = {}): Promise<SecretPresence | null> {
125
141
  if (!creds.workspaceKey || !creds.workspaceId) return null
126
142
  try {
127
- const res = await fetch(new URL(`/v1/workspaces/${encodeURIComponent(creds.workspaceId)}/secrets`, serviceUrl), {
143
+ const res = await (deps.fetch ?? fetch)(new URL(`/v1/workspaces/${encodeURIComponent(creds.workspaceId)}/secrets`, serviceUrl), {
128
144
  headers: { authorization: `Bearer ${creds.workspaceKey}` },
145
+ signal: AbortSignal.timeout(5000),
129
146
  })
130
147
  if (!res.ok) return null
131
- const body = (await res.json()) as { data?: Array<{ name?: string }> } | Array<{ name?: string }>
148
+ const body = (await res.json()) as { data?: Array<{ name?: string; hasValue?: unknown }> } | Array<{ name?: string; hasValue?: unknown }>
132
149
  const rows = Array.isArray(body) ? body : (body.data ?? [])
133
- return new Set(rows.flatMap((r) => (typeof r.name === 'string' ? [r.name] : [])))
150
+ const names = new Set<string>()
151
+ const withoutValue = new Set<string>()
152
+ for (const r of rows) {
153
+ if (typeof r.name !== 'string') continue
154
+ names.add(r.name)
155
+ if (r.hasValue === false) withoutValue.add(r.name)
156
+ }
157
+ return { names, withoutValue }
134
158
  } catch {
135
159
  return null
136
160
  }
137
161
  }
138
162
 
163
+ /** The secret names the local runtime holds a value for (never the values); `null` when it could not say. */
164
+ export async function localSecretNames(serviceUrl: string, creds: LocalCredentials): Promise<Set<string> | null> {
165
+ const presence = await localSecretPresence(serviceUrl, creds)
166
+ return presence ? new Set([...presence.names].filter((n) => !presence.withoutValue.has(n))) : null
167
+ }
168
+
139
169
  /**
140
170
  * A Plugin file as a push sends it: every header, key-like setting and
141
171
  * environment value empty, as the pull writes them. The platform keeps its
@@ -232,7 +262,7 @@ function sameBesideSecrets(text: string, base: string): boolean {
232
262
 
233
263
  function sortKeys(v: unknown): unknown {
234
264
  if (Array.isArray(v)) return v.map(sortKeys)
235
- if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v as Record<string, unknown>).sort().map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]))
265
+ if (v && typeof v === 'object') return Object.fromEntries(Object.keys(v as Record<string, unknown>).sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0)).map((k) => [k, sortKeys((v as Record<string, unknown>)[k])]))
236
266
  return v
237
267
  }
238
268
 
@@ -1,6 +1,6 @@
1
1
  import { appendFileSync } from 'node:fs'
2
2
 
3
- import { findProvider, getKey, keylessEmbeddings, LOCAL_PROVIDERS } from './local-keys'
3
+ import { findProvider, getKey, LOCAL_PROVIDERS, localEmbeddingsReachable } from './local-keys'
4
4
  import type { LocalCredentials } from './local-state'
5
5
 
6
6
  /**
@@ -174,11 +174,35 @@ const EMBEDDING: Array<{ provider: string; model: string }> = [
174
174
  { provider: 'openrouter', model: 'openai/text-embedding-3-small' },
175
175
  ]
176
176
 
177
+ /**
178
+ * The runtime's embedding choice, when it has one its credentials can run
179
+ * (`/v1/config/embedding` lists only credentialed providers): one chosen
180
+ * before, by `local dev` to match the workspace or by its owner, is kept.
181
+ */
182
+ async function credentialedEmbedding(serviceUrl: string, token: string, orgId: string): Promise<{ provider: string; model: string } | null> {
183
+ try {
184
+ const res = await call<{ data?: EmbeddingView } & EmbeddingView>(serviceUrl, '/v1/config/embedding', { token, orgId })
185
+ if (res.status >= 300) return null
186
+ const view = res.body?.data ?? res.body
187
+ const { provider, model } = view ?? {}
188
+ if (typeof provider !== 'string' || !provider || typeof model !== 'string' || !model) return null
189
+ return Array.isArray(view?.providers) && view.providers.some((p) => p?.id === provider) ? { provider, model } : null
190
+ } catch {
191
+ return null
192
+ }
193
+ }
194
+
195
+ interface EmbeddingView {
196
+ provider?: string | null
197
+ model?: string | null
198
+ providers?: Array<{ id?: string }>
199
+ }
200
+
177
201
  /**
178
202
  * Copy the FDE's stored keys into the local organization's credentials, and
179
- * pick an embedding model when one is possible. Returns what was applied, for
180
- * the status line; failures are logged, never fatal — a runtime without a model
181
- * key still starts, and says so.
203
+ * pick an embedding model when one is possible and none that runs is chosen
204
+ * yet. Returns what was applied, for the status line; failures are logged,
205
+ * never fatal — a runtime without a model key still starts, and says so.
182
206
  */
183
207
  export async function applyModelKeys(args: { serviceUrl: string; password: string; organizationId: string; logFile: string }): Promise<string[]> {
184
208
  const applied: string[] = []
@@ -202,33 +226,56 @@ export async function applyModelKeys(args: { serviceUrl: string; password: strin
202
226
  if (res.status >= 200 && res.status < 300) applied.push(p.id)
203
227
  else log(args.logFile, `storing the ${findProvider(p.id)?.label} key answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
204
228
  }
205
- let embedding = EMBEDDING.find((e) => applied.includes(e.provider))
206
- if (!embedding && keylessEmbeddings()) {
207
- // A placeholder OpenAI credential: the service's OpenAI client is pointed
208
- // at the local embeddings (see serviceEnv), which embed without a key.
209
- const res = await call(args.serviceUrl, '/v1/admin/provider-credentials/openai', {
210
- method: 'PUT',
211
- token,
212
- orgId: args.organizationId,
213
- body: { fields: { apiKey: 'local-embeddings' } },
214
- })
215
- if (res.status >= 200 && res.status < 300) {
216
- embedding = EMBEDDING[0]
217
- applied.push('local-embeddings')
218
- } else log(args.logFile, `storing the local embeddings credential answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
229
+ // A choice that runs with this runtime's keys stays: the workspace's model (`mirrorOrgSettings`) or the owner's.
230
+ // The local embeddings' stand-in OpenAI credential is not a key: a real key's model replaces it when the provider takes it.
231
+ const kept = await credentialedEmbedding(args.serviceUrl, token, args.organizationId)
232
+ const standIn = !!kept && isLocalEmbeddings(kept)
233
+ if (kept && !standIn) {
234
+ log(args.logFile, `kept the embedding model ${kept.provider} / ${kept.model}`)
235
+ return applied
219
236
  }
220
- if (embedding) {
221
- const res = await call(args.serviceUrl, '/v1/config/embedding', {
222
- method: 'PUT',
223
- token,
224
- orgId: args.organizationId,
225
- body: embedding,
226
- })
227
- if (res.status >= 300) log(args.logFile, `setting the embedding model answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
237
+ const setEmbedding = async (embedding: { provider: string; model: string }) => {
238
+ const res = await call(args.serviceUrl, '/v1/config/embedding', { method: 'PUT', token, orgId: args.organizationId, body: embedding })
239
+ if (res.status < 300) return true
240
+ log(args.logFile, `setting the embedding model ${embedding.provider} / ${embedding.model} answered ${res.status}: ${JSON.stringify(res.body).slice(0, 300)}`)
241
+ return false
242
+ }
243
+ for (const embedding of EMBEDDING.filter((e) => applied.includes(e.provider))) {
244
+ if (await setEmbedding(embedding)) return applied
245
+ }
246
+ if (standIn) {
247
+ applied.push('local-embeddings')
248
+ return applied
249
+ }
250
+ // No key, or the provider refused the one stored (the line above says its answer): the keyless local
251
+ // embeddings, so knowledge bases still ingest and search here. They need the service's OpenAI client,
252
+ // which points at them while this computer has no OpenAI key (see serviceEnv).
253
+ if (!localEmbeddingsReachable()) return applied
254
+ const res = await call(args.serviceUrl, '/v1/admin/provider-credentials/openai', {
255
+ method: 'PUT',
256
+ token,
257
+ orgId: args.organizationId,
258
+ body: { fields: { apiKey: LOCAL_EMBEDDINGS_CREDENTIAL } },
259
+ })
260
+ if (res.status >= 300) {
261
+ log(args.logFile, `storing the local embeddings credential answered ${res.status}: ${JSON.stringify(res.body).slice(0, 200)}`)
262
+ return applied
263
+ }
264
+ if (await setEmbedding(EMBEDDING[0]!)) {
265
+ applied.push('local-embeddings')
266
+ if (applied.some((p) => p === 'openrouter')) log(args.logFile, 'embedding with the keyless local embeddings: the stored key’s embedding model was refused (see the line above)')
228
267
  }
229
268
  return applied
230
269
  }
231
270
 
271
+ /** The placeholder OpenAI credential that sends the service's OpenAI client to the keyless local embeddings. */
272
+ const LOCAL_EMBEDDINGS_CREDENTIAL = 'local-embeddings'
273
+
274
+ /** Whether a choice is the keyless local embeddings: OpenAI's model while this computer holds no OpenAI key. */
275
+ export function isLocalEmbeddings(embedding: { provider: string | null; model: string | null }): boolean {
276
+ return embedding.provider === 'openai' && localEmbeddingsReachable()
277
+ }
278
+
232
279
  /**
233
280
  * Runtimes started before the echo model was removed still list it: the
234
281
  * `local` custom provider, its `local-model` catalog entry, and that model as
@@ -273,6 +320,35 @@ export const PROVIDER_MODELS: Record<string, Array<{ slug: string; modelId: stri
273
320
  { slug: 'gemini-3-8-flash', modelId: 'google/gemini-3.8-flash', name: 'Gemini 3.8 Flash' },
274
321
  { slug: 'gpt-5-6-terra', modelId: 'openai/gpt-5.6-terra', name: 'GPT-5.6 Terra' },
275
322
  ],
323
+ // Direct from Anthropic, beside OpenRouter's: a stored Anthropic key runs Claude without OpenRouter's
324
+ // credit. Their slugs name the provider, so they never take the OpenRouter models' slugs.
325
+ anthropic: [
326
+ { slug: 'anthropic-claude-haiku-5-5', modelId: 'claude-haiku-5-5', name: 'Claude Haiku 5.5 (Anthropic)' },
327
+ { slug: 'anthropic-claude-sonnet-5-5', modelId: 'claude-sonnet-5-5', name: 'Claude Sonnet 5.5 (Anthropic)' },
328
+ ],
329
+ }
330
+
331
+ /** A model the local catalog offers directly from a provider (not through OpenRouter), and that provider. */
332
+ export interface DirectModel {
333
+ slug: string
334
+ name: string
335
+ provider: string
336
+ providerLabel: string
337
+ }
338
+
339
+ /**
340
+ * The first model offered directly by a provider whose key the runtime holds
341
+ * (`providers`: the ids the runtime applied keys for, never a key), other than
342
+ * the provider that refused (`refusedBy`); null when there is none. What a
343
+ * credit refusal suggests switching to.
344
+ */
345
+ export function directModelFor(providers: readonly string[] | undefined, refusedBy?: string | null): DirectModel | null {
346
+ for (const [provider, models] of Object.entries(PROVIDER_MODELS)) {
347
+ if (provider === 'openrouter' || provider === refusedBy || !providers?.includes(provider) || !models.length) continue
348
+ const m = models[0]!
349
+ return { slug: m.slug, name: m.name.replace(/\s*\([^)]*\)$/, ''), provider, providerLabel: findProvider(provider)?.label ?? provider }
350
+ }
351
+ return null
276
352
  }
277
353
 
278
354
  /**
@@ -34,7 +34,7 @@ import { rows } from './local-sync-remote'
34
34
  * - **Documentation** (Frontera's documentation for Agents): its stand-in
35
35
  * `serves: "documentation"`, answering the plugin's tools from the
36
36
  * documentation `local start` copied for the runtime's release (the same
37
- * copy `frontera local docs-mcp` serves the project's coding agents). The
37
+ * copy `frontera mcp` serves the project's coding agents). The
38
38
  * Agent's documentation tools work here.
39
39
  * - **Analytics**: the workspace's analytics tools read its analytics
40
40
  * project, which a local runtime does not have (design §8). The pull
@@ -70,7 +70,7 @@ const DOCS_TOOLS: StandInFile['tools'] = [
70
70
 
71
71
  /** The built-in Plugins these items need that get a stand-in (connections, the Documentation plugin), by install name. */
72
72
  export function connectionsNeeded(items: Array<Pick<RemoteItem, 'portability'>>): string[] {
73
- return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'connection' || n.kind === 'documentation' ? [n.name] : []))))].sort()
73
+ return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'connection' || n.kind === 'documentation' ? [n.name] : []))))].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
74
74
  }
75
75
 
76
76
  /** Which of those are the Documentation plugin. */
@@ -80,7 +80,7 @@ export function documentationNeeded(items: Array<Pick<RemoteItem, 'portability'>
80
80
 
81
81
  /** The workspace's analytics Plugins these items use: platform-only. */
82
82
  export function analyticsNeeded(items: Array<Pick<RemoteItem, 'portability'>>): string[] {
83
- return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'analytics' ? [n.name] : []))))].sort()
83
+ return [...new Set(items.flatMap((i) => (i.portability?.needs ?? []).flatMap((n) => (n.kind === 'analytics' ? [n.name] : []))))].sort((a, b) => (String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0))
84
84
  }
85
85
 
86
86
  // ── Platform-only Plugins ────────────────────────────────────────────────────
@@ -1,6 +1,7 @@
1
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
2
2
  import { join } from 'node:path'
3
3
 
4
+ import { ignoreInFronteraDir } from '../project-context'
4
5
  import { writeFileAtomic } from '../fs-atomic'
5
6
  import type { LocalProject } from './local-project'
6
7
 
@@ -88,8 +89,8 @@ export function ensureStateDir(project: LocalProject): void {
88
89
  } catch {
89
90
  // Best effort; a filesystem without modes still works.
90
91
  }
91
- const ignore = join(project.root, '.frontera', '.gitignore')
92
- if (!existsSync(ignore)) writeFileAtomic(ignore, 'local/\n', 0o644)
92
+ // The runtime's state, and which profile this machine bound the directory to, stay out of git.
93
+ ignoreInFronteraDir(project.root, ['local/'])
93
94
  }
94
95
 
95
96
  export function readState(project: LocalProject): LocalState | null {
@@ -163,3 +164,43 @@ export function readApplied(project: LocalProject): LastApplied | null {
163
164
  return null
164
165
  }
165
166
  }
167
+
168
+ /**
169
+ * An apply under way: `local dev` writes it as it starts applying what is
170
+ * queued and removes it when the queue is done, so Forge and Studio say
171
+ * "Applying…" rather than show a half-made count as if it were the result.
172
+ */
173
+ export interface ApplyingNow {
174
+ /** When this round of applies began. */
175
+ since: string
176
+ /** How many items were queued when it began. */
177
+ queued: number
178
+ /** The `local dev` applying them: a record whose process is gone is stale. */
179
+ pid: number
180
+ /** What started it, in words: "runtime started", "files changed: agents/desk/agent.json". */
181
+ trigger?: string
182
+ }
183
+
184
+ function applyingPath(project: LocalProject): string {
185
+ return join(project.stateDir, 'applying.json')
186
+ }
187
+
188
+ /** Say an apply is under way (`entry`), or that it is done (`null`). Never throws. */
189
+ export function recordApplying(project: LocalProject, entry: ApplyingNow | null): void {
190
+ try {
191
+ if (entry) writeFileAtomic(applyingPath(project), `${JSON.stringify(entry)}\n`, 0o644)
192
+ else rmSync(applyingPath(project), { force: true })
193
+ } catch {
194
+ // A missed hint must never fail an apply.
195
+ }
196
+ }
197
+
198
+ /** The apply under way, or null: none, or its `local dev` is gone. */
199
+ export function readApplying(stateDir: string): ApplyingNow | null {
200
+ try {
201
+ const entry = JSON.parse(readFileSync(join(stateDir, 'applying.json'), 'utf8')) as ApplyingNow
202
+ return entry && typeof entry.since === 'string' && isAlive(entry.pid) ? entry : null
203
+ } catch {
204
+ return null
205
+ }
206
+ }