@frontera-sdk/cli 1.52.27 → 1.52.29

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 (59) hide show
  1. package/package.json +5 -4
  2. package/src/commands/local/data.ts +60 -0
  3. package/src/commands/local/dev.ts +361 -18
  4. package/src/commands/local/index-commands.ts +11 -3
  5. package/src/commands/local/init.ts +88 -7
  6. package/src/commands/local/sync-request.ts +750 -0
  7. package/src/commands/local/sync.ts +941 -0
  8. package/src/commands/skill/bundle-commands.ts +9 -6
  9. package/src/errors.ts +4 -1
  10. package/src/exit.ts +3 -0
  11. package/src/flag-help.ts +48 -0
  12. package/src/local/local-actions.ts +306 -0
  13. package/src/local/local-adapt.ts +216 -0
  14. package/src/local/local-agent-files.ts +9 -2
  15. package/src/local/local-agent-grants.ts +125 -0
  16. package/src/local/local-agent-name.ts +71 -0
  17. package/src/local/local-applied.ts +334 -3
  18. package/src/local/local-apps.ts +156 -0
  19. package/src/local/local-blueprint-outline.ts +465 -0
  20. package/src/local/local-brand.ts +123 -0
  21. package/src/local/local-build-summary.ts +106 -0
  22. package/src/local/local-chat-apps.ts +170 -0
  23. package/src/local/local-dev-references.ts +1132 -0
  24. package/src/local/local-dev-sync.ts +171 -7
  25. package/src/local/local-docs.ts +53 -5
  26. package/src/local/local-env.ts +2 -0
  27. package/src/local/local-function-draft.ts +33 -0
  28. package/src/local/local-function-shared.ts +65 -0
  29. package/src/local/local-function-state.ts +34 -0
  30. package/src/local/local-knowledge-documents.ts +302 -0
  31. package/src/local/local-lessons.ts +278 -0
  32. package/src/local/local-library.ts +124 -0
  33. package/src/local/local-media-sets.ts +31 -0
  34. package/src/local/local-object-sets.ts +181 -0
  35. package/src/local/local-objects.ts +159 -0
  36. package/src/local/local-operating.ts +9 -5
  37. package/src/local/local-org-settings.ts +118 -0
  38. package/src/local/local-portability.ts +346 -0
  39. package/src/local/local-project.ts +6 -2
  40. package/src/local/local-quality-checks.ts +171 -0
  41. package/src/local/local-scaffold.ts +14 -11
  42. package/src/local/local-schemas.ts +33 -2
  43. package/src/local/local-secrets.ts +306 -0
  44. package/src/local/local-stand-ins.ts +442 -0
  45. package/src/local/local-studio-server.ts +23 -2
  46. package/src/local/local-sync-base.ts +237 -0
  47. package/src/local/local-sync-client.ts +93 -0
  48. package/src/local/local-sync-link.ts +122 -0
  49. package/src/local/local-sync-merge.ts +395 -0
  50. package/src/local/local-sync-names.ts +169 -0
  51. package/src/local/local-sync-plan.ts +717 -0
  52. package/src/local/local-sync-push.ts +986 -0
  53. package/src/local/local-sync-refs.ts +221 -0
  54. package/src/local/local-sync-remote.ts +1482 -0
  55. package/src/local/local-sync-snapshot.ts +526 -0
  56. package/src/local/local-synthetic-data.ts +374 -0
  57. package/src/local/local-test-runner.ts +172 -14
  58. package/src/local/local-themes.ts +201 -0
  59. package/src/local/local-workflow-state.ts +131 -0
@@ -0,0 +1,306 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import type { LocalProject } from './local-project'
7
+ import type { LocalCredentials } from './local-state'
8
+
9
+ /** A project file's text, or '' when it cannot be read. */
10
+ export function readProjectText(project: LocalProject, file: string): string {
11
+ try {
12
+ return readFileSync(join(project.root, file), 'utf8')
13
+ } catch {
14
+ return ''
15
+ }
16
+ }
17
+
18
+ /** A project file's YAML or JSON document, or null when it cannot be read. */
19
+ export function readProjectDoc(project: LocalProject, file: string): unknown {
20
+ try {
21
+ return parseYaml(readProjectText(project, file))
22
+ } catch {
23
+ return null
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Secrets a project needs on the local runtime, by name (design §8: secrets
29
+ * come down as placeholders, as Vercel's do; their values never do).
30
+ *
31
+ * A Function names its secrets as `secret:NAME` grants and a workflow names
32
+ * them in its HTTP steps' and webhook trigger's `auth`; both are in the files
33
+ * already. A Plugin's header and secret settings come down declared and empty
34
+ * (`""`), and each is filled on the local runtime from a workspace secret named
35
+ * after the Plugin and the setting (`acme-crm` + `Authorization` →
36
+ * `ACME_CRM_AUTHORIZATION`). A person sets every one the same way, with
37
+ * `frontera secret set NAME` in the project folder, and `local dev` says
38
+ * "Needs a value for NAME" until they have.
39
+ */
40
+
41
+ /** Headers that carry no credential: the platform shows their values, so a pull keeps them (`auto-vault.ts`). */
42
+ export const NON_SECRET_HEADERS = new Set(['content-type', 'accept', 'accept-encoding', 'accept-language', 'user-agent', 'x-workspace-id'])
43
+
44
+ /** Config keys that hold a credential, by name (the pull's long-standing rule). */
45
+ export const SECRETISH = /key|token|secret|password|authorization|bearer|cookie/i
46
+
47
+ /** A value the platform masked on read. */
48
+ export const MASKED = /^(\*+|•+)$/
49
+
50
+ /** The local secret that fills a Plugin's setting: `acme-crm` + `Authorization` → `ACME_CRM_AUTHORIZATION`. */
51
+ export function pluginSecretName(plugin: string, field: string): string {
52
+ const name = `${plugin}_${field}`.toUpperCase().replace(/[^A-Z0-9]+/g, '_').replace(/^_+|_+$/g, '')
53
+ return /^[A-Z]/.test(name) ? name : `PLUGIN_${name}`
54
+ }
55
+
56
+ /** The `secret:NAME` grants a Function's source names. */
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()
59
+ }
60
+
61
+ /** The secrets a workflow's HTTP steps and webhook trigger name in their `auth`, at any depth. */
62
+ export function workflowSecretNames(doc: unknown): string[] {
63
+ const out = new Set<string>()
64
+ const visit = (v: unknown) => {
65
+ if (Array.isArray(v)) return v.forEach(visit)
66
+ if (!v || typeof v !== 'object') return
67
+ for (const [k, x] of Object.entries(v as Record<string, unknown>)) {
68
+ if (k === 'auth' && x && typeof x === 'object' && typeof (x as { secret?: unknown }).secret === 'string') out.add((x as { secret: string }).secret)
69
+ visit(x)
70
+ }
71
+ }
72
+ visit(doc)
73
+ return [...out].sort()
74
+ }
75
+
76
+ /** A Plugin setting declared empty in its file: a header or a config key, and the local secret that fills it. */
77
+ export interface PluginSecretInput {
78
+ field: string
79
+ header: boolean
80
+ secret: string
81
+ }
82
+
83
+ /** The settings a Plugin file declares empty, to be filled from local secrets. */
84
+ export function pluginSecretInputs(plugin: string, config: Record<string, unknown>): PluginSecretInput[] {
85
+ const out: PluginSecretInput[] = []
86
+ const headers = config.headers
87
+ if (headers && typeof headers === 'object' && !Array.isArray(headers)) {
88
+ for (const [h, v] of Object.entries(headers as Record<string, unknown>)) {
89
+ if (v === '' && !NON_SECRET_HEADERS.has(h.toLowerCase())) out.push({ field: h, header: true, secret: pluginSecretName(plugin, h) })
90
+ }
91
+ }
92
+ for (const [k, v] of Object.entries(config)) {
93
+ if (k !== 'headers' && k !== 'policy' && v === '' && SECRETISH.test(k)) out.push({ field: k, header: false, secret: pluginSecretName(plugin, k) })
94
+ }
95
+ return out
96
+ }
97
+
98
+ /** The config to send: each of `inputs` filled with a reference to its local secret (`{{vault.NAME}}`). */
99
+ export function fillPluginSecrets(config: Record<string, unknown>, inputs: PluginSecretInput[]): Record<string, unknown> {
100
+ const out: Record<string, unknown> = { ...config }
101
+ if (out.headers && typeof out.headers === 'object') out.headers = { ...(out.headers as Record<string, unknown>) }
102
+ for (const input of inputs) {
103
+ const ref = `{{vault.${input.secret}}}`
104
+ if (input.header) (out.headers as Record<string, unknown>)[input.field] = ref
105
+ else out[input.field] = ref
106
+ }
107
+ return out
108
+ }
109
+
110
+ /** One line of the build: an adaptation the person resolves by setting the secret. */
111
+ export function needsValueLine(name: string, who: string): string {
112
+ return ` warning: Needs a value for ${name} (${who}): set it with \`frontera secret set ${name}\` in this folder`
113
+ }
114
+
115
+ /** The build's lines for the secrets `names` that the local runtime has no value for. */
116
+ export async function missingSecretLines(names: string[], who: string, serviceUrl: string, creds: LocalCredentials): Promise<string[]> {
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))
121
+ }
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> {
125
+ if (!creds.workspaceKey || !creds.workspaceId) return null
126
+ try {
127
+ const res = await fetch(new URL(`/v1/workspaces/${encodeURIComponent(creds.workspaceId)}/secrets`, serviceUrl), {
128
+ headers: { authorization: `Bearer ${creds.workspaceKey}` },
129
+ })
130
+ if (!res.ok) return null
131
+ const body = (await res.json()) as { data?: Array<{ name?: string }> } | Array<{ name?: string }>
132
+ const rows = Array.isArray(body) ? body : (body.data ?? [])
133
+ return new Set(rows.flatMap((r) => (typeof r.name === 'string' ? [r.name] : [])))
134
+ } catch {
135
+ return null
136
+ }
137
+ }
138
+
139
+ /**
140
+ * A Plugin file as a push sends it: every header, key-like setting and
141
+ * environment value empty, as the pull writes them. The platform keeps its
142
+ * own; a value typed into the file stays on this computer (and is said so).
143
+ */
144
+ export function withoutSecrets(text: string): { text: string; notes: string[] } {
145
+ let doc: Record<string, unknown>
146
+ try {
147
+ doc = JSON.parse(text) as Record<string, unknown>
148
+ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) return { text, notes: [] }
149
+ } catch {
150
+ return { text, notes: [] }
151
+ }
152
+ const kept: string[] = []
153
+ const blank = (where: string, value: unknown): unknown => {
154
+ if (value && typeof value === 'object' && !Array.isArray(value)) return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([k, v]) => [k, blank(`${where} ${k}`, v)]))
155
+ if (value !== '' && value !== null && value !== undefined) kept.push(where)
156
+ return ''
157
+ }
158
+ const out: Record<string, unknown> = {}
159
+ for (const [k, v] of Object.entries(doc)) {
160
+ if (k === 'headers' && v && typeof v === 'object' && !Array.isArray(v)) {
161
+ // A header that carries no credential keeps its value, as the pull keeps it.
162
+ out[k] = Object.fromEntries(Object.entries(v as Record<string, unknown>).map(([h, x]) => [h, NON_SECRET_HEADERS.has(h.toLowerCase()) && typeof x === 'string' ? x : blank(`header ${h}`, x)]))
163
+ }
164
+ else if (k === 'env' && v && typeof v === 'object' && !Array.isArray(v)) out[k] = blank('env', v)
165
+ else if (SECRETISH.test(k) && k !== 'policy') out[k] = blank(k, v)
166
+ else out[k] = v
167
+ }
168
+ if (!kept.length) return { text, notes: [] }
169
+ return {
170
+ text: `${JSON.stringify(out, null, 2)}\n`,
171
+ notes: [`Not proposed, as secrets never are: the value${kept.length === 1 ? '' : 's'} of ${kept.join(', ')}; the platform keeps its own (here, set ${kept.length === 1 ? 'it' : 'them'} with \`frontera secret set\`)`],
172
+ }
173
+ }
174
+
175
+ /** Whether a project file is a Plugin's, whose secret values are this computer's own. */
176
+ export const isPluginFile = (path: string) => /^mcp\/[^/]+\.plugin\.json$/.test(path)
177
+
178
+ /**
179
+ * A Plugin file as the workspace and this project compare it: its secret
180
+ * values (a header, a key-like setting, an environment value) empty, as the
181
+ * pull writes them. A value set here is this computer's configuration, so a
182
+ * header set only locally does not make the Plugin read as changed here.
183
+ */
184
+ export function comparableText(path: string, text: string, base: string | null = null): string {
185
+ if (!isPluginFile(path)) return text
186
+ const blanked = blankedInPlace(text)
187
+ return base !== null && sameBesideSecrets(text, base) ? base : blanked
188
+ }
189
+
190
+ /**
191
+ * Whether a Plugin file here is the base's but for this computer's secrets:
192
+ * a secret value set here (where the base has it empty, or has no such
193
+ * header at all), and key order. Then it compares as the base does.
194
+ */
195
+ function sameBesideSecrets(text: string, base: string): boolean {
196
+ let ours: Record<string, unknown>
197
+ let theirs: Record<string, unknown>
198
+ try {
199
+ ours = JSON.parse(text) as Record<string, unknown>
200
+ theirs = JSON.parse(base) as Record<string, unknown>
201
+ } catch {
202
+ return false
203
+ }
204
+ const obj = (v: unknown) => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null)
205
+ const norm = (doc: Record<string, unknown>, other: Record<string, unknown>): unknown => {
206
+ const out: Record<string, unknown> = {}
207
+ for (const [k, v] of Object.entries(doc)) {
208
+ if (k === 'headers' || k === 'env') {
209
+ const mine = obj(v)
210
+ if (!mine) {
211
+ out[k] = v
212
+ continue
213
+ }
214
+ const there = obj(other[k]) ?? {}
215
+ const kept: Record<string, unknown> = {}
216
+ for (const [h, x] of Object.entries(mine)) {
217
+ const secret = k === 'env' || !NON_SECRET_HEADERS.has(h.toLowerCase())
218
+ // A secret the other side has not got, or has empty: this computer's own.
219
+ if (secret && (x === '' || !(h in there) || there[h] === '')) continue
220
+ kept[h] = x
221
+ }
222
+ if (Object.keys(kept).length) out[k] = kept
223
+ continue
224
+ }
225
+ if (k !== 'policy' && SECRETISH.test(k) && (v === '' || other[k] === '' || !(k in other))) continue
226
+ out[k] = v
227
+ }
228
+ return out
229
+ }
230
+ return JSON.stringify(sortKeys(norm(ours, theirs))) === JSON.stringify(sortKeys(norm(theirs, ours)))
231
+ }
232
+
233
+ function sortKeys(v: unknown): unknown {
234
+ 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])]))
236
+ return v
237
+ }
238
+
239
+ /**
240
+ * The file's secret values emptied where they stand, so its layout (and so
241
+ * its hash, when nothing else changed) is the file's own: a header typed
242
+ * into a pulled file compares as the file did before. Falls back to the
243
+ * rewritten document when a value cannot be found as written.
244
+ */
245
+ function blankedInPlace(text: string): string {
246
+ const clean = withoutSecrets(text)
247
+ if (clean.text === text) return text
248
+ let doc: Record<string, unknown>
249
+ try {
250
+ doc = JSON.parse(text) as Record<string, unknown>
251
+ } catch {
252
+ return clean.text
253
+ }
254
+ const pairs: Array<[string, string]> = []
255
+ const obj = (v: unknown) => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null)
256
+ for (const [h, v] of Object.entries(obj(doc.headers) ?? {})) if (typeof v === 'string' && v && !NON_SECRET_HEADERS.has(h.toLowerCase())) pairs.push([h, v])
257
+ for (const [k, v] of Object.entries(obj(doc.env) ?? {})) if (typeof v === 'string' && v) pairs.push([k, v])
258
+ for (const [k, v] of Object.entries(doc)) if (k !== 'policy' && k !== 'headers' && k !== 'env' && SECRETISH.test(k) && typeof v === 'string' && v) pairs.push([k, v])
259
+ let out = text
260
+ for (const [key, value] of pairs) {
261
+ const escape = (s: string) => JSON.stringify(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
262
+ out = out.replace(new RegExp(`(${escape(key)}\\s*:\\s*)${escape(value)}`), '$1""')
263
+ }
264
+ try {
265
+ return JSON.stringify(JSON.parse(out)) === JSON.stringify(JSON.parse(clean.text)) ? out : clean.text
266
+ } catch {
267
+ return clean.text
268
+ }
269
+ }
270
+
271
+ /**
272
+ * The workspace's Plugin file with this computer's secret values kept: a
273
+ * header, key-like setting or environment value the workspace leaves empty
274
+ * keeps the value the file here has. Written by a pull, so taking the
275
+ * workspace's change never wipes a header set here.
276
+ */
277
+ export function withLocalSecrets(remote: string, local: string | null): string {
278
+ if (local === null) return remote
279
+ let theirs: Record<string, unknown>
280
+ let ours: Record<string, unknown>
281
+ try {
282
+ theirs = JSON.parse(remote) as Record<string, unknown>
283
+ ours = JSON.parse(local) as Record<string, unknown>
284
+ if (!theirs || typeof theirs !== 'object' || Array.isArray(theirs) || !ours || typeof ours !== 'object' || Array.isArray(ours)) return remote
285
+ } catch {
286
+ return remote
287
+ }
288
+ let kept = false
289
+ const keep = (into: Record<string, unknown>, from: Record<string, unknown>, isSecret: (key: string) => boolean) => {
290
+ for (const [k, v] of Object.entries(from)) {
291
+ if (into[k] === '' && typeof v === 'string' && v !== '' && isSecret(k)) {
292
+ into[k] = v
293
+ kept = true
294
+ }
295
+ }
296
+ }
297
+ const obj = (v: unknown) => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null)
298
+ const th = obj(theirs.headers)
299
+ const oh = obj(ours.headers)
300
+ if (th && oh) keep(th, oh, (h) => !NON_SECRET_HEADERS.has(h.toLowerCase()))
301
+ const te = obj(theirs.env)
302
+ const oe = obj(ours.env)
303
+ if (te && oe) keep(te, oe, () => true)
304
+ keep(theirs, ours, (k) => k !== 'policy' && k !== 'headers' && k !== 'env' && SECRETISH.test(k))
305
+ return kept ? `${JSON.stringify(theirs, null, 2)}\n` : remote
306
+ }