@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,201 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { basename, join } from 'node:path'
3
+
4
+ import type { LocalCredentials } from './local-state'
5
+ import type { SyncClient } from './local-sync-client'
6
+ import type { RemoteItem } from './local-sync-plan'
7
+ import { refName } from './local-sync-remote'
8
+
9
+ /**
10
+ * Slide themes: the workspace's imported decks and custom themes, which an
11
+ * Agent's slides and images are made in (`createSlides`, `createImage`).
12
+ * Built-in themes live in code on every runtime; a workspace's own come down
13
+ * as `themes/<name>/theme.json` (name, style, tokens, layouts, reusable
14
+ * images) with the images beside it, and `local dev` saves each on the local
15
+ * runtime, so the Agent's decks look as they do in the workspace.
16
+ */
17
+
18
+ /** A theme as `/v1/sync/slide-themes` lists it. */
19
+ export interface ThemeRow {
20
+ id: string
21
+ name: string
22
+ styleDescription?: string | null
23
+ source?: string | null
24
+ tokens: Record<string, unknown>
25
+ slideCount?: number | null
26
+ layouts?: Array<{ kind: string; hint: string; imageKey?: string }>
27
+ assets?: Array<{ imageKey: string; name: string; mime: string }>
28
+ thumbnailKey?: string | null
29
+ }
30
+
31
+ /** The file a theme keeps in a project. */
32
+ export interface ThemeFile {
33
+ name: string
34
+ styleDescription?: string
35
+ source?: string
36
+ tokens: Record<string, unknown>
37
+ slideCount?: number
38
+ layouts?: Array<{ kind: string; hint: string; image?: string }>
39
+ assets?: Array<{ name: string; mime: string; file: string }>
40
+ thumbnail?: string
41
+ }
42
+
43
+ /** Each theme's name in a project (`theme/acme-brand`), numbered when two would share one: the index's rule. */
44
+ export function themeNames(rows: Array<{ id: string; name: string }>): Map<string, string> {
45
+ const out = new Map<string, string>()
46
+ const taken = new Set<string>()
47
+ for (const row of [...rows].sort((a, b) => a.name.localeCompare(b.name) || a.id.localeCompare(b.id))) {
48
+ const base = refName(row.name)
49
+ let name = base
50
+ for (let n = 2; taken.has(name); n++) name = `${base}-${n}`
51
+ taken.add(name)
52
+ out.set(row.id, name)
53
+ }
54
+ return out
55
+ }
56
+
57
+ /** A storage key's last segment, as the theme's folder names the file. */
58
+ const fileOf = (key: string) => basename(key).replace(/[^A-Za-z0-9._-]/g, '-').replace(/^[^A-Za-z0-9]+/, '') || 'file'
59
+
60
+ /** An image larger than this stays on the platform, named in the theme's notes. */
61
+ const IMAGE_LIMIT = 20 * 1024 * 1024
62
+
63
+ /**
64
+ * The workspace's themes as project items: `theme.json`, and each image as an
65
+ * asset beside it, read from the theme's file route. An image that cannot be
66
+ * read is left out of the file too, and named.
67
+ */
68
+ export async function readThemes(ws: SyncClient, rows: ThemeRow[], wanted: (ref: string) => boolean, warnings: string[]): Promise<RemoteItem[]> {
69
+ const names = themeNames(rows)
70
+ const out: RemoteItem[] = []
71
+ for (const row of rows) {
72
+ const name = names.get(row.id)!
73
+ const ref = `theme/${name}`
74
+ if (!wanted(ref)) continue
75
+ const it: RemoteItem = { ref, kind: 'theme', name, title: row.name, files: {}, unresolved: [], notes: [], requires: [], id: row.id, assets: [] }
76
+ const files = new Map<string, string>()
77
+ const left: string[] = []
78
+ const fetch = async (key: string | null | undefined): Promise<string | undefined> => {
79
+ if (!key) return undefined
80
+ if (files.has(key)) return files.get(key)
81
+ let file = fileOf(key)
82
+ for (let n = 2; [...files.values()].includes(file); n++) file = `${n}-${fileOf(key)}`
83
+ try {
84
+ const bytes = await ws.getBytes(`/v1/sync/slide-themes/${encodeURIComponent(row.id)}/file?key=${encodeURIComponent(key)}`)
85
+ if (bytes.byteLength > IMAGE_LIMIT) throw new Error('larger than 20 MB')
86
+ it.assets!.push({ path: `themes/${name}/${file}`, bytes })
87
+ files.set(key, file)
88
+ return file
89
+ } catch (err) {
90
+ warnings.push(`Slide theme ${row.name}: ${file}: ${(err as Error).message}`)
91
+ left.push(file)
92
+ return undefined
93
+ }
94
+ }
95
+ const layouts: NonNullable<ThemeFile['layouts']> = []
96
+ for (const l of row.layouts ?? []) {
97
+ const image = await fetch(l.imageKey)
98
+ layouts.push({ kind: l.kind, hint: l.hint, ...(image ? { image } : {}) })
99
+ }
100
+ const assets: NonNullable<ThemeFile['assets']> = []
101
+ for (const a of row.assets ?? []) {
102
+ const file = await fetch(a.imageKey)
103
+ if (file) assets.push({ name: a.name, mime: a.mime, file })
104
+ }
105
+ const thumbnail = await fetch(row.thumbnailKey)
106
+ const doc: ThemeFile = {
107
+ name: row.name,
108
+ ...(row.styleDescription ? { styleDescription: row.styleDescription } : {}),
109
+ ...(row.source ? { source: row.source } : {}),
110
+ tokens: row.tokens,
111
+ ...(row.slideCount ? { slideCount: row.slideCount } : {}),
112
+ ...(layouts.length ? { layouts } : {}),
113
+ ...(assets.length ? { assets } : {}),
114
+ ...(thumbnail ? { thumbnail } : {}),
115
+ }
116
+ it.files[`themes/${name}/theme.json`] = `${JSON.stringify(doc, null, 2)}\n`
117
+ const n = it.assets!.length
118
+ if (n) it.notes.push(n === 1 ? '1 image (a slide reference, background or logo) comes too; a copy the project already has is kept' : `${n} images (slide references, backgrounds, logos) come too; any the project already has are kept`)
119
+ if (left.length) it.notes.push(`Not brought, so the theme is used without ${left.length === 1 ? 'it' : 'them'} here: ${left.join(', ')}`)
120
+ if (!it.assets!.length) delete it.assets
121
+ out.push(it)
122
+ }
123
+ return out
124
+ }
125
+
126
+ /** What `local dev` reads of a theme's folder: its file and the images it names. */
127
+ export function readThemeFolder(root: string, name: string): { doc: ThemeFile; files: Array<{ name: string; mime: string; data: string }>; missing: string[] } | null {
128
+ const dir = join(root, 'themes', name)
129
+ const path = join(dir, 'theme.json')
130
+ if (!existsSync(path)) return null
131
+ const doc = JSON.parse(readFileSync(path, 'utf8')) as ThemeFile
132
+ const named = [...new Set([...(doc.layouts ?? []).flatMap((l) => (l.image ? [l.image] : [])), ...(doc.assets ?? []).map((a) => a.file), ...(doc.thumbnail ? [doc.thumbnail] : [])])]
133
+ const present = new Set(readdirSync(dir))
134
+ const missing = named.filter((n) => !present.has(n))
135
+ const mimeOf = (file: string) => doc.assets?.find((a) => a.file === file)?.mime ?? (/\.png$/i.test(file) ? 'image/png' : /\.jpe?g$/i.test(file) ? 'image/jpeg' : /\.svg$/i.test(file) ? 'image/svg+xml' : /\.gif$/i.test(file) ? 'image/gif' : 'application/octet-stream')
136
+ const files = named.filter((n) => present.has(n)).map((n) => ({ name: n, mime: mimeOf(n), data: readFileSync(join(dir, n)).toString('base64') }))
137
+ return { doc, files, missing }
138
+ }
139
+
140
+ type Call = (method: string, path: string, body?: unknown) => Promise<{ status: number; body: unknown }>
141
+
142
+ export function themeCalls(serviceUrl: string, creds: LocalCredentials, fetcher: typeof fetch = fetch): Call {
143
+ return async (method, path, body) => {
144
+ const res = await fetcher(new URL(path, serviceUrl), {
145
+ method,
146
+ headers: {
147
+ authorization: `Bearer ${creds.session}`,
148
+ 'x-org-id': creds.organizationId,
149
+ 'x-workspace-id': creds.workspaceId,
150
+ ...(body === undefined ? {} : { 'content-type': 'application/json' }),
151
+ },
152
+ body: body === undefined ? undefined : JSON.stringify(body),
153
+ })
154
+ const text = await res.text()
155
+ let parsed: unknown = text
156
+ try {
157
+ parsed = text ? JSON.parse(text) : null
158
+ } catch {
159
+ // Keep the text.
160
+ }
161
+ return { status: res.status, body: parsed }
162
+ }
163
+ }
164
+
165
+ const errorOf = (a: { status: number; body: unknown }) => {
166
+ const b = a.body as { message?: unknown } | null
167
+ return `${a.status} ${String(typeof b?.message === 'string' ? b.message : JSON.stringify(b)).replace(/\s+/g, ' ').slice(0, 200)}`
168
+ }
169
+
170
+ /**
171
+ * Save a project's theme on the runtime: the runtime's theme of the same name
172
+ * is replaced (themes have no edit), so the Agent's gallery holds one. An
173
+ * image the file names but the folder lacks is said, and the theme is saved
174
+ * without it.
175
+ */
176
+ export async function applyTheme(root: string, name: string, call: Call): Promise<{ ok: boolean; output: string }> {
177
+ const read = readThemeFolder(root, name)
178
+ if (!read) return { ok: false, output: `error [THEME]: themes/${name}/theme.json is missing` }
179
+ const { doc, files, missing } = read
180
+ const lines: string[] = []
181
+ const keep = new Set(files.map((f) => f.name))
182
+ const theme = {
183
+ ...doc,
184
+ layouts: doc.layouts?.map((l) => (l.image && !keep.has(l.image) ? { kind: l.kind, hint: l.hint } : l)),
185
+ assets: doc.assets?.filter((a) => keep.has(a.file)),
186
+ thumbnail: doc.thumbnail && keep.has(doc.thumbnail) ? doc.thumbnail : undefined,
187
+ }
188
+ for (const m of missing) lines.push(` warning: theme ${name}: ${m} is not in themes/${name}/, so the theme is used without it`)
189
+ const listed = await call('GET', '/v1/slide-themes')
190
+ if (listed.status >= 400) return { ok: false, output: `error [THEME]: ${errorOf(listed)}` }
191
+ const existing = (((listed.body as { data?: unknown })?.data ?? listed.body) as Array<{ id: string; name: string; source?: string; canDelete?: boolean }>) ?? []
192
+ const saved = await call('POST', '/v1/slide-themes', { theme, files })
193
+ if (saved.status >= 400) return { ok: false, output: `error [THEME]: ${errorOf(saved)}` }
194
+ // The new one is saved; the one it replaces goes (built-ins cannot, and are never named alike).
195
+ for (const old of existing.filter((t) => t.name === doc.name && t.source !== 'builtin' && t.canDelete !== false)) {
196
+ const gone = await call('DELETE', `/v1/slide-themes/${encodeURIComponent(old.id)}`)
197
+ if (gone.status >= 400 && gone.status !== 404) lines.push(` warning: theme ${name}: the earlier copy could not be removed (${errorOf(gone)})`)
198
+ }
199
+ lines.unshift(` saved slide theme ${doc.name}`)
200
+ return { ok: true, output: lines.join('\n') }
201
+ }
@@ -0,0 +1,131 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import { parse as parseYaml } from 'yaml'
5
+
6
+ import { promoteWorkflow } from './local-dev-references'
7
+ import type { LocalProject } from './local-project'
8
+ import { readLock } from './local-sync-plan'
9
+ import type { LocalCredentials } from './local-state'
10
+
11
+ /**
12
+ * A workflow on the local runtime as it is in the workspace (design §8).
13
+ *
14
+ * `local dev` pushes every workflow as a draft. One that is live in the
15
+ * workspace (the lock records it at the pull) is promoted after each push,
16
+ * so its cron, webhook and Plugin triggers fire locally too, and its
17
+ * triggers are switched on or off as they are there.
18
+ *
19
+ * Safe locally: a trigger that fires on its own (a schedule, a Plugin's
20
+ * event) in a workflow with a step that calls another system directly (an
21
+ * HTTP step, a Plugin step) would make the same calls the workspace's runs
22
+ * make, twice over. Such a workflow is promoted with its triggers off here,
23
+ * and says so; it runs when started by hand, an Agent or a webhook.
24
+ */
25
+
26
+ type Doc = Record<string, unknown>
27
+
28
+ /** The kinds of the triggers that start runs on their own. */
29
+ export function selfFiringTriggers(doc: unknown): string[] {
30
+ // The definition lists them under `trigger`, as the service's schema names it.
31
+ const triggers = Array.isArray((doc as Doc | null)?.trigger) ? ((doc as Doc).trigger as Doc[]) : []
32
+ return [...new Set(triggers.flatMap((t) => (t?.kind === 'cron' || t?.kind === 'plugin' ? [String(t.kind)] : [])))]
33
+ }
34
+
35
+ /** The ids of steps that call another system directly, at any depth (branches, loops, parallel arms). */
36
+ export function externalSteps(doc: unknown): string[] {
37
+ const out: string[] = []
38
+ const visit = (v: unknown, inNodes: boolean) => {
39
+ if (Array.isArray(v)) return v.forEach((x) => visit(x, inNodes))
40
+ if (!v || typeof v !== 'object') return
41
+ const o = v as Doc
42
+ if (inNodes && (o.kind === 'http' || o.kind === 'plugin') && typeof o.id === 'string') out.push(o.id)
43
+ for (const [k, x] of Object.entries(o)) if (k !== 'trigger' && k !== 'layout') visit(x, true)
44
+ }
45
+ visit((doc as Doc | null)?.nodes ?? [], true)
46
+ return out
47
+ }
48
+
49
+ const WORDS: Record<string, string> = { cron: 'schedule', plugin: 'Plugin trigger' }
50
+
51
+ /** What a workflow's triggers should be here: on as in the workspace, unless firing them would reach outside. */
52
+ export function localTriggersOn(doc: unknown, enabledInWorkspace: boolean): { on: boolean; why?: string } {
53
+ if (!enabledInWorkspace) return { on: false }
54
+ const firing = selfFiringTriggers(doc)
55
+ const external = externalSteps(doc)
56
+ if (!firing.length || !external.length) return { on: true }
57
+ const which = firing.map((k) => WORDS[k] ?? k).join(' and ')
58
+ return { on: false, why: `Its ${which} doesn’t fire here: steps ${external.join(', ')} call other systems, as the workspace’s runs do; start it by hand, from an Agent or by its webhook` }
59
+ }
60
+
61
+ /** The `decide` steps of a workflow, at any depth: id and the model each asks. */
62
+ export function decideSteps(doc: unknown): Array<{ id: string; model: string }> {
63
+ const out: Array<{ id: string; model: string }> = []
64
+ const visit = (v: unknown) => {
65
+ if (Array.isArray(v)) return v.forEach(visit)
66
+ if (!v || typeof v !== 'object') return
67
+ const o = v as Doc
68
+ if (o.kind === 'decide' && typeof o.id === 'string') out.push({ id: o.id, model: String((o.params as Doc | undefined)?.model ?? DECIDE_DEFAULT_MODEL) })
69
+ for (const x of Object.values(o)) visit(x)
70
+ }
71
+ visit((doc as Doc | null)?.nodes ?? [])
72
+ return out
73
+ }
74
+
75
+ /** The model a `decide` step asks when it names none (the service's `DECIDE_DEFAULT_MODEL`). */
76
+ const DECIDE_DEFAULT_MODEL = 'typesafe/jev-1.13'
77
+
78
+ /**
79
+ * A decide step asks its model through OpenRouter's decisions API with the
80
+ * organization's OpenRouter key, whatever model the Agents use, so its model
81
+ * is not swapped for one in the runtime's catalog: it needs that key here.
82
+ * Without one, it says so (an adaptation) instead of failing at the first run.
83
+ */
84
+ export function decideStepNotes(project: LocalProject, file: string, creds: LocalCredentials): string[] {
85
+ let doc: unknown = null
86
+ try {
87
+ doc = parseYaml(readFileSync(join(project.root, file), 'utf8'))
88
+ } catch {
89
+ return []
90
+ }
91
+ const steps = decideSteps(doc)
92
+ if (!steps.length || creds.modelProviders?.includes('openrouter')) return []
93
+ return [` warning: Decide step${steps.length === 1 ? '' : 's'} ${steps.map((s) => s.id).join(', ')} ask${steps.length === 1 ? 's' : ''} ${[...new Set(steps.map((s) => s.model))].join(', ')} through OpenRouter, and this runtime has no OpenRouter key: add one in Forge → Models`]
94
+ }
95
+
96
+ async function setEnabled(serviceUrl: string, creds: LocalCredentials, slug: string, enabled: boolean): Promise<string | null> {
97
+ try {
98
+ const res = await fetch(new URL(`/v1/workflows/${encodeURIComponent(slug)}`, serviceUrl), {
99
+ method: 'PATCH',
100
+ headers: { authorization: `Bearer ${creds.workspaceKey}`, 'content-type': 'application/json' },
101
+ body: JSON.stringify({ enabled }),
102
+ })
103
+ return res.ok ? null : `${res.status} ${(await res.text()).slice(0, 200)}`
104
+ } catch (err) {
105
+ return (err as Error).message
106
+ }
107
+ }
108
+
109
+ /**
110
+ * After a push: promote and switch triggers as in the workspace, for a
111
+ * workflow the lock says is live there. Returns lines for the apply's output;
112
+ * a `warning:` line is an adaptation in the build.
113
+ */
114
+ export async function mirrorWorkflowState(project: LocalProject, slug: string, file: string, serviceUrl: string, creds: LocalCredentials): Promise<string[]> {
115
+ const state = readLock(project.root)?.items[`workflow/${slug}`]?.state
116
+ if (!state?.live || !creds.workspaceKey) return []
117
+ let doc: unknown = null
118
+ try {
119
+ doc = parseYaml(readFileSync(join(project.root, file), 'utf8'))
120
+ } catch {
121
+ return []
122
+ }
123
+ const lines: string[] = []
124
+ const promoted = await promoteWorkflow(serviceUrl, creds, slug)
125
+ if (promoted) lines.push(` promoted workflow ${slug} v${promoted}: it is live in the workspace`)
126
+ const want = localTriggersOn(doc, state.enabled)
127
+ const failed = await setEnabled(serviceUrl, creds, slug, want.on)
128
+ if (failed) lines.push(` warning: Its triggers could not be switched ${want.on ? 'on' : 'off'} here: ${failed}`)
129
+ else if (want.why) lines.push(` warning: ${want.why}`)
130
+ return lines
131
+ }