@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,395 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Three-way merge for a pull (§7 of the sync v2 design): the base (what the
|
|
7
|
+
* last pull wrote), ours (the project's files) and theirs (the workspace, as
|
|
8
|
+
* fetched). Pure: no files, no network; the caller reads and writes.
|
|
9
|
+
*
|
|
10
|
+
* Rules, the same in every kind:
|
|
11
|
+
*
|
|
12
|
+
* - **JSON and YAML** merge field by field. Object keys merge recursively; a
|
|
13
|
+
* field one side changed takes that side; a field both sides changed the
|
|
14
|
+
* same way takes it once; a field both changed differently is a conflict.
|
|
15
|
+
* A field added on one side is kept; deleted on one side and untouched on
|
|
16
|
+
* the other is deleted; deleted on one side and changed on the other is a
|
|
17
|
+
* conflict.
|
|
18
|
+
* - **Arrays** merge as one value, except a list of objects that each carry a
|
|
19
|
+
* stable key (`apiName`, `id`, `name`, `slug`, `filename`, `modeKey`,
|
|
20
|
+
* `function`, `workflow`, `key`: the first one present as a unique string in
|
|
21
|
+
* every element of all three versions). Such a list merges element by
|
|
22
|
+
* element, matched by that key, with the object rules above. Its order is
|
|
23
|
+
* ours, as the folder has it, unless only the workspace reordered the
|
|
24
|
+
* elements all three share; what the other side added comes after.
|
|
25
|
+
* - **Multi-line strings** inside JSON or YAML (an Agent's prompts, a
|
|
26
|
+
* procedure's instructions) and **every other file** (Markdown, code) merge
|
|
27
|
+
* line by line, diff3-style: edits to different lines merge; the same lines
|
|
28
|
+
* changed differently on both sides conflict, and so do edits to adjacent
|
|
29
|
+
* lines with no unchanged line between them (as in git).
|
|
30
|
+
*
|
|
31
|
+
* A conflict never decides on its own: unless `choose` picks a side, the
|
|
32
|
+
* result keeps ours, and the conflict is reported with all three versions.
|
|
33
|
+
* Where both sides can stand together, as in a merge editor's Accept both,
|
|
34
|
+
* the conflict also says what taking both gives (`both`): for lines, ours
|
|
35
|
+
* then theirs; for a list, ours then what theirs adds, each element once. A
|
|
36
|
+
* single value (a name, a number, a whole file one side deleted) has no
|
|
37
|
+
* both.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export type Side = 'ours' | 'theirs'
|
|
41
|
+
|
|
42
|
+
/** What settles one conflict: a side, or both where the conflict offers it. */
|
|
43
|
+
export type Choice = Side | 'both'
|
|
44
|
+
|
|
45
|
+
export interface Conflict {
|
|
46
|
+
/** 1-based, in the order the merge meets conflicts: across an item's files when merged with `mergeItemFiles`. */
|
|
47
|
+
index: number
|
|
48
|
+
/** The field (`config.name`, `procedures[id=refund].instructions`); '' for a whole text file. */
|
|
49
|
+
path: string
|
|
50
|
+
/** The three versions of the field or the lines; `undefined` where a version does not have it. */
|
|
51
|
+
base: unknown
|
|
52
|
+
ours: unknown
|
|
53
|
+
theirs: unknown
|
|
54
|
+
/** For a line hunk: the 1-based line in our version where it starts. */
|
|
55
|
+
line?: number
|
|
56
|
+
/** What choosing both gives, where both can stand together: ours then theirs (lines), or the two lists joined without repeats. Absent for a single value. */
|
|
57
|
+
both?: unknown
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MergeOptions {
|
|
61
|
+
/** Pick a side for a conflict by its index, or both where it has `both`; `undefined` leaves it unresolved (ours is kept). */
|
|
62
|
+
choose?: (index: number) => Choice | undefined
|
|
63
|
+
/** The index the first conflict gets, minus one: so several files number on from each other. */
|
|
64
|
+
offset?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A key that is not there, as distinct from one holding `undefined` or `null`. */
|
|
68
|
+
const ABSENT: unique symbol = Symbol('absent')
|
|
69
|
+
type Value = unknown
|
|
70
|
+
|
|
71
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v)
|
|
72
|
+
|
|
73
|
+
export function deepEqual(a: unknown, b: unknown): boolean {
|
|
74
|
+
if (a === b) return true
|
|
75
|
+
if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((x, i) => deepEqual(x, b[i]))
|
|
76
|
+
if (isObject(a) && isObject(b)) {
|
|
77
|
+
const ka = Object.keys(a)
|
|
78
|
+
const kb = Object.keys(b)
|
|
79
|
+
return ka.length === kb.length && ka.every((k) => Object.prototype.hasOwnProperty.call(b, k) && deepEqual(a[k], b[k]))
|
|
80
|
+
}
|
|
81
|
+
return false
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
class Merger {
|
|
85
|
+
conflicts: Conflict[] = []
|
|
86
|
+
private n: number
|
|
87
|
+
|
|
88
|
+
constructor(private opts: MergeOptions) {
|
|
89
|
+
this.n = opts.offset ?? 0
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Record a conflict and return what to take: the chosen side (or both, where it has both), or ours. */
|
|
93
|
+
conflict(c: Omit<Conflict, 'index'>): Choice {
|
|
94
|
+
const index = ++this.n
|
|
95
|
+
const { both, ...rest } = c
|
|
96
|
+
this.conflicts.push({ index, ...rest, base: shown(c.base), ours: shown(c.ours), theirs: shown(c.theirs), ...(both !== undefined ? { both } : {}) })
|
|
97
|
+
const choice = this.opts.choose?.(index) ?? 'ours'
|
|
98
|
+
if (choice === 'both' && both === undefined) throw new Error(`conflict ${index} is one value: keep mine or the workspace's`)
|
|
99
|
+
return choice
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
value(path: string, b: Value, o: Value, t: Value): Value {
|
|
103
|
+
if (deepEqual(o, t)) return o
|
|
104
|
+
if (deepEqual(b, o)) return t
|
|
105
|
+
if (deepEqual(b, t)) return o
|
|
106
|
+
if (isObject(o) && isObject(t)) return this.object(path, isObject(b) ? b : null, o, t)
|
|
107
|
+
if (Array.isArray(o) && Array.isArray(t)) return this.array(path, Array.isArray(b) ? b : null, o, t)
|
|
108
|
+
if (typeof o === 'string' && typeof t === 'string' && [b, o, t].some((s) => typeof s === 'string' && s.includes('\n'))) {
|
|
109
|
+
return this.lines(path, typeof b === 'string' ? b : '', o, t)
|
|
110
|
+
}
|
|
111
|
+
return this.conflict({ path, base: b, ours: o, theirs: t }) === 'ours' ? o : t
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
object(path: string, b: Record<string, unknown> | null, o: Record<string, unknown>, t: Record<string, unknown>): Record<string, unknown> {
|
|
115
|
+
const out: Record<string, unknown> = {}
|
|
116
|
+
// Our order first, so the merged file reads like the one in the folder; then what only the workspace has.
|
|
117
|
+
const keys = [...Object.keys(o), ...Object.keys(t).filter((k) => !(k in o)), ...Object.keys(b ?? {}).filter((k) => !(k in o) && !(k in t))]
|
|
118
|
+
for (const k of keys) {
|
|
119
|
+
const at = (x: Record<string, unknown> | null) => (x && Object.prototype.hasOwnProperty.call(x, k) ? x[k] : ABSENT)
|
|
120
|
+
const v = this.value(path ? `${path}.${k}` : k, at(b), at(o), at(t))
|
|
121
|
+
if (v !== ABSENT) out[k] = v
|
|
122
|
+
}
|
|
123
|
+
return out
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
array(path: string, b: unknown[] | null, o: unknown[], t: unknown[]): unknown {
|
|
127
|
+
const key = stableKey([...(b ? [b] : []), o, t])
|
|
128
|
+
if (!key) {
|
|
129
|
+
const both = union(o, t)
|
|
130
|
+
const choice = this.conflict({ path, base: b ?? ABSENT, ours: o, theirs: t, both })
|
|
131
|
+
return choice === 'both' ? both : choice === 'ours' ? o : t
|
|
132
|
+
}
|
|
133
|
+
const index = (list: unknown[] | null) => new Map((list ?? []).map((x) => [(x as Record<string, string>)[key]!, x]))
|
|
134
|
+
const bm = index(b)
|
|
135
|
+
const om = index(o)
|
|
136
|
+
const tm = index(t)
|
|
137
|
+
const order = (m: Map<string, unknown>, among: Map<string, unknown>[]) => [...m.keys()].filter((k) => among.every((x) => x.has(k)))
|
|
138
|
+
const shared = (m: Map<string, unknown>) => order(m, [bm, om, tm]).join('\0')
|
|
139
|
+
// Our order, as the folder has it, unless only the workspace reordered what all three share.
|
|
140
|
+
const theirsFirst = shared(tm) !== shared(bm) && shared(om) === shared(bm)
|
|
141
|
+
const [first, second] = theirsFirst ? [tm, om] : [om, tm]
|
|
142
|
+
const keys = [...first.keys(), ...[...second.keys()].filter((k) => !first.has(k)), ...[...bm.keys()].filter((k) => !om.has(k) && !tm.has(k))]
|
|
143
|
+
const out: unknown[] = []
|
|
144
|
+
for (const k of keys) {
|
|
145
|
+
const at = (m: Map<string, unknown>) => (m.has(k) ? m.get(k) : ABSENT)
|
|
146
|
+
const v = this.value(`${path}[${key}=${k}]`, at(bm), at(om), at(tm))
|
|
147
|
+
if (v !== ABSENT) out.push(v)
|
|
148
|
+
}
|
|
149
|
+
return out
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
lines(path: string, base: string, ours: string, theirs: string): string {
|
|
153
|
+
const result = diff3(base.split('\n'), ours.split('\n'), theirs.split('\n'))
|
|
154
|
+
const out: string[] = []
|
|
155
|
+
for (const chunk of result) {
|
|
156
|
+
if (chunk.kind === 'ok') {
|
|
157
|
+
out.push(...chunk.lines)
|
|
158
|
+
continue
|
|
159
|
+
}
|
|
160
|
+
const both = [...chunk.ours, ...chunk.theirs]
|
|
161
|
+
const choice = this.conflict({ path, base: chunk.base.join('\n'), ours: chunk.ours.join('\n'), theirs: chunk.theirs.join('\n'), line: chunk.oursLine, both: both.join('\n') })
|
|
162
|
+
out.push(...(choice === 'both' ? both : choice === 'ours' ? chunk.ours : chunk.theirs))
|
|
163
|
+
}
|
|
164
|
+
return out.join('\n')
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Two lists as one: ours in its order, then what only theirs has, each element once. */
|
|
169
|
+
function union(ours: unknown[], theirs: unknown[]): unknown[] {
|
|
170
|
+
const out: unknown[] = []
|
|
171
|
+
for (const x of [...ours, ...theirs]) if (!out.some((y) => deepEqual(x, y))) out.push(x)
|
|
172
|
+
return out
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** A conflict's version as a reader sees it: `undefined` where it is absent. */
|
|
176
|
+
function shown(v: unknown): unknown {
|
|
177
|
+
return v === ABSENT ? undefined : v
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const KEY_FIELDS = ['apiName', 'id', 'name', 'slug', 'filename', 'modeKey', 'function', 'workflow', 'objectType', 'key']
|
|
181
|
+
|
|
182
|
+
/** The field every element of every list carries as a unique string, if there is one. */
|
|
183
|
+
export function stableKey(lists: unknown[][]): string | null {
|
|
184
|
+
if (!lists.some((l) => l.length)) return null
|
|
185
|
+
for (const key of KEY_FIELDS) {
|
|
186
|
+
const ok = lists.every((list) => {
|
|
187
|
+
const seen = new Set<string>()
|
|
188
|
+
for (const x of list) {
|
|
189
|
+
if (!isObject(x) || typeof x[key] !== 'string' || seen.has(x[key] as string)) return false
|
|
190
|
+
seen.add(x[key] as string)
|
|
191
|
+
}
|
|
192
|
+
return true
|
|
193
|
+
})
|
|
194
|
+
if (ok) return key
|
|
195
|
+
}
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Field-by-field merge of two documents from their base. `base` is
|
|
201
|
+
* `undefined` when there is none (an item pulled before base content was
|
|
202
|
+
* kept): then only what both sides agree on, or only one side has, merges.
|
|
203
|
+
*/
|
|
204
|
+
export function mergeJson(base: unknown, ours: unknown, theirs: unknown, opts: MergeOptions = {}): { merged: unknown; conflicts: Conflict[] } {
|
|
205
|
+
const m = new Merger(opts)
|
|
206
|
+
const merged = m.value('', base === undefined ? ABSENT : base, ours, theirs)
|
|
207
|
+
return { merged: merged === ABSENT ? undefined : merged, conflicts: m.conflicts }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Line-by-line merge of three texts, diff3-style. */
|
|
211
|
+
export function mergeLines(base: string, ours: string, theirs: string, opts: MergeOptions = {}): { merged: string; conflicts: Conflict[] } {
|
|
212
|
+
const m = new Merger(opts)
|
|
213
|
+
const merged = ours === theirs ? ours : base === ours ? theirs : base === theirs ? ours : m.lines('', base, ours, theirs)
|
|
214
|
+
return { merged, conflicts: m.conflicts }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// ── diff3 ────────────────────────────────────────────────────────────────────
|
|
218
|
+
|
|
219
|
+
type Chunk = { kind: 'ok'; lines: string[] } | { kind: 'conflict'; base: string[]; ours: string[]; theirs: string[]; oursLine: number }
|
|
220
|
+
|
|
221
|
+
/** Past this many table cells, the middle of two texts is compared as one block instead of line by line. */
|
|
222
|
+
const LCS_LIMIT = 4_000_000
|
|
223
|
+
|
|
224
|
+
/** For each line of `a`, the line of `b` a longest common subsequence pairs it with, or -1. */
|
|
225
|
+
function matchLines(a: string[], b: string[]): Int32Array {
|
|
226
|
+
const out = new Int32Array(a.length).fill(-1)
|
|
227
|
+
let start = 0
|
|
228
|
+
while (start < a.length && start < b.length && a[start] === b[start]) {
|
|
229
|
+
out[start] = start
|
|
230
|
+
start++
|
|
231
|
+
}
|
|
232
|
+
let endA = a.length
|
|
233
|
+
let endB = b.length
|
|
234
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
235
|
+
endA--
|
|
236
|
+
endB--
|
|
237
|
+
out[endA] = endB
|
|
238
|
+
}
|
|
239
|
+
const n = endA - start
|
|
240
|
+
const m = endB - start
|
|
241
|
+
if (!n || !m || n * m > LCS_LIMIT) return out
|
|
242
|
+
const table: Uint32Array[] = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1))
|
|
243
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
244
|
+
for (let j = m - 1; j >= 0; j--) table[i]![j] = a[start + i] === b[start + j] ? table[i + 1]![j + 1]! + 1 : Math.max(table[i + 1]![j]!, table[i]![j + 1]!)
|
|
245
|
+
}
|
|
246
|
+
let i = 0
|
|
247
|
+
let j = 0
|
|
248
|
+
while (i < n && j < m) {
|
|
249
|
+
if (a[start + i] === b[start + j]) {
|
|
250
|
+
out[start + i] = start + j
|
|
251
|
+
i++
|
|
252
|
+
j++
|
|
253
|
+
} else if (table[i + 1]![j]! >= table[i]![j + 1]!) i++
|
|
254
|
+
else j++
|
|
255
|
+
}
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const same = (x: string[], y: string[]) => x.length === y.length && x.every((l, i) => l === y[i])
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Walk the base with both sides: a base line both sides kept is stable; the
|
|
263
|
+
* lines between two stable points changed on one side (take it), on both the
|
|
264
|
+
* same way (take it once), or on both differently (a conflict).
|
|
265
|
+
*/
|
|
266
|
+
export function diff3(base: string[], ours: string[], theirs: string[]): Chunk[] {
|
|
267
|
+
const mo = matchLines(base, ours)
|
|
268
|
+
const mt = matchLines(base, theirs)
|
|
269
|
+
const out: Chunk[] = []
|
|
270
|
+
const ok = (lines: string[]) => {
|
|
271
|
+
if (!lines.length) return
|
|
272
|
+
const last = out[out.length - 1]
|
|
273
|
+
if (last?.kind === 'ok') last.lines.push(...lines)
|
|
274
|
+
else out.push({ kind: 'ok', lines: [...lines] })
|
|
275
|
+
}
|
|
276
|
+
let i = 0
|
|
277
|
+
let a = 0
|
|
278
|
+
let b = 0
|
|
279
|
+
while (i < base.length || a < ours.length || b < theirs.length) {
|
|
280
|
+
if (i < base.length && mo[i] === a && mt[i] === b) {
|
|
281
|
+
ok([base[i]!])
|
|
282
|
+
i++
|
|
283
|
+
a++
|
|
284
|
+
b++
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
// The next base line both sides kept, at or after this one.
|
|
288
|
+
let j = i
|
|
289
|
+
while (j < base.length && !(mo[j]! >= a && mt[j]! >= b)) j++
|
|
290
|
+
const oa = j < base.length ? mo[j]! : ours.length
|
|
291
|
+
const ob = j < base.length ? mt[j]! : theirs.length
|
|
292
|
+
const bs = base.slice(i, j)
|
|
293
|
+
const os = ours.slice(a, oa)
|
|
294
|
+
const ts = theirs.slice(b, ob)
|
|
295
|
+
if (same(os, bs)) ok(ts)
|
|
296
|
+
else if (same(ts, bs) || same(os, ts)) ok(os)
|
|
297
|
+
else out.push({ kind: 'conflict', base: bs, ours: os, theirs: ts, oursLine: a + 1 })
|
|
298
|
+
i = j
|
|
299
|
+
a = oa
|
|
300
|
+
b = ob
|
|
301
|
+
}
|
|
302
|
+
return out
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ── Files ────────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
export interface FileMerge {
|
|
308
|
+
path: string
|
|
309
|
+
/** The merged content to write, or null for no file. Where a conflict is left unresolved, ours. */
|
|
310
|
+
merged: string | null
|
|
311
|
+
conflicts: Conflict[]
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
type Format = 'json' | 'yaml' | 'text'
|
|
315
|
+
|
|
316
|
+
function formatOf(path: string): Format {
|
|
317
|
+
if (/\.json$/i.test(path)) return 'json'
|
|
318
|
+
if (/\.ya?ml$/i.test(path)) return 'yaml'
|
|
319
|
+
return 'text'
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function parse(format: Format, text: string): { ok: true; value: unknown } | { ok: false } {
|
|
323
|
+
try {
|
|
324
|
+
return { ok: true, value: format === 'json' ? JSON.parse(text) : parseYaml(text) }
|
|
325
|
+
} catch {
|
|
326
|
+
return { ok: false }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** A document as a pull writes it: two-space JSON with a final newline, YAML without wrapping. */
|
|
331
|
+
function render(format: Format, value: unknown): string {
|
|
332
|
+
return format === 'json' ? `${JSON.stringify(value, null, 2)}\n` : stringifyYaml(value, { lineWidth: 0 })
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* One file's merge, by its kind: JSON and YAML field by field (falling back
|
|
337
|
+
* to lines when a version does not parse), anything else by line. A file one
|
|
338
|
+
* side deleted and the other changed is a conflict over the whole file.
|
|
339
|
+
*/
|
|
340
|
+
export function mergeFile(path: string, base: string | null, ours: string | null, theirs: string | null, opts: MergeOptions = {}): FileMerge {
|
|
341
|
+
if (ours === theirs) return { path, merged: ours, conflicts: [] }
|
|
342
|
+
if (base === ours) return { path, merged: theirs, conflicts: [] }
|
|
343
|
+
if (base === theirs) return { path, merged: ours, conflicts: [] }
|
|
344
|
+
if (ours === null || theirs === null) {
|
|
345
|
+
const m = new Merger(opts)
|
|
346
|
+
const side = m.conflict({ path: '', base: base ?? undefined, ours: ours ?? undefined, theirs: theirs ?? undefined })
|
|
347
|
+
return { path, merged: side === 'ours' ? ours : theirs, conflicts: m.conflicts }
|
|
348
|
+
}
|
|
349
|
+
const format = formatOf(path)
|
|
350
|
+
if (format !== 'text') {
|
|
351
|
+
const o = parse(format, ours)
|
|
352
|
+
const t = parse(format, theirs)
|
|
353
|
+
const b = base === null ? ({ ok: true, value: undefined } as const) : parse(format, base)
|
|
354
|
+
if (o.ok && t.ok && b.ok) {
|
|
355
|
+
const { merged, conflicts } = mergeJson(b.value, o.value, t.value, opts)
|
|
356
|
+
// Keep a side's text as it is when the merge is that side: formatting and comments survive.
|
|
357
|
+
const text = deepEqual(merged, o.value) ? ours : deepEqual(merged, t.value) ? theirs : render(format, merged)
|
|
358
|
+
return { path, merged: text, conflicts }
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
const { merged, conflicts } = mergeLines(base ?? '', ours, theirs, opts)
|
|
362
|
+
return { path, merged, conflicts }
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Every file of one item: the paths any of the three versions has, in order,
|
|
367
|
+
* conflicts numbered across them so a choice by index names one conflict.
|
|
368
|
+
*/
|
|
369
|
+
export function mergeItemFiles(
|
|
370
|
+
base: Record<string, string | null>,
|
|
371
|
+
ours: Record<string, string | null>,
|
|
372
|
+
theirs: Record<string, string | null>,
|
|
373
|
+
opts: Omit<MergeOptions, 'offset'> = {},
|
|
374
|
+
): { files: FileMerge[]; conflicts: number } {
|
|
375
|
+
const paths = [...new Set([...Object.keys(base), ...Object.keys(ours), ...Object.keys(theirs)])].sort()
|
|
376
|
+
const files: FileMerge[] = []
|
|
377
|
+
let offset = 0
|
|
378
|
+
for (const path of paths) {
|
|
379
|
+
const f = mergeFile(path, base[path] ?? null, ours[path] ?? null, theirs[path] ?? null, { ...opts, offset })
|
|
380
|
+
offset += f.conflicts.length
|
|
381
|
+
files.push(f)
|
|
382
|
+
}
|
|
383
|
+
return { files, conflicts: offset }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* An item's numbered conflicts as one value: where each is and what each
|
|
388
|
+
* side holds. A choice made by number (`--picks 1=workspace`) applies only
|
|
389
|
+
* while it is the same; a workspace change that moves or renumbers the
|
|
390
|
+
* conflicts changes it, and stale picks are refused.
|
|
391
|
+
*/
|
|
392
|
+
export function conflictsId(files: FileMerge[]): string {
|
|
393
|
+
const list = files.flatMap((f) => f.conflicts.map((c) => [f.path, c.index, c.path, c.line ?? null, c.base ?? null, c.ours ?? null, c.theirs ?? null]))
|
|
394
|
+
return createHash('sha256').update(JSON.stringify(list)).digest('hex').slice(0, 16)
|
|
395
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One name for each synced item everywhere it is said: the CLI's status,
|
|
3
|
+
* preview and pull lines, Forge's Pull and Propose dialogs, the change
|
|
4
|
+
* request's rows and steps. An item is called by its display name (the
|
|
5
|
+
* Agent's own name, the Blueprint file's display name, a skill's display
|
|
6
|
+
* name), else its slug made readable, and a sentence puts its kind first:
|
|
7
|
+
* "Action Notify the customer", "Skill Brand".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const KIND_WORD: Record<string, string> = {
|
|
11
|
+
agent: 'Agent',
|
|
12
|
+
workflow: 'Workflow',
|
|
13
|
+
function: 'Function',
|
|
14
|
+
skill: 'Skill',
|
|
15
|
+
knowledge: 'Knowledge base',
|
|
16
|
+
plugin: 'Plugin',
|
|
17
|
+
evaluation: 'Evaluation dataset',
|
|
18
|
+
dataset: 'Dataset',
|
|
19
|
+
theme: 'Slide theme',
|
|
20
|
+
'chat-app': 'Chat App',
|
|
21
|
+
app: 'App',
|
|
22
|
+
brand: 'Brand',
|
|
23
|
+
library: 'Library installation',
|
|
24
|
+
lessons: 'Lessons for Agent',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const BLUEPRINT_WORD: Record<string, string> = {
|
|
28
|
+
'object-types': 'Object type',
|
|
29
|
+
'link-types': 'Link type',
|
|
30
|
+
'shared-fields': 'Shared field',
|
|
31
|
+
'semantic-types': 'Semantic type',
|
|
32
|
+
metrics: 'Metric',
|
|
33
|
+
actions: 'Action',
|
|
34
|
+
'object-sets': 'Saved object set',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `word-count` → "Word count". */
|
|
38
|
+
export function readableName(slug: string): string {
|
|
39
|
+
const words = slug.replace(/[-_]+/g, ' ').trim()
|
|
40
|
+
return words ? words[0]!.toUpperCase() + words.slice(1) : slug
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The kind of the item a reference names, in a word: `blueprint/actions/x` is an Action. */
|
|
44
|
+
export function kindWord(ref: string): string {
|
|
45
|
+
const [kind, sub] = ref.split('/')
|
|
46
|
+
if (kind === 'blueprint') return BLUEPRINT_WORD[sub ?? ''] ?? 'Blueprint'
|
|
47
|
+
return KIND_WORD[kind ?? ''] ?? readableName(kind ?? 'item')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An item as a sentence names it: its kind, then its name. A name that already says its kind ("Organization brand") stands alone. */
|
|
51
|
+
export function kindTitle(ref: string, title: string): string {
|
|
52
|
+
const word = kindWord(ref)
|
|
53
|
+
return title.toLowerCase().includes(word.toLowerCase()) ? title : `${word} ${title}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A display name from a document's `displayName` or `name`, when it has one. */
|
|
57
|
+
function declared(doc: unknown, ...keys: string[]): string | null {
|
|
58
|
+
if (!doc || typeof doc !== 'object') return null
|
|
59
|
+
for (const k of keys) {
|
|
60
|
+
const v = (doc as Record<string, unknown>)[k]
|
|
61
|
+
if (typeof v === 'string' && v.trim()) return v.trim()
|
|
62
|
+
}
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseJson(text: string | undefined): unknown {
|
|
67
|
+
if (!text) return null
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(text)
|
|
70
|
+
} catch {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** `displayName:` or `name:` at the top level of YAML (a Blueprint file, a workflow) or in a Markdown file's front matter. */
|
|
76
|
+
function yamlField(text: string | undefined, key: string): string | null {
|
|
77
|
+
if (!text) return null
|
|
78
|
+
const m = text.match(new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm'))
|
|
79
|
+
if (!m) return null
|
|
80
|
+
const raw = m[1]!.replace(/^(['"])(.*)\1$/, '$2').trim()
|
|
81
|
+
return raw && !raw.startsWith('{') && !raw.startsWith('[') ? raw : null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Whether a title someone kept is only a slug form, not a name: empty, the
|
|
86
|
+
* item's slug or reference, a Blueprint file's apiName, or the "apiName
|
|
87
|
+
* (kind)" a snapshot gave Blueprint files before they were named by display
|
|
88
|
+
* name. A change request proposed before items were named this way keeps
|
|
89
|
+
* such titles; whoever shows it names the item afresh instead.
|
|
90
|
+
*/
|
|
91
|
+
export function isSlugTitle(ref: string, title: string | null | undefined): boolean {
|
|
92
|
+
const t = title?.trim()
|
|
93
|
+
if (!t) return true
|
|
94
|
+
const [kind, ...rest] = ref.split('/')
|
|
95
|
+
const name = rest.join('/')
|
|
96
|
+
if (t === ref || t === name) return true
|
|
97
|
+
if (kind !== 'blueprint') return false
|
|
98
|
+
return t === rest[1] || / \((semantic type|shared field|object type|link type|metric|action|saved object set|blueprint)\)$/i.test(t)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The files `itemTitle` reads a name from, by project path: a caller that
|
|
103
|
+
* holds only the reference (a kept change request) reads these from the
|
|
104
|
+
* project. A kind named from its slug alone has none.
|
|
105
|
+
*/
|
|
106
|
+
export function namingPaths(kind: string, name: string): string[] {
|
|
107
|
+
if (kind === 'agent') return [`agents/${name}/agent.json`]
|
|
108
|
+
if (kind === 'blueprint') {
|
|
109
|
+
const [dir, apiName] = name.split('/')
|
|
110
|
+
return ['yaml', 'yml', 'json'].map((ext) => `blueprint/${dir}/${apiName}.${ext}`)
|
|
111
|
+
}
|
|
112
|
+
if (kind === 'skill') return [`skills/${name}/SKILL.md`]
|
|
113
|
+
if (kind === 'workflow') return [`workflows/${name}.yaml`, `workflows/${name}.yml`]
|
|
114
|
+
if (kind === 'chat-app') return [`chat-apps/${name}.json`]
|
|
115
|
+
if (kind === 'app') return [`apps/${name}.json`]
|
|
116
|
+
if (kind === 'knowledge') return [`knowledge/${name}/knowledge.json`]
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* An item's name as people know it: the name the workspace gave it
|
|
122
|
+
* (`known`), else what its files declare, else its slug made readable.
|
|
123
|
+
* `files` are the item's files by project path, as the project or the
|
|
124
|
+
* workspace holds them. A `known` that is only a slug form (`isSlugTitle`)
|
|
125
|
+
* gives way to the files.
|
|
126
|
+
*/
|
|
127
|
+
export function itemTitle(kind: string, name: string, files: Record<string, string>, known?: string | null): string {
|
|
128
|
+
if (known && !isSlugTitle(`${kind}/${name}`, known)) return known.trim()
|
|
129
|
+
if (kind === 'agent') {
|
|
130
|
+
const doc = parseJson(files[`agents/${name}/agent.json`]) as { config?: unknown; name?: unknown } | null
|
|
131
|
+
const own = declared(doc?.config, 'name') ?? declared(doc, 'name')
|
|
132
|
+
if (own) return own
|
|
133
|
+
}
|
|
134
|
+
if (kind === 'blueprint') {
|
|
135
|
+
const [dir, apiName] = name.split('/')
|
|
136
|
+
const file = Object.entries(files).find(([p]) => p.startsWith(`blueprint/${dir}/${apiName}.`))?.[1]
|
|
137
|
+
const own = yamlField(file, 'displayName') ?? declared(parseJson(file), 'displayName')
|
|
138
|
+
return own ?? (dir === 'object-sets' ? readableName(apiName ?? name) : (apiName ?? name))
|
|
139
|
+
}
|
|
140
|
+
if (kind === 'skill') {
|
|
141
|
+
const own = yamlField(files[`skills/${name}/SKILL.md`], 'displayName')
|
|
142
|
+
if (own) return own
|
|
143
|
+
}
|
|
144
|
+
if (kind === 'workflow') {
|
|
145
|
+
const file = files[`workflows/${name}.yaml`] ?? files[`workflows/${name}.yml`]
|
|
146
|
+
const own = yamlField(file, 'displayName')
|
|
147
|
+
if (own) return own
|
|
148
|
+
}
|
|
149
|
+
if (kind === 'chat-app' || kind === 'app') {
|
|
150
|
+
const file = Object.entries(files).find(([p]) => p.endsWith('.json'))?.[1]
|
|
151
|
+
const own = declared(parseJson(file), 'displayName', 'name')
|
|
152
|
+
if (own) return own
|
|
153
|
+
}
|
|
154
|
+
if (kind === 'knowledge') {
|
|
155
|
+
const own = declared(parseJson(files[`knowledge/${name}/knowledge.json`]), 'displayName', 'name')
|
|
156
|
+
if (own) return own
|
|
157
|
+
}
|
|
158
|
+
// An Agent's lessons are named by their Agent: "Lessons for Agent Claims desk".
|
|
159
|
+
if (kind === 'lessons') {
|
|
160
|
+
const own = declared((parseJson(files[`agents/${name}/agent.json`]) as { config?: unknown } | null)?.config, 'name')
|
|
161
|
+
return own ?? readableName(name)
|
|
162
|
+
}
|
|
163
|
+
if (kind === 'evaluation') {
|
|
164
|
+
// As a pull names it: the dataset, then the Agent its cases test.
|
|
165
|
+
const [agent, dataset] = name.split('.')
|
|
166
|
+
return `${readableName(dataset ?? name)} → ${agent}`
|
|
167
|
+
}
|
|
168
|
+
return readableName(name)
|
|
169
|
+
}
|