@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,237 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
import { stringify as stringifyYaml } from 'yaml'
|
|
7
|
+
|
|
8
|
+
import { toFiles } from '../blueprint/projection'
|
|
9
|
+
import { serialize } from '../blueprint/tree'
|
|
10
|
+
import { hash, readLocal, safePath, type LockEntry, type SyncKind } from './local-sync-plan'
|
|
11
|
+
import { projectAgent, type SyncSource } from './local-sync-remote'
|
|
12
|
+
import { toNames, type Directory } from './local-sync-refs'
|
|
13
|
+
import { readBase, SYNC_DIR } from './local-sync-snapshot'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Rebuilding a merge base the project lost. `.frontera/sync/base/` is not
|
|
17
|
+
* committed and the lock is, so a fresh clone, a cleaned folder or a pull
|
|
18
|
+
* by an older CLI leaves a merge with the lock's hashes and no content: it
|
|
19
|
+
* then merges only what both sides agree on and conflicts on the rest.
|
|
20
|
+
*
|
|
21
|
+
* Before such a merge, each base file is looked for where it may still be,
|
|
22
|
+
* and kept only when it hashes to what the lock recorded:
|
|
23
|
+
*
|
|
24
|
+
* 1. the fetched snapshot or the working file, when either still is the base
|
|
25
|
+
* (the workspace or this project did not change that file);
|
|
26
|
+
* 2. the platform, at the revision the lock records (`GET /v1/sync/item`,
|
|
27
|
+
* feature `item-revision`), turned into files as a pull would write them.
|
|
28
|
+
*
|
|
29
|
+
* The platform keeps earlier versions of Agents (published versions),
|
|
30
|
+
* workflows, Functions and the Blueprint (releases); not of Knowledge,
|
|
31
|
+
* Skills, Plugins or evaluations. Where nothing is found, the merge falls
|
|
32
|
+
* back as before, and the result says why.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Kinds the platform keeps earlier versions of. */
|
|
36
|
+
export const HISTORY_KINDS: readonly SyncKind[] = ['agent', 'workflow', 'function', 'blueprint']
|
|
37
|
+
|
|
38
|
+
export type BaseGap = 'offline' | 'no-route' | 'no-history' | 'no-revision' | 'not-kept' | 'differs' | 'failed'
|
|
39
|
+
|
|
40
|
+
export interface BaseRepair {
|
|
41
|
+
ref: string
|
|
42
|
+
/** Base files written back, each matching the lock. */
|
|
43
|
+
restored: string[]
|
|
44
|
+
/** Base files still missing: the merge has no base for them. */
|
|
45
|
+
missing: string[]
|
|
46
|
+
/** Read from the platform at this revision. */
|
|
47
|
+
revision?: string
|
|
48
|
+
/** Why the missing ones could not be found. */
|
|
49
|
+
gap?: BaseGap
|
|
50
|
+
/** The platform's message when the read failed. */
|
|
51
|
+
error?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface RepairOptions {
|
|
55
|
+
/** The platform, with the project's keys; without it only local copies are tried. */
|
|
56
|
+
source?: SyncSource
|
|
57
|
+
/** The sync features the platform said it has at the last fetch. */
|
|
58
|
+
capabilities?: string[]
|
|
59
|
+
/** The workspace's names at the last fetch, to write ids as names as a pull does. */
|
|
60
|
+
directory?: Directory
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RepairTarget {
|
|
64
|
+
ref: string
|
|
65
|
+
entry: LockEntry
|
|
66
|
+
/** The fetched snapshot's files for the item. */
|
|
67
|
+
remote?: Record<string, string>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The lock's paths whose base content is gone or no longer matches. */
|
|
71
|
+
export function missingBase(root: string, entry: LockEntry | undefined): string[] {
|
|
72
|
+
return Object.entries(entry?.files ?? {}).flatMap(([path, wrote]) => {
|
|
73
|
+
const content = readBase(root, path)
|
|
74
|
+
return content !== null && hash(content) === wrote ? [] : [path]
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function writeBaseFile(root: string, path: string, content: string): void {
|
|
79
|
+
// Inside the project, and inside the base folder, as every base write is.
|
|
80
|
+
safePath(root, path)
|
|
81
|
+
const full = safePath(join(root, SYNC_DIR, 'base'), path)
|
|
82
|
+
mkdirSync(dirname(full), { recursive: true })
|
|
83
|
+
writeFileSync(full, content)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** What to ask the platform for: the Agent's version number, otherwise the lock's revision. */
|
|
87
|
+
export function revisionFor(kind: SyncKind, entry: LockEntry): string | null {
|
|
88
|
+
// An Agent's index revision is a hash; its version (`v12`) names the published version.
|
|
89
|
+
if (kind === 'agent') return entry.version && /^v\d+$/.test(entry.version) ? entry.version : null
|
|
90
|
+
return entry.revision ?? null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ItemAnswer { ref: string; kind: string; revision: string; version?: string; content: Record<string, unknown> }
|
|
94
|
+
|
|
95
|
+
const json = (value: unknown) => `${JSON.stringify(value, null, 2)}\n`
|
|
96
|
+
|
|
97
|
+
/** Only the paths asked for, out of a Function's source archive: `functions/<slug>.ts` as it is, or a folder's files under `functions/<slug>/`. */
|
|
98
|
+
function fromArchive(bytes: Uint8Array, slug: string, paths: string[]): Record<string, string> {
|
|
99
|
+
const dir = mkdtempSync(join(tmpdir(), 'frontera-sync-base-'))
|
|
100
|
+
try {
|
|
101
|
+
const archive = join(dir, 'src.tgz')
|
|
102
|
+
writeFileSync(archive, bytes)
|
|
103
|
+
const out = join(dir, 'out')
|
|
104
|
+
mkdirSync(out)
|
|
105
|
+
if (spawnSync('tar', ['-xzf', archive, '-C', out], { stdio: 'ignore' }).status !== 0) throw new Error('the source archive could not be unpacked')
|
|
106
|
+
const files: Record<string, string> = {}
|
|
107
|
+
for (const path of paths) {
|
|
108
|
+
const folder = `functions/${slug}/`
|
|
109
|
+
for (const inside of path.startsWith(folder) ? [path.slice(folder.length), path] : [path]) {
|
|
110
|
+
const full = join(out, inside)
|
|
111
|
+
if (!full.startsWith(`${out}/`) || !existsSync(full)) continue
|
|
112
|
+
files[path] = readFileSync(full, 'utf8')
|
|
113
|
+
break
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return files
|
|
117
|
+
} finally {
|
|
118
|
+
rmSync(dir, { recursive: true, force: true })
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* An item's files at a revision, as a pull writes them: the same projection
|
|
124
|
+
* (`projectAgent`, ids written as names, YAML for workflows, the Blueprint's
|
|
125
|
+
* files), from the platform's raw kept version. What the platform does not
|
|
126
|
+
* version (an Agent's `bindings.json`) is not produced.
|
|
127
|
+
*/
|
|
128
|
+
export async function projectAtRevision(source: SyncSource, ref: string, answer: ItemAnswer, paths: string[], directory: Directory = {}): Promise<Record<string, string>> {
|
|
129
|
+
const [kind, ...rest] = ref.split('/')
|
|
130
|
+
const name = rest.join('/')
|
|
131
|
+
const c = answer.content
|
|
132
|
+
if (kind === 'agent') {
|
|
133
|
+
const snapshot = (c.snapshot ?? {}) as Record<string, unknown>
|
|
134
|
+
const files: Record<string, string> = { [`agents/${name}/agent.json`]: json(toNames(projectAgent(snapshot, []), directory).doc) }
|
|
135
|
+
const operating = (snapshot.config as Record<string, unknown> | undefined)?.operatingConfig
|
|
136
|
+
if (operating && typeof operating === 'object' && Object.keys(operating).length) files[`agents/${name}/operating.json`] = json(toNames(operating, directory).doc)
|
|
137
|
+
return files
|
|
138
|
+
}
|
|
139
|
+
if (kind === 'workflow') {
|
|
140
|
+
const definition = (c.definition ?? {}) as Record<string, unknown>
|
|
141
|
+
const doc = c.layout ? { ...definition, layout: c.layout } : definition
|
|
142
|
+
return { [`workflows/${name}.yaml`]: stringifyYaml(toNames(doc, directory).doc, { lineWidth: 0 }) }
|
|
143
|
+
}
|
|
144
|
+
if (kind === 'function') {
|
|
145
|
+
if (typeof c.source !== 'string' || !c.source.startsWith('/v1/functions/')) return {}
|
|
146
|
+
return fromArchive(await source.workspace.getBytes(c.source), name, paths)
|
|
147
|
+
}
|
|
148
|
+
if (kind === 'blueprint') {
|
|
149
|
+
if (!c.definition) return {}
|
|
150
|
+
const model = toFiles(c.definition as Parameters<typeof toFiles>[0], new Map(Object.entries((c.datasetNames ?? {}) as Record<string, string>)))
|
|
151
|
+
return Object.fromEntries(model.files.filter((f) => !f.path.startsWith('actions/')).map((f) => [`blueprint/${f.path}`, serialize(f.path, f.document as Record<string, unknown>)]))
|
|
152
|
+
}
|
|
153
|
+
return {}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Find and write back the base of each item whose base is missing, as far
|
|
158
|
+
* as it can be found. Items whose base is whole are left out of the answer.
|
|
159
|
+
*/
|
|
160
|
+
export async function repairBases(root: string, targets: RepairTarget[], opts: RepairOptions = {}): Promise<BaseRepair[]> {
|
|
161
|
+
const out: BaseRepair[] = []
|
|
162
|
+
// The Blueprint's items share one release: read it once per revision.
|
|
163
|
+
const answers = new Map<string, Promise<ItemAnswer | null>>()
|
|
164
|
+
for (const { ref, entry, remote } of targets) {
|
|
165
|
+
let missing = missingBase(root, entry)
|
|
166
|
+
if (!missing.length) continue
|
|
167
|
+
const restored: string[] = []
|
|
168
|
+
const take = (path: string, content: string | null | undefined) => {
|
|
169
|
+
if (content === null || content === undefined || hash(content) !== entry.files[path]) return false
|
|
170
|
+
writeBaseFile(root, path, content)
|
|
171
|
+
restored.push(path)
|
|
172
|
+
return true
|
|
173
|
+
}
|
|
174
|
+
// 1. A copy that still is the base: the workspace's or this project's file, unchanged since the pull.
|
|
175
|
+
missing = missing.filter((path) => !(take(path, remote?.[path]) || take(path, readLocal(root, path))))
|
|
176
|
+
const report: BaseRepair = { ref, restored, missing }
|
|
177
|
+
out.push(report)
|
|
178
|
+
if (!missing.length) continue
|
|
179
|
+
|
|
180
|
+
// 2. The platform, at the revision the lock records.
|
|
181
|
+
const kind = ref.split('/')[0] as SyncKind
|
|
182
|
+
const revision = revisionFor(kind, entry)
|
|
183
|
+
if (!opts.source) {
|
|
184
|
+
report.gap = 'offline'
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (!(opts.capabilities ?? []).includes('item-revision')) {
|
|
188
|
+
report.gap = 'no-route'
|
|
189
|
+
continue
|
|
190
|
+
}
|
|
191
|
+
if (!HISTORY_KINDS.includes(kind)) {
|
|
192
|
+
report.gap = 'no-history'
|
|
193
|
+
continue
|
|
194
|
+
}
|
|
195
|
+
if (!revision) {
|
|
196
|
+
report.gap = 'no-revision'
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
report.revision = revision
|
|
200
|
+
const asked = kind === 'blueprint' ? 'blueprint' : ref
|
|
201
|
+
const key = `${asked}@${revision}`
|
|
202
|
+
if (!answers.has(key)) answers.set(key, opts.source.workspace.find<ItemAnswer>(`/v1/sync/item?ref=${encodeURIComponent(asked)}&revision=${encodeURIComponent(revision)}`))
|
|
203
|
+
try {
|
|
204
|
+
const answer = await answers.get(key)!
|
|
205
|
+
if (!answer) {
|
|
206
|
+
report.gap = 'not-kept'
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
const files = await projectAtRevision(opts.source, ref, answer, missing, opts.directory)
|
|
210
|
+
missing = missing.filter((path) => !take(path, files[path]))
|
|
211
|
+
report.missing = missing
|
|
212
|
+
if (missing.length) report.gap = 'differs'
|
|
213
|
+
} catch (err) {
|
|
214
|
+
report.gap = 'failed'
|
|
215
|
+
report.error = (err as Error).message
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const KIND_WORD: Partial<Record<SyncKind, string>> = { knowledge: 'Knowledge', skill: 'Skills', plugin: 'Plugins', evaluation: 'evaluations', dataset: 'datasets' }
|
|
222
|
+
|
|
223
|
+
/** One line on what happened to an item's base, for `pull` and `diff`. */
|
|
224
|
+
export function describeRepair(r: BaseRepair): string {
|
|
225
|
+
if (!r.missing.length) return `${r.ref}: its base was missing; ${r.revision ? `re-read at ${r.revision} from the workspace` : 'recovered from an unchanged copy'}.`
|
|
226
|
+
const kind = r.ref.split('/')[0] as SyncKind
|
|
227
|
+
const why: Record<BaseGap, string> = {
|
|
228
|
+
offline: 'not read from the platform here',
|
|
229
|
+
'no-route': 'this platform cannot return earlier versions yet',
|
|
230
|
+
'no-history': `the platform keeps no earlier versions of ${KIND_WORD[kind] ?? kind}`,
|
|
231
|
+
'no-revision': 'the lock records no revision to read it at',
|
|
232
|
+
'not-kept': `the platform no longer keeps ${r.revision}`,
|
|
233
|
+
differs: `the version read at ${r.revision} differs from what was pulled`,
|
|
234
|
+
failed: `the platform did not answer (${r.error ?? 'error'})`,
|
|
235
|
+
}
|
|
236
|
+
return `${r.ref}: no base for ${r.missing.length === 1 ? r.missing[0] : `${r.missing.length} files`} (${why[r.gap ?? 'failed']}); it merges from what both sides agree on.`
|
|
237
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { CliError } from '../errors'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The calls a sync makes: to the platform with the project's workspace (or
|
|
5
|
+
* organization) key, and to the local runtime with its own keys. Plain
|
|
6
|
+
* `fetch` with an explicit origin and key, never a CLI profile, so a pull
|
|
7
|
+
* cannot read from or write to whatever deployment a folder happens to be
|
|
8
|
+
* bound to.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface SyncClient {
|
|
12
|
+
origin: string
|
|
13
|
+
get<T>(path: string): Promise<T>
|
|
14
|
+
/** Like `get`, but a 404 is `null` rather than an error. */
|
|
15
|
+
find<T>(path: string): Promise<T | null>
|
|
16
|
+
getBytes(path: string): Promise<Uint8Array>
|
|
17
|
+
post<T>(path: string, body: unknown): Promise<T>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Responses come as `{ data }` envelopes or bare; callers want what is inside. */
|
|
21
|
+
export function unwrap<T>(body: unknown): T {
|
|
22
|
+
if (body && typeof body === 'object' && 'data' in (body as Record<string, unknown>) && Object.keys(body as object).every((k) => ['data', 'message', 'error', 'success', 'ok', 'meta', 'pagination', 'total', 'nextCursor'].includes(k))) {
|
|
23
|
+
return (body as { data: T }).data
|
|
24
|
+
}
|
|
25
|
+
return body as T
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeOrigin(raw: string): string {
|
|
29
|
+
let url: URL
|
|
30
|
+
try {
|
|
31
|
+
url = new URL(raw.includes('://') ? raw : `https://${raw}`)
|
|
32
|
+
} catch {
|
|
33
|
+
throw new CliError(`"${raw}" is not a platform address`, { code: 'USAGE', hint: 'give the platform API origin, e.g. https://api.acme.frontera.example' })
|
|
34
|
+
}
|
|
35
|
+
if (url.protocol !== 'https:' && !['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname)) {
|
|
36
|
+
throw new CliError('a platform key is only sent over https', { code: 'USAGE', hint: `use https://${url.host}` })
|
|
37
|
+
}
|
|
38
|
+
return url.origin
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function syncClient(origin: string, key: string, headers: Record<string, string> = {}): SyncClient {
|
|
42
|
+
const call = async (path: string, init: { method?: string; body?: unknown } = {}): Promise<Response> => {
|
|
43
|
+
let res: Response
|
|
44
|
+
try {
|
|
45
|
+
res = await fetch(new URL(path, origin), {
|
|
46
|
+
method: init.method ?? 'GET',
|
|
47
|
+
headers: { authorization: `Bearer ${key}`, accept: 'application/json', ...(init.body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers },
|
|
48
|
+
body: init.body === undefined ? undefined : JSON.stringify(init.body),
|
|
49
|
+
signal: AbortSignal.timeout(30_000),
|
|
50
|
+
})
|
|
51
|
+
} catch (err) {
|
|
52
|
+
throw new CliError(`${origin} did not answer: ${(err as Error).message}`, { code: 'UNAVAILABLE', hint: 'check the address and your network' })
|
|
53
|
+
}
|
|
54
|
+
return res
|
|
55
|
+
}
|
|
56
|
+
const fail = async (res: Response, path: string): Promise<never> => {
|
|
57
|
+
const text = await res.text().catch(() => '')
|
|
58
|
+
let message = text.slice(0, 300)
|
|
59
|
+
try {
|
|
60
|
+
const body = JSON.parse(text) as { message?: string; error?: string | { message?: string } }
|
|
61
|
+
message = body.message ?? (typeof body.error === 'string' ? body.error : body.error?.message) ?? message
|
|
62
|
+
} catch {
|
|
63
|
+
// Keep the text.
|
|
64
|
+
}
|
|
65
|
+
if (res.status === 401) throw new CliError('the platform refused the key', { code: 'UNAUTHORIZED', hint: 'check it is a current workspace key (sk-ws-…) for this platform' })
|
|
66
|
+
if (res.status === 403) throw new CliError(`the key may not read ${path}: ${message}`, { code: 'FORBIDDEN', hint: 'the key’s role decides what it reads; ask a workspace admin for a key that can read it' })
|
|
67
|
+
throw new CliError(`${path} answered ${res.status}: ${message}`, { code: res.status === 404 ? 'NOT_FOUND' : 'UNAVAILABLE', hint: 'check the platform is reachable, then run the command again' })
|
|
68
|
+
}
|
|
69
|
+
const json = async <T>(res: Response, path: string): Promise<T> => {
|
|
70
|
+
if (!res.ok) return fail(res, path)
|
|
71
|
+
const text = await res.text()
|
|
72
|
+
return unwrap<T>(text ? JSON.parse(text) : null)
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
origin,
|
|
76
|
+
async get<T>(path: string) {
|
|
77
|
+
return json<T>(await call(path), path)
|
|
78
|
+
},
|
|
79
|
+
async find<T>(path: string) {
|
|
80
|
+
const res = await call(path)
|
|
81
|
+
if (res.status === 404) return null
|
|
82
|
+
return json<T>(res, path)
|
|
83
|
+
},
|
|
84
|
+
async getBytes(path: string) {
|
|
85
|
+
const res = await call(path)
|
|
86
|
+
if (!res.ok) return fail(res, path)
|
|
87
|
+
return new Uint8Array(await res.arrayBuffer())
|
|
88
|
+
},
|
|
89
|
+
async post<T>(path: string, body: unknown) {
|
|
90
|
+
return json<T>(await call(path, { method: 'POST', body }), path)
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { parseDocument } from 'yaml'
|
|
6
|
+
|
|
7
|
+
import { writeFileAtomic } from '../fs-atomic'
|
|
8
|
+
import { configDir } from '../paths'
|
|
9
|
+
import { PROJECT_FILES, type LocalProject } from './local-project'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A project's link to one platform workspace, and the keys that read it.
|
|
13
|
+
*
|
|
14
|
+
* The link is committed in `frontera.yaml` (`sync:`), so a teammate who
|
|
15
|
+
* clones the project sees which workspace it came from. The keys are not:
|
|
16
|
+
* they live in the macOS keychain under the project's id (a 0600 file
|
|
17
|
+
* elsewhere), as model provider keys do, and never in the folder.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface SyncLink {
|
|
21
|
+
/** The platform's API origin, e.g. `https://acme.frontera.example`. */
|
|
22
|
+
platform: string
|
|
23
|
+
workspace: string
|
|
24
|
+
/** Set when an organization key was given. */
|
|
25
|
+
organization?: string
|
|
26
|
+
/** `workspace`: the workspace's slice of the Blueprint (default). `organization`: the whole model. */
|
|
27
|
+
blueprint: 'workspace' | 'organization'
|
|
28
|
+
/** Names shown in Forge and `sync status`; refreshed on connect. */
|
|
29
|
+
workspaceName?: string
|
|
30
|
+
organizationName?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function projectFile(root: string): string {
|
|
34
|
+
return join(root, PROJECT_FILES.find((f) => existsSync(join(root, f))) ?? PROJECT_FILES[0])
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function readLink(root: string): SyncLink | null {
|
|
38
|
+
const file = projectFile(root)
|
|
39
|
+
if (!existsSync(file)) return null
|
|
40
|
+
const doc = parseDocument(readFileSync(file, 'utf8')).toJS() as { sync?: Partial<SyncLink> } | null
|
|
41
|
+
const s = doc?.sync
|
|
42
|
+
if (!s || typeof s.platform !== 'string' || typeof s.workspace !== 'string') return null
|
|
43
|
+
return {
|
|
44
|
+
platform: s.platform,
|
|
45
|
+
workspace: s.workspace,
|
|
46
|
+
organization: typeof s.organization === 'string' ? s.organization : undefined,
|
|
47
|
+
blueprint: s.blueprint === 'organization' ? 'organization' : 'workspace',
|
|
48
|
+
workspaceName: typeof s.workspaceName === 'string' ? s.workspaceName : undefined,
|
|
49
|
+
organizationName: typeof s.organizationName === 'string' ? s.organizationName : undefined,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Write the link into `frontera.yaml`, keeping the rest of the file and its comments. */
|
|
54
|
+
export function writeLink(root: string, link: SyncLink | null): void {
|
|
55
|
+
const file = projectFile(root)
|
|
56
|
+
const doc = parseDocument(existsSync(file) ? readFileSync(file, 'utf8') : '')
|
|
57
|
+
if (!doc.contents) doc.contents = doc.createNode({}) as never
|
|
58
|
+
if (!link) {
|
|
59
|
+
doc.delete('sync')
|
|
60
|
+
} else {
|
|
61
|
+
const value: Record<string, string> = { platform: link.platform, workspace: link.workspace }
|
|
62
|
+
if (link.workspaceName) value.workspaceName = link.workspaceName
|
|
63
|
+
if (link.organization) value.organization = link.organization
|
|
64
|
+
if (link.organizationName) value.organizationName = link.organizationName
|
|
65
|
+
value.blueprint = link.blueprint
|
|
66
|
+
const existing = doc.get('sync', true) as { commentBefore?: string } | undefined
|
|
67
|
+
const node = doc.createNode(value) as unknown as { commentBefore?: string }
|
|
68
|
+
node.commentBefore = existing?.commentBefore ?? ' The platform workspace this project pulls from (`frontera local sync`). Keys are in your keychain, not here.'
|
|
69
|
+
doc.set('sync', node)
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(file, doc.toString())
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Keys ─────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const SERVICE = 'frontera-sync-key'
|
|
77
|
+
export type KeyRole = 'workspace' | 'organization'
|
|
78
|
+
|
|
79
|
+
function useKeychain(): boolean {
|
|
80
|
+
return process.platform === 'darwin' && !process.env.FRONTERA_SYNC_KEYS_FILE
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function keysFile(): string {
|
|
84
|
+
return process.env.FRONTERA_SYNC_KEYS_FILE ?? join(configDir(), 'sync-keys.json')
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function readKeysFile(): Record<string, string> {
|
|
88
|
+
try {
|
|
89
|
+
return existsSync(keysFile()) ? (JSON.parse(readFileSync(keysFile(), 'utf8')) as Record<string, string>) : {}
|
|
90
|
+
} catch {
|
|
91
|
+
return {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const account = (project: LocalProject, role: KeyRole) => `${project.id}:${role}`
|
|
96
|
+
|
|
97
|
+
export function setSyncKey(project: LocalProject, role: KeyRole, key: string): void {
|
|
98
|
+
if (useKeychain()) {
|
|
99
|
+
const res = spawnSync('security', ['add-generic-password', '-U', '-s', SERVICE, '-a', account(project, role), '-w', key], { stdio: 'ignore' })
|
|
100
|
+
if (res.status !== 0) throw new Error('the macOS keychain refused the key')
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
writeFileAtomic(keysFile(), `${JSON.stringify({ ...readKeysFile(), [account(project, role)]: key }, null, 2)}\n`, 0o600)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getSyncKey(project: LocalProject, role: KeyRole): string | null {
|
|
107
|
+
if (useKeychain()) {
|
|
108
|
+
const res = spawnSync('security', ['find-generic-password', '-s', SERVICE, '-a', account(project, role), '-w'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
109
|
+
return res.status === 0 ? res.stdout.trim() || null : null
|
|
110
|
+
}
|
|
111
|
+
return readKeysFile()[account(project, role)] ?? null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function removeSyncKey(project: LocalProject, role: KeyRole): void {
|
|
115
|
+
if (useKeychain()) {
|
|
116
|
+
spawnSync('security', ['delete-generic-password', '-s', SERVICE, '-a', account(project, role)], { stdio: 'ignore' })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const keys = readKeysFile()
|
|
120
|
+
delete keys[account(project, role)]
|
|
121
|
+
writeFileAtomic(keysFile(), `${JSON.stringify(keys, null, 2)}\n`, 0o600)
|
|
122
|
+
}
|