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