@frontera-sdk/cli 1.52.28 → 1.52.30
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.
- package/package.json +5 -4
- package/src/commands/local/data.ts +60 -0
- package/src/commands/local/dev.ts +361 -18
- package/src/commands/local/index-commands.ts +11 -3
- package/src/commands/local/init.ts +88 -7
- package/src/commands/local/sync-request.ts +750 -0
- package/src/commands/local/sync.ts +941 -0
- package/src/commands/skill/bundle-commands.ts +9 -6
- package/src/errors.ts +4 -1
- package/src/exit.ts +3 -0
- package/src/flag-help.ts +48 -0
- package/src/local/local-actions.ts +306 -0
- package/src/local/local-adapt.ts +216 -0
- package/src/local/local-agent-files.ts +9 -2
- package/src/local/local-agent-grants.ts +125 -0
- package/src/local/local-agent-name.ts +71 -0
- package/src/local/local-applied.ts +334 -3
- package/src/local/local-apps.ts +156 -0
- package/src/local/local-blueprint-outline.ts +465 -0
- package/src/local/local-brand.ts +123 -0
- package/src/local/local-build-summary.ts +106 -0
- package/src/local/local-chat-apps.ts +170 -0
- package/src/local/local-dev-references.ts +1132 -0
- package/src/local/local-dev-sync.ts +171 -7
- package/src/local/local-docs.ts +53 -5
- package/src/local/local-env.ts +2 -0
- package/src/local/local-function-draft.ts +33 -0
- package/src/local/local-function-shared.ts +65 -0
- package/src/local/local-function-state.ts +34 -0
- package/src/local/local-knowledge-documents.ts +302 -0
- package/src/local/local-lessons.ts +278 -0
- package/src/local/local-library.ts +124 -0
- package/src/local/local-media-sets.ts +31 -0
- package/src/local/local-object-sets.ts +181 -0
- package/src/local/local-objects.ts +159 -0
- package/src/local/local-operating.ts +9 -5
- package/src/local/local-org-settings.ts +118 -0
- package/src/local/local-portability.ts +346 -0
- package/src/local/local-project.ts +6 -2
- package/src/local/local-quality-checks.ts +171 -0
- package/src/local/local-scaffold.ts +14 -11
- package/src/local/local-schemas.ts +33 -2
- package/src/local/local-secrets.ts +306 -0
- package/src/local/local-stand-ins.ts +442 -0
- package/src/local/local-studio-server.ts +23 -2
- package/src/local/local-sync-base.ts +237 -0
- package/src/local/local-sync-client.ts +93 -0
- package/src/local/local-sync-link.ts +122 -0
- package/src/local/local-sync-merge.ts +395 -0
- package/src/local/local-sync-names.ts +169 -0
- package/src/local/local-sync-plan.ts +717 -0
- package/src/local/local-sync-push.ts +986 -0
- package/src/local/local-sync-refs.ts +221 -0
- package/src/local/local-sync-remote.ts +1482 -0
- package/src/local/local-sync-snapshot.ts +526 -0
- package/src/local/local-synthetic-data.ts +374 -0
- package/src/local/local-test-runner.ts +172 -14
- package/src/local/local-themes.ts +201 -0
- 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
|
+
}
|