@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,717 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { dirname, join, relative, resolve, sep } from 'node:path'
4
+
5
+ import { parse as parseYaml } from 'yaml'
6
+
7
+ import type { Portability } from './local-portability'
8
+ import { conflictsId, deepEqual, mergeItemFiles, type Choice, type Side } from './local-sync-merge'
9
+ import { itemTitle } from './local-sync-names'
10
+ import { toNames, type Directory, type Unresolved } from './local-sync-refs'
11
+ import { comparableText, isPluginFile, withLocalSecrets } from './local-secrets'
12
+
13
+ /**
14
+ * A pull is a plan first. Each item in the workspace (an Agent with its
15
+ * behavior settings, a workflow, an object type…) becomes the files the
16
+ * project would hold; the plan compares them with what the last pull wrote
17
+ * (`frontera.lock`) and with what is in the folder now, and says per item
18
+ * whether pulling it is new, a plain update, or would discard local edits.
19
+ * Only what the person selects is written.
20
+ */
21
+
22
+ /**
23
+ * `dataset` is declared and never read from a workspace: a dataset's rows are
24
+ * the workspace's records (design §8), so `datasets/` is the project's own
25
+ * (sample data from `frontera local data`, or files written by hand) and no
26
+ * pull produces a `dataset/` item. It stays a kind so `--kinds datasets`, a
27
+ * lock written with one and the change-request kinds keep reading as before.
28
+ *
29
+ * `lessons` is an Agent's `lessons.yaml` as a change request carries it
30
+ * (`lessons/<slug>`): brought by `pull --lessons`, never listed by a fetch or
31
+ * kept in the lock, and proposed back as rules for the platform's reviewers.
32
+ */
33
+ export const SYNC_KINDS = ['blueprint', 'dataset', 'skill', 'function', 'plugin', 'knowledge', 'workflow', 'agent', 'evaluation', 'theme', 'chat-app', 'app', 'brand', 'library', 'lessons'] as const
34
+ export type SyncKind = (typeof SYNC_KINDS)[number]
35
+
36
+ export const KIND_LABEL: Record<SyncKind, string> = {
37
+ blueprint: 'Blueprint',
38
+ dataset: 'Datasets',
39
+ skill: 'Skills',
40
+ function: 'Functions',
41
+ plugin: 'Plugins',
42
+ knowledge: 'Knowledge',
43
+ workflow: 'Workflows',
44
+ agent: 'Agents',
45
+ evaluation: 'Evaluations',
46
+ theme: 'Slide themes',
47
+ 'chat-app': 'Chat Apps',
48
+ app: 'Apps',
49
+ brand: 'Brand',
50
+ library: 'Library',
51
+ lessons: 'Lessons',
52
+ }
53
+
54
+ /** One workspace item as the project would hold it. */
55
+ export interface RemoteItem {
56
+ /** `agent/support-concierge`: the item's kind and name. */
57
+ ref: string
58
+ kind: SyncKind
59
+ name: string
60
+ /** What people call it: the display name, beside the name it is filed under. */
61
+ title: string
62
+ /** Project-relative path → content. */
63
+ files: Record<string, string>
64
+ /** The workspace's version of it, when it has one (a workflow's live version number). */
65
+ version?: string
66
+ /** The platform's id for it: survives a rename. */
67
+ id?: string
68
+ /** The platform's revision of it, from the sync index; opaque. */
69
+ revision?: string
70
+ /** References the pull could not name; the item is pulled with them only once confirmed. */
71
+ unresolved: Unresolved[]
72
+ /** What the project's shape cannot hold, said rather than dropped. */
73
+ notes: string[]
74
+ /** The other items it names (`function/refund-eligibility`), so a pull can bring them too. */
75
+ requires?: string[]
76
+ /** Whether it runs on the local runtime, and how (design §8): computed when it is read. */
77
+ portability?: Portability
78
+ /** A workflow's or Function's state on the platform: whether the version read is live, and whether it is switched on. */
79
+ state?: ItemState
80
+ /**
81
+ * Binary files that come with it (a skill's images, fonts, templates), kept
82
+ * apart from `files`: they are not merged or tracked. A pull writes each one
83
+ * the project does not have yet and never overwrites one. `bytes` while it
84
+ * is being read; `from`, the snapshot's copy, once fetched.
85
+ */
86
+ assets?: ItemAsset[]
87
+ }
88
+
89
+ export interface ItemAsset {
90
+ path: string
91
+ bytes?: Uint8Array
92
+ from?: string
93
+ }
94
+
95
+ /** Write the assets the project does not have yet; never one already there. Returns the paths written. */
96
+ export function placeAssets(root: string, item: Pick<RemoteItem, 'assets'>): string[] {
97
+ const out: string[] = []
98
+ for (const asset of item.assets ?? []) {
99
+ const full = safePath(root, asset.path)
100
+ if (existsSync(full)) continue
101
+ let bytes = asset.bytes
102
+ if (!bytes && asset.from && existsSync(asset.from)) bytes = new Uint8Array(readFileSync(asset.from))
103
+ if (!bytes) continue
104
+ mkdirSync(dirname(full), { recursive: true })
105
+ writeFileSync(full, bytes)
106
+ out.push(asset.path)
107
+ }
108
+ return out
109
+ }
110
+
111
+ /** A workflow's or Function's state on the platform, kept in the lock so `local dev` mirrors it (live, switched on or off). */
112
+ export interface ItemState {
113
+ live: boolean
114
+ enabled: boolean
115
+ }
116
+
117
+ /**
118
+ * Where an item stands between the base (the last pull, `frontera.lock`), the
119
+ * working files and the workspace (the fetched snapshot):
120
+ *
121
+ * same up to date
122
+ * changed incoming: only the workspace moved
123
+ * edited changed here: only the files moved
124
+ * conflict both changed (or a file the last pull did not write is in the way): a pull merges
125
+ * unresolved a pull merged it and left conflicts: the files are ours, `<file>.workspace` theirs
126
+ * new new in the workspace
127
+ * deleted-remote deleted in the workspace; the files are still here
128
+ * deleted-local deleted here; the workspace still has it as pulled
129
+ * local-only only here
130
+ */
131
+ export type ItemStatus = 'new' | 'changed' | 'same' | 'edited' | 'conflict' | 'unresolved' | 'local-only' | 'deleted-remote' | 'deleted-local'
132
+
133
+ export interface FileChange {
134
+ path: string
135
+ /** Absent in the folder or in the workspace. */
136
+ local: string | null
137
+ remote: string | null
138
+ }
139
+
140
+ export interface PlanItem {
141
+ ref: string
142
+ kind: SyncKind
143
+ name: string
144
+ title: string
145
+ status: ItemStatus
146
+ /** Ticked by default in the checklist. */
147
+ selected: boolean
148
+ version?: string
149
+ files: FileChange[]
150
+ unresolved: Unresolved[]
151
+ notes: string[]
152
+ requires: string[]
153
+ /** How it runs on the local runtime, from the fetch (design §8): the class and each adaptation in words. */
154
+ portability?: Pick<Portability, 'class' | 'adaptations'>
155
+ /** Both changed, or pulled with conflicts: how a three-way merge with the workspace comes out now. */
156
+ merge?: { conflicts: number }
157
+ /**
158
+ * Only the workspace moved, and only by naming what it pointed at by id
159
+ * (`resourceId: <uuid>` is now `action/queueTelcoNotification`): nothing
160
+ * changed in the workspace. A pull writes the names.
161
+ */
162
+ namesOnly?: boolean
163
+ /** It was conflicted, and a later workspace change merged cleanly with it: the merge is written. */
164
+ cleared?: boolean
165
+ }
166
+
167
+ /** A pull that merged with conflicts, until `resolve` settles it. */
168
+ export interface LockConflict {
169
+ /** The workspace's item hash and revision the merge was against. */
170
+ remote: string
171
+ revision?: string
172
+ /** Files with a `<file>.workspace` beside them. */
173
+ files: string[]
174
+ at: string
175
+ /** How many conflicts, numbered as `diff` numbers them, when recorded. */
176
+ count?: number
177
+ /** The numbered conflicts as one value (`conflictsId`): picks made against another are stale. */
178
+ id?: string
179
+ }
180
+
181
+ export interface LockEntry {
182
+ /** The platform's id for the item (lock v2; absent in a v1 lock). */
183
+ id?: string
184
+ /** The platform's revision the base holds (lock v2; absent means unknown). */
185
+ revision?: string
186
+ /** Hash of the item's files as the workspace gave them: the base's hash. */
187
+ remote: string
188
+ /** Hash of each file as written. */
189
+ files: Record<string, string>
190
+ version?: string
191
+ /** A workflow's state on the platform when it was pulled. */
192
+ state?: ItemState
193
+ pulledAt: string
194
+ /** Pulled with conflicts not yet resolved; the base above is still the last clean pull. */
195
+ conflict?: LockConflict
196
+ }
197
+
198
+ /**
199
+ * `frontera.lock`, committed. Version 2 adds each item's platform id and
200
+ * revision and keeps every v1 field, so a v1 reader still reads it and a v1
201
+ * lock reads as v2 with those unknown.
202
+ */
203
+ export interface SyncLock {
204
+ version: 1 | 2
205
+ workspace: string
206
+ pulledAt?: string
207
+ items: Record<string, LockEntry>
208
+ }
209
+
210
+ export const LOCK_FILE = 'frontera.lock'
211
+
212
+ export function hash(text: string): string {
213
+ return createHash('sha256').update(text).digest('hex').slice(0, 16)
214
+ }
215
+
216
+ export function itemHash(files: Record<string, string>): string {
217
+ return hash(Object.keys(files).sort().map((p) => `${p}\0${files[p]}`).join('\0\0'))
218
+ }
219
+
220
+ export function readLock(root: string): SyncLock | null {
221
+ const file = join(root, LOCK_FILE)
222
+ if (!existsSync(file)) return null
223
+ try {
224
+ const lock = JSON.parse(readFileSync(file, 'utf8')) as SyncLock
225
+ if (!lock || typeof lock !== 'object' || !lock.items || typeof lock.items !== 'object') return null
226
+ return { ...lock, version: lock.version === 2 ? 2 : 1 }
227
+ } catch {
228
+ return null
229
+ }
230
+ }
231
+
232
+ /** Sorted keys, two spaces, a final newline: a lock diffs cleanly in review. Always written as v2. */
233
+ export function writeLock(root: string, lock: SyncLock): void {
234
+ const items = Object.fromEntries(Object.keys(lock.items).sort().map((k) => [k, sortEntry(lock.items[k]!)]))
235
+ writeFileSync(join(root, LOCK_FILE), `${JSON.stringify({ version: 2, workspace: lock.workspace, ...(lock.pulledAt ? { pulledAt: lock.pulledAt } : {}), items }, null, 2)}\n`)
236
+ }
237
+
238
+ function sortEntry(entry: LockEntry): LockEntry {
239
+ return {
240
+ ...(entry.id ? { id: entry.id } : {}),
241
+ ...(entry.revision ? { revision: entry.revision } : {}),
242
+ remote: entry.remote,
243
+ files: Object.fromEntries(Object.keys(entry.files).sort().map((k) => [k, entry.files[k]!])),
244
+ ...(entry.version ? { version: entry.version } : {}),
245
+ ...(entry.state ? { state: entry.state } : {}),
246
+ pulledAt: entry.pulledAt,
247
+ ...(entry.conflict ? { conflict: sortConflict(entry.conflict) } : {}),
248
+ }
249
+ }
250
+
251
+ function sortConflict(c: LockConflict): LockConflict {
252
+ return {
253
+ remote: c.remote,
254
+ ...(c.revision ? { revision: c.revision } : {}),
255
+ files: [...c.files].sort(),
256
+ at: c.at,
257
+ ...(c.count !== undefined ? { count: c.count } : {}),
258
+ ...(c.id ? { id: c.id } : {}),
259
+ }
260
+ }
261
+
262
+ /** A project-relative path that stays inside the project. */
263
+ export function safePath(root: string, rel: string): string {
264
+ const full = resolve(root, rel)
265
+ const back = relative(root, full)
266
+ if (!back || back.startsWith('..') || back.split(sep).includes('..') || rel.startsWith('/')) throw new Error(`refusing to write outside the project: ${rel}`)
267
+ return full
268
+ }
269
+
270
+ export function readLocal(root: string, rel: string): string | null {
271
+ const full = safePath(root, rel)
272
+ try {
273
+ return existsSync(full) ? readFileSync(full, 'utf8') : null
274
+ } catch {
275
+ return null
276
+ }
277
+ }
278
+
279
+ /**
280
+ * The status of one item from three hashes (§6 of the sync v2 design): the
281
+ * base (`remote` in the lock, null when never pulled), the working files
282
+ * (over the base's paths, null when none of them exist) and the workspace
283
+ * (null when it does not have the item). `localMatchesRemote` says the files
284
+ * are exactly the workspace's. `remoteKnownGone`: the workspace's list is
285
+ * complete for the item's kind, so its absence is a deletion and not a gap.
286
+ * Null: nothing to show (gone on both sides).
287
+ */
288
+ export function statusFor(base: string | null, local: string | null, remote: string | null, localMatchesRemote: boolean, remoteKnownGone = false): ItemStatus | null {
289
+ if (remote !== null && localMatchesRemote) return 'same'
290
+ if (base === null) {
291
+ if (remote !== null) return local === null ? 'new' : 'conflict'
292
+ return local === null ? null : 'local-only'
293
+ }
294
+ if (remote === null) {
295
+ if (local === null) return null
296
+ return remoteKnownGone ? 'deleted-remote' : 'local-only'
297
+ }
298
+ const remoteMoved = remote !== base
299
+ if (local === null) return remoteMoved ? 'conflict' : 'deleted-local'
300
+ const localMoved = local !== base
301
+ if (remoteMoved && localMoved) return 'conflict'
302
+ if (localMoved) return 'edited'
303
+ if (remoteMoved) return 'changed'
304
+ return 'same'
305
+ }
306
+
307
+ /** A working file as it compares with the workspace's: a Plugin's secret values, this computer's own, left out. */
308
+ function readComparable(root: string, rel: string, base?: (path: string) => string | null): string | null {
309
+ const text = readLocal(root, rel)
310
+ return text === null ? null : comparableText(rel, text, isPluginFile(rel) ? (base?.(rel) ?? null) : null)
311
+ }
312
+
313
+ /** Hash of the working files at these paths, or null when none of them exist. */
314
+ function localHash(root: string, paths: string[], base?: (path: string) => string | null): string | null {
315
+ const files: Record<string, string> = {}
316
+ for (const p of paths) {
317
+ const content = readComparable(root, p, base)
318
+ if (content !== null) files[p] = content
319
+ }
320
+ return Object.keys(files).length ? itemHash(files) : null
321
+ }
322
+
323
+ export interface PlanOptions {
324
+ /** Whether the workspace's list is complete for this ref's kind (a fetched snapshot after a full index). */
325
+ remoteKnownGone?: (ref: string) => boolean
326
+ /** A base file as the last pull wrote it (`.frontera/sync/base/`), for previewing merges. */
327
+ base?: (path: string) => string | null
328
+ /** The workspace's names → ids at the last fetch, so an id and the name it resolves to compare equal. */
329
+ directory?: Directory
330
+ }
331
+
332
+ /** The base's files for an item: each as last pulled, or null where it is missing or no longer matches the lock. */
333
+ export function baseFiles(entry: LockEntry | undefined, read: ((path: string) => string | null) | undefined): Record<string, string | null> {
334
+ const out: Record<string, string | null> = {}
335
+ for (const [path, wrote] of Object.entries(entry?.files ?? {})) {
336
+ const content = read?.(path) ?? null
337
+ out[path] = content !== null && hash(content) === wrote ? content : null
338
+ }
339
+ return out
340
+ }
341
+
342
+ /** The working files at these paths, null where absent. */
343
+ export function localFiles(root: string, paths: string[]): Record<string, string | null> {
344
+ return Object.fromEntries(paths.map((p) => [p, readLocal(root, p)]))
345
+ }
346
+
347
+ function parsed(path: string, text: string): { ok: true; value: unknown } | { ok: false } {
348
+ try {
349
+ if (/\.json$/i.test(path)) return { ok: true, value: JSON.parse(text) }
350
+ if (/\.ya?ml$/i.test(path)) return { ok: true, value: parseYaml(text) }
351
+ } catch {
352
+ // Not a document: compared as text.
353
+ }
354
+ return { ok: false }
355
+ }
356
+
357
+ /**
358
+ * Whether two versions of an item's files differ only in naming: each file
359
+ * is the same document once ids the directory knows are written as names.
360
+ * Text files must match exactly.
361
+ */
362
+ export function differsOnlyInNames(local: Record<string, string | null>, remote: Record<string, string>, directory: Directory): boolean {
363
+ const paths = new Set([...Object.keys(local).filter((p) => local[p] !== null), ...Object.keys(remote)])
364
+ let differs = false
365
+ for (const path of paths) {
366
+ const l = local[path] ?? null
367
+ const r = remote[path] ?? null
368
+ if (l === r) continue
369
+ if (l === null || r === null) return false
370
+ const a = parsed(path, l)
371
+ const b = parsed(path, r)
372
+ if (!a.ok || !b.ok) return false
373
+ if (!deepEqual(toNames(a.value, directory).doc, toNames(b.value, directory).doc)) return false
374
+ differs = true
375
+ }
376
+ return differs
377
+ }
378
+
379
+ /**
380
+ * The plan for each workspace item, plus the items the project holds that
381
+ * the workspace does not (`localOnly`: refs from the last pull or from the
382
+ * folder, for information; push is later).
383
+ */
384
+ /** An Agent's files, as push sends them. */
385
+ const AGENT_FILES = ['agent.json', 'operating.json', 'bindings.json'] as const
386
+
387
+ export function computePlan(root: string, remote: RemoteItem[], lock: SyncLock | null, localOnly: Array<{ ref: string; kind: SyncKind; name: string; files: string[] }> = [], opts: PlanOptions = {}): PlanItem[] {
388
+ const plan: PlanItem[] = []
389
+ const seen = new Set<string>()
390
+ for (const item of remote) {
391
+ seen.add(item.ref)
392
+ const entry = lock?.items[item.ref]
393
+ const paths = [...new Set([...Object.keys(item.files), ...Object.keys(entry?.files ?? {})])].sort()
394
+ const files: FileChange[] = paths.map((path) => ({ path, local: readComparable(root, path, opts.base), remote: item.files[path] ?? null }))
395
+ // An Agent's file that neither the base nor the workspace has but the folder does (its first tools, in a new
396
+ // `bindings.json`) is a change here too, as push reads it: otherwise status says nothing while push proposes it.
397
+ const added = item.kind === 'agent' ? AGENT_FILES.map((f) => `agents/${item.name}/${f}`).filter((p) => !paths.includes(p) && existsSync(join(root, p))) : []
398
+ const matches = files.every((f) => f.local === f.remote) && !added.length
399
+ // The files as they stand over the base's paths, plus any path only the workspace has that is
400
+ // already in the folder: a file the pull would overwrite counts as a change here.
401
+ const basePaths = entry ? [...Object.keys(entry.files), ...files.filter((f) => !(f.path in entry.files) && f.local !== null).map((f) => f.path), ...added] : Object.keys(item.files)
402
+ const found = statusFor(entry?.remote ?? null, localHash(root, basePaths, opts.base), itemHash(item.files), matches) ?? 'same'
403
+ // Pulled with conflicts and not resolved: it stays so until `resolve`, whatever the files say.
404
+ const status: ItemStatus = entry?.conflict && found !== 'same' ? 'unresolved' : found
405
+ let merge: PlanItem['merge']
406
+ if (status === 'conflict' || status === 'unresolved') {
407
+ const local = localFiles(root, paths)
408
+ merge = { conflicts: mergeItemFiles(baseFiles(entry, opts.base), local, item.files).conflicts }
409
+ }
410
+ const namesOnly = status === 'changed' && opts.directory ? differsOnlyInNames(localFiles(root, paths), item.files, opts.directory) : false
411
+ plan.push({
412
+ ref: item.ref,
413
+ kind: item.kind,
414
+ name: item.name,
415
+ // As every surface names it; a snapshot read before display names gets them from the files.
416
+ title: itemTitle(item.kind, item.name, item.files, item.title),
417
+ status,
418
+ // What a pull takes without asking: the workspace's changes, and merges that come out clean.
419
+ selected: status === 'changed' || (status === 'conflict' && merge?.conflicts === 0),
420
+ version: item.version,
421
+ files: files.filter((f) => f.local !== f.remote),
422
+ unresolved: item.unresolved,
423
+ notes: item.notes,
424
+ requires: item.requires ?? [],
425
+ ...(item.portability ? { portability: { class: item.portability.class, adaptations: item.portability.adaptations } } : {}),
426
+ ...(merge ? { merge } : {}),
427
+ ...(namesOnly ? { namesOnly } : {}),
428
+ })
429
+ }
430
+ const localRefs = new Map(localOnly.map((l) => [l.ref, l]))
431
+ for (const ref of Object.keys(lock?.items ?? {})) {
432
+ if (seen.has(ref) || localRefs.has(ref)) continue
433
+ const [kind, ...rest] = ref.split('/')
434
+ if ((SYNC_KINDS as readonly string[]).includes(kind ?? '')) localRefs.set(ref, { ref, kind: kind as SyncKind, name: rest.join('/'), files: Object.keys(lock!.items[ref]!.files) })
435
+ }
436
+ for (const l of localRefs.values()) {
437
+ if (seen.has(l.ref)) continue
438
+ const entry = lock?.items[l.ref]
439
+ const local = localHash(root, entry ? Object.keys(entry.files) : l.files)
440
+ // Something the folder has on its own, never pulled: only here. Pulled once and gone from
441
+ // the workspace: deleted there, when the workspace's list is known complete.
442
+ const status = statusFor(entry?.remote ?? null, local, null, false, opts.remoteKnownGone?.(l.ref) ?? false)
443
+ if (!status) continue
444
+ // Named as its files name it here, as the workspace would.
445
+ const own = Object.fromEntries(Object.entries(localFiles(root, l.files)).filter((e): e is [string, string] => e[1] !== null))
446
+ plan.push({ ref: l.ref, kind: l.kind, name: l.name, title: itemTitle(l.kind, l.name, own), status, selected: false, files: [], unresolved: [], notes: [], requires: [] })
447
+ }
448
+ return plan.sort((a, b) => SYNC_KINDS.indexOf(a.kind) - SYNC_KINDS.indexOf(b.kind) || a.title.localeCompare(b.title))
449
+ }
450
+
451
+ export interface ApplyOptions {
452
+ /** Items both sides changed: merged three ways from the base instead of written over. */
453
+ merge?: Set<string>
454
+ /** A base file as the last pull wrote it. */
455
+ base?: (path: string) => string | null
456
+ }
457
+
458
+ export interface ApplyResult {
459
+ written: string[]
460
+ removed: string[]
461
+ lock: SyncLock
462
+ /** Items merged cleanly. */
463
+ merged: string[]
464
+ /** Items merged with conflicts: their files are untouched, and `<file>.workspace` holds the workspace's version. */
465
+ conflicted: Array<{ ref: string; conflicts: number; files: string[] }>
466
+ }
467
+
468
+ /** The lock entry for an item as the workspace has it: the base after a pull or a resolve. */
469
+ function entryFor(item: RemoteItem, now: string): LockEntry {
470
+ return {
471
+ ...(item.id ? { id: item.id } : {}),
472
+ ...(item.revision ? { revision: item.revision } : {}),
473
+ remote: itemHash(item.files),
474
+ files: Object.fromEntries(Object.entries(item.files).map(([p, c]) => [p, hash(c)])),
475
+ ...(item.version ? { version: item.version } : {}),
476
+ ...(item.state ? { state: item.state } : {}),
477
+ pulledAt: now,
478
+ }
479
+ }
480
+
481
+ export const WORKSPACE_SUFFIX = '.workspace'
482
+
483
+ /** Write or delete a working file; true when it changed. */
484
+ function put(root: string, path: string, content: string | null): boolean {
485
+ const full = safePath(root, path)
486
+ const now = readLocal(root, path)
487
+ if (now === content) return false
488
+ if (content === null) rmSync(full, { force: true })
489
+ else {
490
+ mkdirSync(dirname(full), { recursive: true })
491
+ writeFileSync(full, content)
492
+ }
493
+ return true
494
+ }
495
+
496
+ /** Remove the folders a deleted file leaves empty, up to the project. */
497
+ function pruneEmpty(root: string, path: string): void {
498
+ let dir = dirname(safePath(root, path))
499
+ const top = resolve(root)
500
+ while (dir !== top && dir.startsWith(`${top}${sep}`)) {
501
+ try {
502
+ if (readdirSync(dir).length) return
503
+ rmdirSync(dir)
504
+ } catch {
505
+ return
506
+ }
507
+ dir = dirname(dir)
508
+ }
509
+ }
510
+
511
+ /** Remove the `<file>.workspace` copies a conflict left beside these files. */
512
+ function clearWorkspaceCopies(root: string, paths: string[]): string[] {
513
+ const removed: string[] = []
514
+ for (const path of paths) {
515
+ const copy = `${path}${WORKSPACE_SUFFIX}`
516
+ if (existsSync(safePath(root, copy))) {
517
+ rmSync(safePath(root, copy))
518
+ removed.push(copy)
519
+ }
520
+ }
521
+ return removed
522
+ }
523
+
524
+ /**
525
+ * Write the chosen items' files and record them in the lock. Files the
526
+ * workspace no longer has for an item (a removed evaluation file) are
527
+ * deleted only when the last pull wrote them and nobody edited them since.
528
+ *
529
+ * An item in `merge` (both sides changed) is merged three ways instead. Clean:
530
+ * the merged files are written and the base moves to the workspace's
531
+ * revision. With conflicts nothing of the item is written over: its files
532
+ * keep the local version, the workspace's version of each conflicted file
533
+ * goes beside it as `<file>.workspace`, and the lock marks it conflicted.
534
+ */
535
+ export function applyItems(root: string, workspace: string, items: RemoteItem[], lock: SyncLock | null, opts: ApplyOptions = {}): ApplyResult {
536
+ const next: SyncLock = { version: 2, workspace, items: { ...(lock?.workspace === workspace ? lock.items : {}) } }
537
+ const now = new Date().toISOString()
538
+ const written: string[] = []
539
+ const removed: string[] = []
540
+ const merged: string[] = []
541
+ const conflicted: ApplyResult['conflicted'] = []
542
+ for (const item of items) {
543
+ const previous = next.items[item.ref]
544
+ if (opts.merge?.has(item.ref)) {
545
+ const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {})])].sort()
546
+ const result = mergeItemFiles(baseFiles(previous, opts.base), localFiles(root, paths), item.files)
547
+ if (result.conflicts) {
548
+ const files = result.files.filter((f) => f.conflicts.length).map((f) => f.path)
549
+ for (const path of files) {
550
+ const theirs = item.files[path]
551
+ // A file the workspace deleted has no copy to put beside it; the conflict says so.
552
+ if (theirs !== undefined && put(root, `${path}${WORKSPACE_SUFFIX}`, theirs)) written.push(`${path}${WORKSPACE_SUFFIX}`)
553
+ }
554
+ next.items[item.ref] = {
555
+ ...(previous ?? { remote: '', files: {}, pulledAt: now }),
556
+ conflict: { remote: itemHash(item.files), ...(item.revision ? { revision: item.revision } : {}), files, at: now, count: result.conflicts, id: conflictsId(result.files) },
557
+ }
558
+ conflicted.push({ ref: item.ref, conflicts: result.conflicts, files })
559
+ continue
560
+ }
561
+ for (const f of result.files) if (put(root, f.path, f.merged)) (f.merged === null ? removed : written).push(f.path)
562
+ removed.push(...clearWorkspaceCopies(root, paths))
563
+ written.push(...placeAssets(root, item))
564
+ next.items[item.ref] = entryFor(item, now)
565
+ merged.push(item.ref)
566
+ continue
567
+ }
568
+ for (const [path, remote] of Object.entries(item.files)) {
569
+ const full = safePath(root, path)
570
+ mkdirSync(dirname(full), { recursive: true })
571
+ // A Plugin's secret values set here stay: they are this computer's configuration.
572
+ const content = isPluginFile(path) ? withLocalSecrets(remote, readLocal(root, path)) : remote
573
+ if (readLocal(root, path) !== content) {
574
+ writeFileSync(full, content)
575
+ written.push(path)
576
+ }
577
+ }
578
+ for (const [path, wroteHash] of Object.entries(previous?.files ?? {})) {
579
+ if (path in item.files) continue
580
+ // A file another item still holds stays (a shared file two Functions import).
581
+ if (Object.entries(next.items).some(([ref, e]) => ref !== item.ref && path in (e.files ?? {}))) continue
582
+ const local = readLocal(root, path)
583
+ if (local !== null && hash(local) === wroteHash) {
584
+ rmSync(safePath(root, path))
585
+ removed.push(path)
586
+ }
587
+ }
588
+ written.push(...placeAssets(root, item))
589
+ next.items[item.ref] = entryFor(item, now)
590
+ }
591
+ next.pulledAt = now
592
+ writeLock(root, next)
593
+ return { written, removed, lock: next, merged, conflicted }
594
+ }
595
+
596
+ export interface RefreshResult {
597
+ lock: SyncLock
598
+ /**
599
+ * `renumbered`: still conflicted, against the workspace's newer version: the
600
+ * `.workspace` copies and the conflict record are redone. `cleared`: the
601
+ * newer version merges cleanly with the files here: the merge is written,
602
+ * the copies go and the base moves. `unchanged`: nothing to redo.
603
+ */
604
+ outcome: 'renumbered' | 'cleared' | 'unchanged'
605
+ conflicts: number
606
+ written: string[]
607
+ removed: string[]
608
+ }
609
+
610
+ /**
611
+ * An item pulled with conflicts, when the workspace moved again before it was
612
+ * resolved: merge the base and the files here with the workspace's newer
613
+ * version, as the pull did. Conflicts left: the `.workspace` copies are
614
+ * rewritten from the newer version (a file no longer conflicted loses its
615
+ * copy) and the lock's conflict record is renumbered. None left: the merged
616
+ * files are written, the copies removed, and the base becomes the
617
+ * workspace's revision, as a clean pull leaves it.
618
+ */
619
+ export function refreshConflict(root: string, workspace: string, item: RemoteItem, lock: SyncLock | null, base?: (path: string) => string | null): RefreshResult {
620
+ const same = lock?.workspace === workspace
621
+ const next: SyncLock = { version: 2, workspace, ...(same && lock.pulledAt ? { pulledAt: lock.pulledAt } : {}), items: { ...(same ? lock.items : {}) } }
622
+ const previous = next.items[item.ref]
623
+ const remote = itemHash(item.files)
624
+ if (!previous?.conflict || previous.conflict.remote === remote) return { lock: next, outcome: 'unchanged', conflicts: previous?.conflict?.count ?? 0, written: [], removed: [] }
625
+ const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous.files), ...previous.conflict.files])].sort()
626
+ const result = mergeItemFiles(baseFiles(previous, base), localFiles(root, paths), item.files)
627
+ const now = new Date().toISOString()
628
+ const written: string[] = []
629
+ const removed: string[] = []
630
+ if (result.conflicts) {
631
+ const files = result.files.filter((f) => f.conflicts.length).map((f) => f.path)
632
+ for (const path of files) {
633
+ const theirs = item.files[path]
634
+ if (theirs !== undefined) {
635
+ if (put(root, `${path}${WORKSPACE_SUFFIX}`, theirs)) written.push(`${path}${WORKSPACE_SUFFIX}`)
636
+ } else removed.push(...clearWorkspaceCopies(root, [path]))
637
+ }
638
+ removed.push(...clearWorkspaceCopies(root, paths.filter((p) => !files.includes(p))))
639
+ next.items[item.ref] = { ...previous, conflict: { remote, ...(item.revision ? { revision: item.revision } : {}), files, at: now, count: result.conflicts, id: conflictsId(result.files) } }
640
+ writeLock(root, next)
641
+ return { lock: next, outcome: 'renumbered', conflicts: result.conflicts, written, removed }
642
+ }
643
+ for (const f of result.files) if (put(root, f.path, f.merged)) (f.merged === null ? removed : written).push(f.path)
644
+ removed.push(...clearWorkspaceCopies(root, paths))
645
+ next.items[item.ref] = entryFor(item, now)
646
+ // It finishes the pull that left the conflict.
647
+ next.pulledAt = now
648
+ writeLock(root, next)
649
+ return { lock: next, outcome: 'cleared', conflicts: 0, written, removed }
650
+ }
651
+
652
+ export type Resolution = { edited: true } | { keep?: Side; picks?: Map<number, Choice> }
653
+
654
+ /**
655
+ * Settle an item both sides changed: merge it with a side chosen for each
656
+ * conflict (`picks` by index, both where a conflict offers it; the rest
657
+ * `keep`), or take the files as the person edited them (`edited`). The
658
+ * `.workspace` copies go, and the base becomes the workspace's revision, so
659
+ * what is left differs only by what was kept from here. Throws when a
660
+ * conflict has no side, or both is picked for a single value.
661
+ */
662
+ export function resolveItem(root: string, workspace: string, item: RemoteItem, lock: SyncLock | null, how: Resolution, base?: (path: string) => string | null): { written: string[]; removed: string[]; lock: SyncLock } {
663
+ const next: SyncLock = { version: 2, workspace, items: { ...(lock?.workspace === workspace ? lock.items : {}) } }
664
+ const previous = next.items[item.ref]
665
+ const paths = [...new Set([...Object.keys(item.files), ...Object.keys(previous?.files ?? {}), ...(previous?.conflict?.files ?? [])])].sort()
666
+ const written: string[] = []
667
+ const removed: string[] = []
668
+ if (!('edited' in how)) {
669
+ const unchosen: number[] = []
670
+ const result = mergeItemFiles(baseFiles(previous, base), localFiles(root, paths), item.files, {
671
+ choose: (i) => {
672
+ const side = how.picks?.get(i) ?? how.keep
673
+ if (!side) unchosen.push(i)
674
+ return side
675
+ },
676
+ })
677
+ if (unchosen.length) throw new Error(`no side chosen for conflict${unchosen.length === 1 ? '' : 's'} ${unchosen.join(', ')}`)
678
+ for (const f of result.files) if (put(root, f.path, f.merged)) (f.merged === null ? removed : written).push(f.path)
679
+ }
680
+ removed.push(...clearWorkspaceCopies(root, paths))
681
+ const now = new Date().toISOString()
682
+ next.items[item.ref] = entryFor(item, now)
683
+ next.pulledAt = now
684
+ writeLock(root, next)
685
+ return { written, removed, lock: next }
686
+ }
687
+
688
+ /**
689
+ * Items the workspace deleted: forget them in the lock, and with
690
+ * `deleteFiles` delete their files here too; without, they stay as items
691
+ * only this project has.
692
+ */
693
+ export function forgetItems(root: string, workspace: string, refs: string[], lock: SyncLock | null, deleteFiles: boolean): { removed: string[]; forgotten: Record<string, string[]>; lock: SyncLock } {
694
+ const next: SyncLock = { version: 2, workspace, items: { ...(lock?.workspace === workspace ? lock.items : {}) } }
695
+ const removed: string[] = []
696
+ const forgotten: Record<string, string[]> = {}
697
+ for (const ref of refs) {
698
+ const entry = next.items[ref]
699
+ if (!entry) continue
700
+ const paths = Object.keys(entry.files)
701
+ forgotten[ref] = paths
702
+ if (deleteFiles) for (const path of paths) if (put(root, path, null)) removed.push(path)
703
+ removed.push(...clearWorkspaceCopies(root, [...paths, ...(entry.conflict?.files ?? [])]))
704
+ if (deleteFiles) for (const path of paths) pruneEmpty(root, path)
705
+ delete next.items[ref]
706
+ }
707
+ next.pulledAt = new Date().toISOString()
708
+ writeLock(root, next)
709
+ return { removed, forgotten, lock: next }
710
+ }
711
+
712
+ /** Counts by status, for `sync status` and Forge's card. */
713
+ export function summarize(plan: PlanItem[]): Record<ItemStatus, number> {
714
+ const out: Record<ItemStatus, number> = { new: 0, changed: 0, same: 0, edited: 0, conflict: 0, unresolved: 0, 'local-only': 0, 'deleted-remote': 0, 'deleted-local': 0 }
715
+ for (const p of plan) out[p.status]++
716
+ return out
717
+ }