@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.
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,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
+ }