@voxgig/system 1.3.1 → 1.6.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,323 @@
1
+ /* Copyright © 2026 Voxgig Ltd, MIT License. */
2
+
3
+ // Template customization tooling: list / eject / diff for the generation
4
+ // templates provided by @voxgig/build.
5
+ //
6
+ // Layered resolution (first hit wins):
7
+ // 1. backend/src/gen/<name>.ts compiled generator override (deep custom)
8
+ // 2. backend/tm/<area>/<frag> project fragment (text-level custom)
9
+ // 3. @voxgig/build package defaults
10
+ //
11
+ // Fragment names are area-qualified (lambda/srv.yml.frag,
12
+ // env/aws/serverless.yml.frag); bare names default to the lambda area.
13
+ //
14
+ // eject copies a package fragment (or, with code=true, a template source
15
+ // rewired to the package's public API) into the project, recording
16
+ // provenance in tm/lambda/.ejected.json so diff can show upstream drift
17
+ // after package upgrades.
18
+
19
+ import Crypto from 'node:crypto'
20
+ import Fs from 'node:fs'
21
+ import Path from 'node:path'
22
+ import { spawnSync } from 'node:child_process'
23
+
24
+ import { resolveModelFiles } from './add'
25
+
26
+
27
+ // The code-level template generators (env/lambda/<name>.ts in the package).
28
+ const GENERATORS = ['srv_yml', 'srv_handler', 'res_yml']
29
+
30
+ // Generator shorthand -> default fragment.
31
+ const GENERATOR_FRAG: Record<string, string> = {
32
+ srv_yml: 'lambda/srv.yml.frag',
33
+ srv_handler: 'lambda/srv_handler.ts.frag',
34
+ res_yml: 'lambda/res.role.yml.frag',
35
+ }
36
+
37
+
38
+ export type Project = {
39
+ backend: string // the project backend folder (holds model/, tm/, src/)
40
+ tm: string // backend/tm (project fragment root)
41
+ gen: string // backend/src/gen
42
+ pkg: string // resolved @voxgig/build package folder
43
+ pkgtm: string // package tm root
44
+ }
45
+
46
+
47
+ function resolveProject(start: string): Project {
48
+ const files = resolveModelFiles(start)
49
+ const backend = Path.dirname(files.folder)
50
+
51
+ let pkgjson: string
52
+ try {
53
+ pkgjson = require.resolve('@voxgig/build/package.json', {
54
+ paths: [backend, files.folder, start],
55
+ })
56
+ }
57
+ catch (e: any) {
58
+ throw new Error('@voxgig/build not found from ' + backend +
59
+ ' - is it installed? (npm install)')
60
+ }
61
+ const pkg = Path.dirname(pkgjson)
62
+
63
+ return {
64
+ backend,
65
+ tm: Path.join(backend, 'tm'),
66
+ gen: Path.join(backend, 'src', 'gen'),
67
+ pkg,
68
+ pkgtm: Path.join(pkg, 'tm'),
69
+ }
70
+ }
71
+
72
+
73
+ function pkgVersion(project: Project): string {
74
+ return JSON.parse(
75
+ Fs.readFileSync(Path.join(project.pkg, 'package.json'), 'utf8')).version
76
+ }
77
+
78
+ function sha256(content: string | Buffer): string {
79
+ return Crypto.createHash('sha256').update(content).digest('hex')
80
+ }
81
+
82
+
83
+ type Provenance = Record<string, {
84
+ package: string, version: string, sha256: string
85
+ }>
86
+
87
+ // Provenance lives at tm/.ejected.json; the pre-area location
88
+ // (tm/lambda/.ejected.json) and bare fragment keys are still read, with
89
+ // bare keys normalized to the lambda area.
90
+ function readProvenance(project: Project): Provenance {
91
+ const out: Provenance = {}
92
+ for (const p of [
93
+ Path.join(project.tm, 'lambda', '.ejected.json'),
94
+ Path.join(project.tm, '.ejected.json'),
95
+ ]) {
96
+ if (Fs.existsSync(p)) {
97
+ const prov = JSON.parse(Fs.readFileSync(p, 'utf8'))
98
+ for (const key of Object.keys(prov)) {
99
+ const norm = (key.includes('/') || key.startsWith('code:')) ?
100
+ key : 'lambda/' + key
101
+ out[norm] = prov[key]
102
+ }
103
+ }
104
+ }
105
+ return out
106
+ }
107
+
108
+ function writeProvenance(project: Project, prov: Provenance) {
109
+ Fs.mkdirSync(project.tm, { recursive: true })
110
+ Fs.writeFileSync(Path.join(project.tm, '.ejected.json'),
111
+ JSON.stringify(prov, null, 2) + '\n')
112
+ const old = Path.join(project.tm, 'lambda', '.ejected.json')
113
+ if (Fs.existsSync(old)) {
114
+ Fs.rmSync(old)
115
+ }
116
+ }
117
+
118
+
119
+ // All package fragments, as area-qualified relative names.
120
+ function pkgFragments(project: Project): string[] {
121
+ const out: string[] = []
122
+ const walk = (rel: string) => {
123
+ const dir = Path.join(project.pkgtm, rel)
124
+ if (!Fs.existsSync(dir)) {
125
+ return
126
+ }
127
+ for (const e of Fs.readdirSync(dir, { withFileTypes: true })) {
128
+ const relpath = '' === rel ? e.name : rel + '/' + e.name
129
+ if (e.isDirectory()) {
130
+ walk(relpath)
131
+ }
132
+ else if (e.name.endsWith('.frag')) {
133
+ out.push(relpath)
134
+ }
135
+ }
136
+ }
137
+ walk('')
138
+ return out.sort()
139
+ }
140
+
141
+
142
+ export type TemplateRow = {
143
+ name: string // fragment or generator name
144
+ kind: 'fragment' | 'generator'
145
+ layer: string // which layer currently provides it
146
+ }
147
+
148
+ // List each template with the layer that currently provides it.
149
+ function listTemplates(start: string): TemplateRow[] {
150
+ const project = resolveProject(start)
151
+
152
+ const rows: TemplateRow[] = []
153
+
154
+ for (const gen of GENERATORS) {
155
+ const override = Path.join(project.gen, gen + '.ts')
156
+ rows.push({
157
+ name: gen,
158
+ kind: 'generator',
159
+ layer: Fs.existsSync(override) ?
160
+ 'project (src/gen/' + gen + '.ts)' : 'package',
161
+ })
162
+ }
163
+
164
+ for (const frag of pkgFragments(project)) {
165
+ rows.push({
166
+ name: frag,
167
+ kind: 'fragment',
168
+ layer: Fs.existsSync(Path.join(project.tm, frag)) ?
169
+ 'project (tm/' + frag + ')' : 'package',
170
+ })
171
+ }
172
+
173
+ return rows
174
+ }
175
+
176
+
177
+ // Eject a fragment (text template) into backend/tm/lambda/.
178
+ function ejectFragment(start: string, name: string): string {
179
+ const project = resolveProject(start)
180
+
181
+ let frag = GENERATOR_FRAG[name] || name
182
+ if (!frag.includes('/')) {
183
+ frag = 'lambda/' + frag
184
+ }
185
+ const srcpath = Path.join(project.pkgtm, frag)
186
+ if (!Fs.existsSync(srcpath)) {
187
+ throw new Error('unknown fragment: ' + frag +
188
+ ' (available: ' + pkgFragments(project).join(', ') + ')')
189
+ }
190
+
191
+ const dest = Path.join(project.tm, frag)
192
+ if (Fs.existsSync(dest)) {
193
+ throw new Error('already ejected: ' + Path.relative(start, dest))
194
+ }
195
+
196
+ const content = Fs.readFileSync(srcpath, 'utf8')
197
+ Fs.mkdirSync(Path.dirname(dest), { recursive: true })
198
+ Fs.writeFileSync(dest, content)
199
+
200
+ const prov = readProvenance(project)
201
+ prov[frag] = {
202
+ package: '@voxgig/build',
203
+ version: pkgVersion(project),
204
+ sha256: sha256(content),
205
+ }
206
+ writeProvenance(project, prov)
207
+
208
+ return dest
209
+ }
210
+
211
+
212
+ // Eject a generator source (compiled component override) into
213
+ // backend/src/gen/<name>.ts, rewiring package-internal imports to the
214
+ // public '@voxgig/build' API.
215
+ function ejectCode(start: string, name: string): string {
216
+ const project = resolveProject(start)
217
+
218
+ if (!GENERATORS.includes(name)) {
219
+ throw new Error('unknown generator: ' + name +
220
+ ' (available: ' + GENERATORS.join(', ') + ')')
221
+ }
222
+
223
+ const srcpath = Path.join(project.pkg, 'env', 'lambda', name + '.ts')
224
+ if (!Fs.existsSync(srcpath)) {
225
+ throw new Error('template source not shipped by installed ' +
226
+ '@voxgig/build (' + pkgVersion(project) + ') - needs >= 4.1.0')
227
+ }
228
+
229
+ const dest = Path.join(project.gen, name + '.ts')
230
+ if (Fs.existsSync(dest)) {
231
+ throw new Error('already ejected: ' + Path.relative(start, dest))
232
+ }
233
+
234
+ let src = Fs.readFileSync(srcpath, 'utf8')
235
+
236
+ // Package-internal imports -> public API.
237
+ src = src
238
+ .replace(/from '\.\/generate'/g, "from '@voxgig/build'")
239
+ .replace(/from '\.\.\/\.\.\/shape\/msg'/g, "from '@voxgig/build'")
240
+ .replace(/from '\.\.\/\.\.\/shape\/conf'/g, "from '@voxgig/build'")
241
+ .replace(/from '\.\.\/\.\.\/yml\/res_dynamo_yml'/g, "from '@voxgig/build'")
242
+
243
+ const version = pkgVersion(project)
244
+ src = '// Ejected from @voxgig/build ' + version +
245
+ ' (env/lambda/' + name + '.ts).\n' +
246
+ '// This project copy now OWNS this generator: the build action uses\n' +
247
+ '// it instead of the package default. After editing, run:\n' +
248
+ '// npm run build && npm run model-build\n' +
249
+ '// Compare with the package version: voxgig-system template diff\n' +
250
+ src
251
+
252
+ Fs.mkdirSync(project.gen, { recursive: true })
253
+ Fs.writeFileSync(dest, src)
254
+
255
+ const prov = readProvenance(project)
256
+ prov['code:' + name] = {
257
+ package: '@voxgig/build',
258
+ version,
259
+ sha256: sha256(Fs.readFileSync(srcpath)),
260
+ }
261
+ writeProvenance(project, prov)
262
+
263
+ return dest
264
+ }
265
+
266
+
267
+ export type DiffRow = {
268
+ name: string
269
+ upstream: 'unchanged' | 'changed' | 'missing'
270
+ diff?: string // unified diff of package vs project copy
271
+ }
272
+
273
+ // For each ejected template, report upstream drift (package version now
274
+ // differs from what was ejected) and show package-vs-project diffs.
275
+ function diffTemplates(start: string): DiffRow[] {
276
+ const project = resolveProject(start)
277
+ const prov = readProvenance(project)
278
+
279
+ const rows: DiffRow[] = []
280
+
281
+ for (const key of Object.keys(prov).sort()) {
282
+ const rec = prov[key]
283
+ const isCode = key.startsWith('code:')
284
+ const name = isCode ? key.substring(5) : key
285
+
286
+ const pkgfile = isCode ?
287
+ Path.join(project.pkg, 'env', 'lambda', name + '.ts') :
288
+ Path.join(project.pkgtm, name)
289
+ const projfile = isCode ?
290
+ Path.join(project.gen, name + '.ts') :
291
+ Path.join(project.tm, name)
292
+
293
+ if (!Fs.existsSync(pkgfile)) {
294
+ rows.push({ name: key, upstream: 'missing' })
295
+ continue
296
+ }
297
+
298
+ const upstream =
299
+ sha256(Fs.readFileSync(pkgfile)) === rec.sha256 ?
300
+ 'unchanged' : 'changed'
301
+
302
+ let diff: string | undefined = undefined
303
+ if (Fs.existsSync(projfile)) {
304
+ const res = spawnSync('diff', ['-u', pkgfile, projfile],
305
+ { encoding: 'utf8' })
306
+ diff = 0 === res.status ? '' : res.stdout
307
+ }
308
+
309
+ rows.push({ name: key, upstream, diff })
310
+ }
311
+
312
+ return rows
313
+ }
314
+
315
+
316
+ export {
317
+ resolveProject,
318
+ listTemplates,
319
+ ejectFragment,
320
+ ejectCode,
321
+ diffTemplates,
322
+ GENERATORS,
323
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxgig/system",
3
- "version": "1.3.1",
3
+ "version": "1.6.0",
4
4
  "main": "dist/system.js",
5
5
  "type": "commonjs",
6
6
  "types": "dist/system.d.ts",
package/system.ts CHANGED
@@ -13,7 +13,8 @@ import { Utility } from './lib/utility'
13
13
 
14
14
  import { MakeSrv } from './srv/make'
15
15
 
16
- import { addEntity, addSrv, addMsg, addFields, resolveModelFiles } from './lib/add'
16
+ import { addEntity, addSrv, addMsg, addFields, addEnv, resolveModelFiles } from './lib/add'
17
+ import { listTemplates, ejectFragment, ejectCode, diffTemplates } from './lib/template'
17
18
 
18
19
 
19
20
 
@@ -221,11 +222,20 @@ const Add = {
221
222
  srv: addSrv,
222
223
  msg: addMsg,
223
224
  fields: addFields,
225
+ env: addEnv,
224
226
  resolveModelFiles,
225
227
  }
226
228
 
229
+ const Template = {
230
+ list: listTemplates,
231
+ eject: ejectFragment,
232
+ ejectCode,
233
+ diff: diffTemplates,
234
+ }
235
+
227
236
  export {
228
237
  Add,
238
+ Template,
229
239
  gubuify,
230
240
  System,
231
241
  MakeSrv,