@voxgig/create-sdkgen 0.30.9 → 0.31.0

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.
@@ -0,0 +1,711 @@
1
+ /* Copyright (c) 2026 Richard Rodger, MIT License */
2
+
3
+ // The copy merge @voxgig/sdkgen runs on an add, applied to a re-scaffold.
4
+
5
+ import Path from 'node:path'
6
+ import { realpathSync } from 'node:fs'
7
+ import { execFileSync } from 'node:child_process'
8
+
9
+ import { DiffUtil } from 'jostraca'
10
+
11
+
12
+ // How a toolchain write treats a project file the project changed after the
13
+ // toolchain last wrote it.
14
+ // merge a three-way merge of the incoming copy into the local change
15
+ // overwrite the incoming copy replaces the file
16
+ type CopyMode = 'merge' | 'overwrite'
17
+
18
+ const MERGE: CopyMode = 'merge'
19
+ const OVERWRITE: CopyMode = 'overwrite'
20
+
21
+
22
+ type GuardSpec = {
23
+ fs: any
24
+ folder: string
25
+ log: any
26
+ mode: CopyMode
27
+ what: string
28
+ force?: boolean
29
+ keep?: boolean
30
+ dryrun?: boolean
31
+
32
+ // Relative to `folder`, with forward slashes.
33
+ manage: (rel: string) => boolean
34
+
35
+ // The fingerprint of what the toolchain wrote at `rel` last time, read
36
+ // before the run starts.
37
+ recorded: (rel: string) => string | undefined
38
+ fingerprint: (content: any) => string
39
+
40
+ basedir: string
41
+ conflictdir: string
42
+
43
+ // Earlier versions of a file, newest first, for a base the store lacks.
44
+ versions?: (abs: string) => Iterable<Buffer>
45
+
46
+ error?: (msg: string) => Error
47
+ }
48
+
49
+
50
+ type Conflict = {
51
+ rel: string
52
+ why: 'conflict' | 'nobase' | 'binary'
53
+ preview?: Buffer
54
+ }
55
+
56
+
57
+ type MergeOutcome = {
58
+ merged: string[]
59
+ kept: string[]
60
+ retained: string[]
61
+ spared: string[]
62
+ }
63
+
64
+
65
+ const DELETED = Symbol('deleted')
66
+
67
+ type Pending = Buffer | typeof DELETED
68
+
69
+
70
+ function copyMode(value: any): CopyMode {
71
+ return OVERWRITE === value ? OVERWRITE : MERGE
72
+ }
73
+
74
+
75
+ async function withCopyMerge<T>(
76
+ spec: GuardSpec, run: (fs: any) => Promise<T>,
77
+ ): Promise<T> {
78
+ if (MERGE !== spec.mode || true === spec.force || true === spec.dryrun) {
79
+ return run(spec.fs)
80
+ }
81
+
82
+ const guard = new CopyGuard(spec)
83
+
84
+ let result: T
85
+ try {
86
+ result = await run(guard.fs)
87
+ }
88
+ catch (err: any) {
89
+ guard.rollback()
90
+ throw err
91
+ }
92
+
93
+ guard.finish()
94
+ return result
95
+ }
96
+
97
+
98
+ class CopyGuard {
99
+ spec: GuardSpec
100
+ real: any
101
+ root: string
102
+ fs: any
103
+
104
+ orig = new Map<string, Buffer | null>()
105
+ dirs: string[] = []
106
+ edited = new Map<string, boolean>()
107
+ pending = new Map<string, Pending>()
108
+ touched = new Set<string>()
109
+ spared: string[] = []
110
+
111
+ constructor(spec: GuardSpec) {
112
+ this.spec = spec
113
+ this.real = spec.fs
114
+ this.root = Path.resolve(spec.folder)
115
+ this.fs = this.wrap()
116
+ }
117
+
118
+
119
+ wrap(): any {
120
+ const real = this.real
121
+ const gfs: any = {}
122
+
123
+ for (const key in real) {
124
+ const val = real[key]
125
+ gfs[key] = 'function' === typeof val ? val.bind(real) : val
126
+ }
127
+
128
+ gfs.writeFileSync = (path: any, data: any, opts?: any) => {
129
+ if (this.holds(path, toBuffer(data, opts))) {
130
+ return
131
+ }
132
+ this.before(path)
133
+ return real.writeFileSync(path, data, opts)
134
+ }
135
+
136
+ gfs.appendFileSync = (path: any, data: any, opts?: any) => {
137
+ this.before(path)
138
+ return real.appendFileSync(path, data, opts)
139
+ }
140
+
141
+ gfs.copyFileSync = (from: any, to: any, mode?: any) => {
142
+ if (this.holds(to, Buffer.from(real.readFileSync(from)))) {
143
+ return
144
+ }
145
+ this.before(to)
146
+ return real.copyFileSync(from, to, mode)
147
+ }
148
+
149
+ gfs.renameSync = (from: any, to: any) => {
150
+ const data = this.isFile(from) ? this.read(from) : undefined
151
+ if (null != data && this.holds(to, data)) {
152
+ this.before(from)
153
+ real.unlinkSync(from)
154
+ return
155
+ }
156
+ this.before(from)
157
+ this.before(to)
158
+ return real.renameSync(from, to)
159
+ }
160
+
161
+ gfs.unlinkSync = (path: any) => {
162
+ if (this.spares(path)) {
163
+ return
164
+ }
165
+ this.before(path)
166
+ return real.unlinkSync(path)
167
+ }
168
+
169
+ gfs.mkdirSync = (path: any, opts?: any) => {
170
+ const missing = this.missingDirs(path)
171
+ const res = real.mkdirSync(path, opts)
172
+ this.dirs.push(...missing.filter((dir: string) => real.existsSync(dir)))
173
+ return res
174
+ }
175
+
176
+ gfs.readFileSync = (path: any, opts?: any) => {
177
+ const held = 'string' === typeof path ?
178
+ this.pending.get(Path.resolve(path)) : undefined
179
+ if (held instanceof Buffer) {
180
+ const encoding = 'string' === typeof opts ? opts : opts?.encoding
181
+ return null == encoding ? Buffer.from(held) : held.toString(encoding)
182
+ }
183
+ return real.readFileSync(path, opts)
184
+ }
185
+
186
+ return gfs
187
+ }
188
+
189
+
190
+ rel(path: any): string | undefined {
191
+ if ('string' !== typeof path) {
192
+ return undefined
193
+ }
194
+
195
+ const rel = Path.relative(this.root, Path.resolve(path)).split(Path.sep).join('/')
196
+
197
+ return ('' === rel || '..' === rel || rel.startsWith('../') || Path.isAbsolute(rel)) ?
198
+ undefined : rel
199
+ }
200
+
201
+
202
+ managed(rel: string | undefined): rel is string {
203
+ return null != rel &&
204
+ !within(rel, this.spec.basedir) && !within(rel, this.spec.conflictdir) &&
205
+ this.spec.manage(rel)
206
+ }
207
+
208
+
209
+ isFile(path: any): boolean {
210
+ try {
211
+ return true === this.real.statSync(path)?.isFile()
212
+ }
213
+ catch (err: any) {
214
+ return false
215
+ }
216
+ }
217
+
218
+
219
+ read(path: any): Buffer {
220
+ return Buffer.from(this.real.readFileSync(path))
221
+ }
222
+
223
+
224
+ createdHere(abs: string): boolean {
225
+ return this.orig.has(abs) && null === this.orig.get(abs)
226
+ }
227
+
228
+
229
+ before(path: any) {
230
+ if ('string' !== typeof path) {
231
+ return
232
+ }
233
+
234
+ const abs = Path.resolve(path)
235
+
236
+ if (!this.orig.has(abs)) {
237
+ if (this.isFile(abs)) {
238
+ this.orig.set(abs, this.read(abs))
239
+ }
240
+ else if (!this.real.existsSync(abs)) {
241
+ this.orig.set(abs, null)
242
+ }
243
+ }
244
+
245
+ const rel = this.rel(abs)
246
+ if (this.managed(rel)) {
247
+ this.touched.add(rel)
248
+ }
249
+ }
250
+
251
+
252
+ // Was this file changed after the toolchain wrote it? Asked once, before
253
+ // the run writes it; a file the record does not hold counts as changed.
254
+ isEdited(rel: string, abs: string): boolean {
255
+ let edited = this.edited.get(rel)
256
+
257
+ if (null == edited) {
258
+ const print = this.spec.recorded(rel)
259
+ edited = null == print || print !== this.spec.fingerprint(this.read(abs))
260
+ this.edited.set(rel, edited)
261
+ }
262
+
263
+ return edited
264
+ }
265
+
266
+
267
+ // Defers a write over a locally changed file, leaving it on disk as it is.
268
+ holds(path: any, data: Buffer): boolean {
269
+ if ('string' !== typeof path) {
270
+ return false
271
+ }
272
+
273
+ const abs = Path.resolve(path)
274
+ const rel = this.rel(abs)
275
+
276
+ if (!this.managed(rel)) {
277
+ return false
278
+ }
279
+
280
+ if (this.pending.has(abs)) {
281
+ this.pending.set(abs, data)
282
+ return true
283
+ }
284
+
285
+ if (this.createdHere(abs) || !this.isFile(abs) || !this.isEdited(rel, abs)) {
286
+ return false
287
+ }
288
+
289
+ if (sameText(this.read(abs), data)) {
290
+ return false
291
+ }
292
+
293
+ this.pending.set(abs, data)
294
+ return true
295
+ }
296
+
297
+
298
+ // A delete of a changed file, or of one no add wrote, leaves it in place.
299
+ spares(path: any): boolean {
300
+ if ('string' !== typeof path) {
301
+ return false
302
+ }
303
+
304
+ const abs = Path.resolve(path)
305
+ const rel = this.rel(abs)
306
+
307
+ if (!this.managed(rel) || this.createdHere(abs) || !this.isFile(abs)) {
308
+ return false
309
+ }
310
+
311
+ if (null == this.spec.recorded(rel)) {
312
+ this.spared.push(rel)
313
+ return true
314
+ }
315
+
316
+ if (this.pending.has(abs) || this.isEdited(rel, abs)) {
317
+ this.pending.set(abs, DELETED)
318
+ return true
319
+ }
320
+
321
+ return false
322
+ }
323
+
324
+
325
+ missingDirs(path: any): string[] {
326
+ if ('string' !== typeof path) {
327
+ return []
328
+ }
329
+
330
+ const missing: string[] = []
331
+ let dir = Path.resolve(path)
332
+
333
+ while (!this.real.existsSync(dir) && Path.dirname(dir) !== dir) {
334
+ missing.push(dir)
335
+ dir = Path.dirname(dir)
336
+ }
337
+
338
+ return missing
339
+ }
340
+
341
+
342
+ // An incoming copy the record already holds is its own base: the source
343
+ // did not move since the add that wrote it.
344
+ base(rel: string, incoming: Buffer): Buffer | undefined {
345
+ const print = this.spec.recorded(rel)
346
+
347
+ if (null == print) {
348
+ return undefined
349
+ }
350
+
351
+ if (print === this.spec.fingerprint(incoming)) {
352
+ return incoming
353
+ }
354
+
355
+ const stored = this.storePath(this.spec.basedir, rel)
356
+ if (this.isFile(stored)) {
357
+ const base = this.read(stored)
358
+ if (print === this.spec.fingerprint(base)) {
359
+ return base
360
+ }
361
+ }
362
+
363
+ for (const version of this.spec.versions?.(Path.join(this.root, rel)) ?? []) {
364
+ if (print === this.spec.fingerprint(version)) {
365
+ return version
366
+ }
367
+ }
368
+
369
+ return undefined
370
+ }
371
+
372
+
373
+ storePath(dir: string, rel: string): string {
374
+ return Path.join(this.root, ...dir.split('/'), ...rel.split('/'))
375
+ }
376
+
377
+
378
+ finish(): MergeOutcome {
379
+ const outcome: MergeOutcome = { merged: [], kept: [], retained: [], spared: this.spared }
380
+ const conflicts: Conflict[] = []
381
+ const writes: [string, Buffer][] = []
382
+ const bases = new Map<string, Buffer>()
383
+
384
+ for (const [abs, incoming] of this.pending) {
385
+ const rel = this.rel(abs) as string
386
+
387
+ if (DELETED === incoming) {
388
+ outcome.retained.push(rel)
389
+ continue
390
+ }
391
+
392
+ const local = this.read(abs)
393
+ const base = this.base(rel, incoming)
394
+
395
+ let conflict: Conflict | undefined
396
+ let merged: Buffer | undefined
397
+
398
+ if (null == base) {
399
+ conflict = { rel, why: 'nobase', preview: incoming }
400
+ }
401
+ else if (isBinary(incoming) || isBinary(local) || isBinary(base)) {
402
+ conflict = { rel, why: 'binary', preview: incoming }
403
+ }
404
+ else {
405
+ const res = merge3(incoming, base, local, this.spec.what)
406
+ if (res.conflict) {
407
+ conflict = { rel, why: 'conflict', preview: res.content }
408
+ }
409
+ else {
410
+ merged = res.content
411
+ }
412
+ }
413
+
414
+ if (null != conflict && true === this.spec.keep) {
415
+ outcome.kept.push(rel)
416
+ bases.set(rel, incoming)
417
+ }
418
+ else if (null != conflict) {
419
+ conflicts.push(conflict)
420
+ }
421
+ else {
422
+ writes.push([abs, merged as Buffer])
423
+ outcome.merged.push(rel)
424
+ bases.set(rel, incoming)
425
+ }
426
+ }
427
+
428
+ if (0 < conflicts.length) {
429
+ this.rollback()
430
+ this.previews(conflicts)
431
+ throw this.failure(conflicts)
432
+ }
433
+
434
+ for (const [abs, content] of writes) {
435
+ if (!sameText(this.read(abs), content)) {
436
+ this.writeAtomic(abs, content)
437
+ }
438
+ }
439
+
440
+ this.keepBases(bases)
441
+ this.clearDir(this.spec.conflictdir)
442
+ this.report(outcome)
443
+
444
+ return outcome
445
+ }
446
+
447
+
448
+ // The store holds a base only where the file differs from it: elsewhere
449
+ // the file itself, checked against the record, is the base.
450
+ keepBases(bases: Map<string, Buffer>) {
451
+ for (const rel of new Set([...this.touched, ...bases.keys()])) {
452
+ const at = this.storePath(this.spec.basedir, rel)
453
+ const base = bases.get(rel)
454
+
455
+ if (null != base) {
456
+ this.real.mkdirSync(Path.dirname(at), { recursive: true })
457
+ this.real.writeFileSync(at, base)
458
+ }
459
+ else if (this.isFile(at)) {
460
+ this.real.unlinkSync(at)
461
+ }
462
+ }
463
+ }
464
+
465
+
466
+ rollback() {
467
+ for (const [abs, content] of Array.from(this.orig).reverse()) {
468
+ try {
469
+ if (null != content) {
470
+ this.real.writeFileSync(abs, content)
471
+ }
472
+ else if (this.isFile(abs)) {
473
+ this.real.unlinkSync(abs)
474
+ }
475
+ }
476
+ catch (err: any) {
477
+ this.spec.log.warn({
478
+ point: 'copy-merge-rollback', file: abs, err: err?.message,
479
+ note: 'could not restore ' + abs + ': ' + err?.message,
480
+ })
481
+ }
482
+ }
483
+
484
+ for (const dir of [...this.dirs].reverse()) {
485
+ try {
486
+ if (0 === this.real.readdirSync(dir).length) {
487
+ this.real.rmdirSync(dir)
488
+ }
489
+ }
490
+ catch (err: any) {
491
+ continue
492
+ }
493
+ }
494
+ }
495
+
496
+
497
+ previews(conflicts: Conflict[]) {
498
+ this.clearDir(this.spec.conflictdir)
499
+
500
+ const dir = this.storePath(this.spec.conflictdir, '')
501
+ this.real.mkdirSync(dir, { recursive: true })
502
+ this.real.writeFileSync(Path.join(dir, '.gitignore'), '*\n')
503
+
504
+ for (const conflict of conflicts) {
505
+ const at = this.storePath(this.spec.conflictdir, conflict.rel)
506
+ this.real.mkdirSync(Path.dirname(at), { recursive: true })
507
+ this.real.writeFileSync(at, conflict.preview ?? Buffer.alloc(0))
508
+ }
509
+ }
510
+
511
+
512
+ clearDir(rel: string) {
513
+ const dir = this.storePath(rel, '')
514
+ if (this.real.existsSync(dir)) {
515
+ this.real.rmSync(dir, { recursive: true, force: true })
516
+ }
517
+ }
518
+
519
+
520
+ writeAtomic(abs: string, content: Buffer) {
521
+ const tmp = abs + '.copymerge-' + process.pid + '-' + Date.now()
522
+ this.real.writeFileSync(tmp, content)
523
+ this.real.renameSync(tmp, abs)
524
+ }
525
+
526
+
527
+ failure(conflicts: Conflict[]): Error {
528
+ const why = {
529
+ conflict: 'the incoming copy and the local change touch the same lines',
530
+ nobase: 'no record of what the toolchain wrote here, so nothing to merge ' +
531
+ 'against; a base is found in git once the copies an add writes are committed',
532
+ binary: 'a binary file, which is never merged',
533
+ }
534
+
535
+ const shown = this.spec.conflictdir
536
+
537
+ const msg = this.spec.what + ': ' + conflicts.length +
538
+ ' locally changed file(s) cannot take the incoming copy, so nothing was changed:\n ' +
539
+ conflicts.map((c: Conflict) => c.rel + ' (' + why[c.why] + ')').join('\n ') +
540
+ '\n\n The incoming copies are in ' + shown + '/, with conflict markers where ' +
541
+ 'both sides changed the same lines. Then either:' +
542
+ '\n - fold the incoming change into the local file by hand and re-run with ' +
543
+ '--keep, which keeps the local files and records the incoming copies as ' +
544
+ 'their base;' +
545
+ '\n - or re-run with --force, which takes the incoming copies and discards ' +
546
+ 'the local changes.'
547
+
548
+ return null == this.spec.error ? new Error(msg) : this.spec.error(msg)
549
+ }
550
+
551
+
552
+ report(outcome: MergeOutcome) {
553
+ const log = this.spec.log
554
+
555
+ if (0 < outcome.merged.length) {
556
+ log.info({
557
+ point: 'copy-merge-merged', files: outcome.merged,
558
+ note: this.spec.what + ': merged the incoming copy into ' +
559
+ outcome.merged.length + ' locally changed file(s): ' + outcome.merged.join(', ')
560
+ })
561
+ }
562
+
563
+ if (0 < outcome.kept.length) {
564
+ log.warn({
565
+ point: 'copy-merge-kept', files: outcome.kept,
566
+ note: this.spec.what + ': --keep, kept ' + outcome.kept.length +
567
+ ' local file(s) as they are: ' + outcome.kept.join(', ')
568
+ })
569
+ }
570
+
571
+ if (0 < outcome.retained.length) {
572
+ log.warn({
573
+ point: 'copy-merge-retained', files: outcome.retained,
574
+ note: this.spec.what + ': the source no longer provides ' +
575
+ outcome.retained.length + ' locally changed file(s), left in place: ' +
576
+ outcome.retained.join(', ')
577
+ })
578
+ }
579
+
580
+ if (0 < outcome.spared.length) {
581
+ log.info({
582
+ point: 'copy-merge-spared', files: outcome.spared,
583
+ note: this.spec.what + ': left ' + outcome.spared.length +
584
+ ' file(s) no add wrote in place: ' + outcome.spared.join(', ')
585
+ })
586
+ }
587
+ }
588
+ }
589
+
590
+
591
+ function merge3(
592
+ incoming: Buffer, base: Buffer, local: Buffer, what: string,
593
+ ): { conflict: boolean, content: Buffer } {
594
+ const text = (bytes: Buffer) => bytes.toString('latin1').replace(/\r\n/g, '\n')
595
+
596
+ const res: any = DiffUtil.merge(text(incoming), text(base), text(local), {
597
+ labels: { generated: 'INCOMING (' + what + ')', existing: 'LOCAL' },
598
+ })
599
+
600
+ return {
601
+ conflict: true === res.conflict || 'unresolved' === res.outcome,
602
+ content: Buffer.from(res.content, 'latin1'),
603
+ }
604
+ }
605
+
606
+
607
+ function within(rel: string, dir: string): boolean {
608
+ return rel === dir || rel.startsWith(dir + '/')
609
+ }
610
+
611
+
612
+ function toBuffer(data: any, opts?: any): Buffer {
613
+ if (Buffer.isBuffer(data)) {
614
+ return data
615
+ }
616
+
617
+ if (ArrayBuffer.isView(data)) {
618
+ return Buffer.from(data.buffer, data.byteOffset, data.byteLength)
619
+ }
620
+
621
+ const encoding = ('string' === typeof opts ? opts : opts?.encoding) || 'utf8'
622
+ return Buffer.from(String(data), encoding)
623
+ }
624
+
625
+
626
+ function sameText(a: Buffer, b: Buffer): boolean {
627
+ return a.toString('latin1').replace(/\r\n/g, '\n') ===
628
+ b.toString('latin1').replace(/\r\n/g, '\n')
629
+ }
630
+
631
+
632
+ function isBinary(bytes: Buffer): boolean {
633
+ return 0 <= bytes.subarray(0, 8000).indexOf(0)
634
+ }
635
+
636
+
637
+ // Committed versions of a file, newest first: the index, HEAD, then the
638
+ // commits that touched it. Nothing outside a git checkout.
639
+ function gitVersions(abs: string, limit = 20): Iterable<Buffer> {
640
+ return {
641
+ *[Symbol.iterator]() {
642
+ let top: string
643
+ let rel: string
644
+
645
+ try {
646
+ const dir = Path.dirname(abs)
647
+ top = git(dir, ['rev-parse', '--show-toplevel']).toString().trim()
648
+ const real = Path.join(realpathSync(dir), Path.basename(abs))
649
+ rel = Path.relative(top, real).split(Path.sep).join('/')
650
+ }
651
+ catch (err: any) {
652
+ return
653
+ }
654
+
655
+ const shown = (spec: string) => {
656
+ try {
657
+ return git(top, ['show', spec])
658
+ }
659
+ catch (err: any) {
660
+ return undefined
661
+ }
662
+ }
663
+
664
+ for (const spec of [':' + rel, 'HEAD:' + rel]) {
665
+ const version = shown(spec)
666
+ if (null != version) {
667
+ yield version
668
+ }
669
+ }
670
+
671
+ let shas: string[] = []
672
+ try {
673
+ shas = git(top, ['log', '--format=%H', '-n', String(limit), '--', rel])
674
+ .toString().split('\n').filter(Boolean)
675
+ }
676
+ catch (err: any) {
677
+ return
678
+ }
679
+
680
+ for (const sha of shas) {
681
+ const version = shown(sha + ':' + rel)
682
+ if (null != version) {
683
+ yield version
684
+ }
685
+ }
686
+ }
687
+ }
688
+ }
689
+
690
+
691
+ function git(cwd: string, args: string[]): Buffer {
692
+ return execFileSync('git', args, {
693
+ cwd, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024,
694
+ })
695
+ }
696
+
697
+
698
+ export type {
699
+ CopyMode,
700
+ GuardSpec,
701
+ MergeOutcome,
702
+ }
703
+
704
+ export {
705
+ MERGE,
706
+ OVERWRITE,
707
+ copyMode,
708
+ withCopyMerge,
709
+ gitVersions,
710
+ merge3,
711
+ }