@frontera-sdk/cli 1.52.28 → 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.
- 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,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
|
+
}
|