@voxgig/sdkgen 3.4.6 → 3.5.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.
Files changed (198) hide show
  1. package/bin/voxgig-sdkgen +78 -10
  2. package/dist/action/action.d.ts +5 -2
  3. package/dist/action/action.js +113 -6
  4. package/dist/action/action.js.map +1 -1
  5. package/dist/action/check.d.ts +15 -0
  6. package/dist/action/check.js +347 -0
  7. package/dist/action/check.js.map +1 -0
  8. package/dist/action/dispatch.d.ts +8 -0
  9. package/dist/action/dispatch.js +74 -0
  10. package/dist/action/dispatch.js.map +1 -0
  11. package/dist/action/docs.d.ts +4 -0
  12. package/dist/action/docs.js +236 -0
  13. package/dist/action/docs.js.map +1 -0
  14. package/dist/action/doctor.d.ts +5 -2
  15. package/dist/action/doctor.js +526 -74
  16. package/dist/action/doctor.js.map +1 -1
  17. package/dist/action/feature.js +96 -17
  18. package/dist/action/feature.js.map +1 -1
  19. package/dist/action/kind.d.ts +30 -0
  20. package/dist/action/kind.js +258 -0
  21. package/dist/action/kind.js.map +1 -0
  22. package/dist/action/package.d.ts +25 -0
  23. package/dist/action/package.js +852 -0
  24. package/dist/action/package.js.map +1 -0
  25. package/dist/action/resolve.d.ts +18 -0
  26. package/dist/action/resolve.js +280 -0
  27. package/dist/action/resolve.js.map +1 -0
  28. package/dist/action/target.d.ts +7 -1
  29. package/dist/action/target.js +240 -97
  30. package/dist/action/target.js.map +1 -1
  31. package/dist/cmp/Docs.d.ts +4 -0
  32. package/dist/cmp/Docs.js +98 -0
  33. package/dist/cmp/Docs.js.map +1 -0
  34. package/dist/cmp/ExternalDocs.d.ts +2 -0
  35. package/dist/cmp/ExternalDocs.js +43 -0
  36. package/dist/cmp/ExternalDocs.js.map +1 -0
  37. package/dist/cmp/ReadmeTop.js +5 -1
  38. package/dist/cmp/ReadmeTop.js.map +1 -1
  39. package/dist/helpers/definition.d.ts +5 -0
  40. package/dist/helpers/definition.js +72 -0
  41. package/dist/helpers/definition.js.map +1 -0
  42. package/dist/helpers/featureSource.d.ts +8 -2
  43. package/dist/helpers/featureSource.js +61 -12
  44. package/dist/helpers/featureSource.js.map +1 -1
  45. package/dist/helpers/junk.d.ts +11 -0
  46. package/dist/helpers/junk.js +149 -0
  47. package/dist/helpers/junk.js.map +1 -0
  48. package/dist/helpers/manifest.d.ts +50 -0
  49. package/dist/helpers/manifest.js +328 -0
  50. package/dist/helpers/manifest.js.map +1 -0
  51. package/dist/helpers/modelcheck.d.ts +22 -0
  52. package/dist/helpers/modelcheck.js +203 -0
  53. package/dist/helpers/modelcheck.js.map +1 -0
  54. package/dist/helpers/semver.d.ts +4 -0
  55. package/dist/helpers/semver.js +174 -0
  56. package/dist/helpers/semver.js.map +1 -0
  57. package/dist/helpers/shipped.d.ts +4 -0
  58. package/dist/helpers/shipped.js +36 -0
  59. package/dist/helpers/shipped.js.map +1 -0
  60. package/dist/helpers/stdrep.d.ts +9 -1
  61. package/dist/helpers/stdrep.js +22 -0
  62. package/dist/helpers/stdrep.js.map +1 -1
  63. package/dist/sdkgen.d.ts +15 -3
  64. package/dist/sdkgen.js +233 -67
  65. package/dist/sdkgen.js.map +1 -1
  66. package/dist/tsconfig.tsbuildinfo +1 -1
  67. package/dist/types.d.ts +10 -2
  68. package/dist/utility.js +17 -0
  69. package/dist/utility.js.map +1 -1
  70. package/model/sdkgen.aontu +69 -0
  71. package/package.json +3 -2
  72. package/project/.sdk/model/feature/audit.aontu +1 -0
  73. package/project/.sdk/model/feature/cache.aontu +1 -0
  74. package/project/.sdk/model/feature/clienttrack.aontu +1 -0
  75. package/project/.sdk/model/feature/debug.aontu +1 -0
  76. package/project/.sdk/model/feature/idempotency.aontu +1 -0
  77. package/project/.sdk/model/feature/log.aontu +1 -0
  78. package/project/.sdk/model/feature/metrics.aontu +1 -0
  79. package/project/.sdk/model/feature/netsim.aontu +1 -0
  80. package/project/.sdk/model/feature/paging.aontu +1 -0
  81. package/project/.sdk/model/feature/proxy.aontu +1 -0
  82. package/project/.sdk/model/feature/ratelimit.aontu +1 -0
  83. package/project/.sdk/model/feature/rbac.aontu +1 -0
  84. package/project/.sdk/model/feature/retry.aontu +1 -0
  85. package/project/.sdk/model/feature/streaming.aontu +1 -0
  86. package/project/.sdk/model/feature/telemetry.aontu +1 -0
  87. package/project/.sdk/model/feature/test.aontu +1 -0
  88. package/project/.sdk/model/feature/timeout.aontu +1 -0
  89. package/project/.sdk/model/target/c.aontu +1 -0
  90. package/project/.sdk/model/target/clojure.aontu +1 -0
  91. package/project/.sdk/model/target/cpp.aontu +1 -0
  92. package/project/.sdk/model/target/dart.aontu +1 -0
  93. package/project/.sdk/model/target/elixir.aontu +1 -0
  94. package/project/.sdk/model/target/go-cli.aontu +1 -0
  95. package/project/.sdk/model/target/go-mcp.aontu +1 -0
  96. package/project/.sdk/model/target/go.aontu +1 -0
  97. package/project/.sdk/model/target/haskell.aontu +1 -0
  98. package/project/.sdk/model/target/java.aontu +1 -0
  99. package/project/.sdk/model/target/js.aontu +1 -0
  100. package/project/.sdk/model/target/kotlin.aontu +1 -0
  101. package/project/.sdk/model/target/lean.aontu +1 -0
  102. package/project/.sdk/model/target/lua.aontu +1 -0
  103. package/project/.sdk/model/target/ocaml.aontu +1 -0
  104. package/project/.sdk/model/target/perl.aontu +1 -0
  105. package/project/.sdk/model/target/php.aontu +1 -0
  106. package/project/.sdk/model/target/py-data.aontu +1 -0
  107. package/project/.sdk/model/target/py.aontu +1 -0
  108. package/project/.sdk/model/target/rb.aontu +1 -0
  109. package/project/.sdk/model/target/rust.aontu +1 -0
  110. package/project/.sdk/model/target/scala.aontu +1 -0
  111. package/project/.sdk/model/target/seneca-provider.aontu +1 -0
  112. package/project/.sdk/model/target/zig.aontu +1 -0
  113. package/project/.sdk/src/cmp/c/Config_c.ts +44 -0
  114. package/project/.sdk/src/cmp/c/fragment/Main.fragment.c +3 -1
  115. package/project/.sdk/src/cmp/clojure/Config_clojure.ts +37 -42
  116. package/project/.sdk/src/cmp/clojure/utility_clojure.ts +42 -0
  117. package/project/.sdk/src/cmp/cpp/Config_cpp.ts +18 -0
  118. package/project/.sdk/src/cmp/csharp/Config_csharp.ts +125 -52
  119. package/project/.sdk/src/cmp/csharp/fragment/Main.fragment.cs +3 -1
  120. package/project/.sdk/src/cmp/csharp/utility_csharp.ts +94 -6
  121. package/project/.sdk/src/cmp/dart/Config_dart.ts +56 -3
  122. package/project/.sdk/src/cmp/dart/fragment/Config.data.fragment.dart +74 -0
  123. package/project/.sdk/src/cmp/dart/fragment/Config.fragment.dart +6 -9
  124. package/project/.sdk/src/cmp/dart/fragment/Main.fragment.dart +16 -5
  125. package/project/.sdk/src/cmp/dart/utility_dart.ts +18 -0
  126. package/project/.sdk/src/cmp/elixir/Config_elixir.ts +128 -13
  127. package/project/.sdk/src/cmp/elixir/fragment/Main.fragment.ex +3 -1
  128. package/project/.sdk/src/cmp/elixir/utility_elixir.ts +6 -0
  129. package/project/.sdk/src/cmp/haskell/Config_haskell.ts +35 -52
  130. package/project/.sdk/src/cmp/haskell/Package_haskell.ts +1 -0
  131. package/project/.sdk/src/cmp/haskell/Test_haskell.ts +1 -1
  132. package/project/.sdk/src/cmp/haskell/utility_haskell.ts +15 -5
  133. package/project/.sdk/src/cmp/java/Config_java.ts +21 -0
  134. package/project/.sdk/src/cmp/kotlin/Config_kotlin.ts +15 -0
  135. package/project/.sdk/src/cmp/ocaml/Config_ocaml.ts +42 -39
  136. package/project/.sdk/src/cmp/perl/Config_perl.ts +15 -0
  137. package/project/.sdk/src/cmp/perl/fragment/Main.fragment.pm +3 -1
  138. package/project/.sdk/src/cmp/rust/Config_rust.ts +44 -0
  139. package/project/.sdk/src/cmp/rust/fragment/Main.fragment.rs +4 -2
  140. package/project/.sdk/src/cmp/scala/Config_scala.ts +15 -0
  141. package/project/.sdk/src/cmp/swift/Config_swift.ts +17 -0
  142. package/project/.sdk/src/cmp/swift/fragment/Main.fragment.swift +3 -1
  143. package/project/.sdk/src/cmp/zig/Config_zig.ts +44 -0
  144. package/project/.sdk/src/cmp/zig/TestEntity_zig.ts +9 -2
  145. package/project/.sdk/src/cmp/zig/fragment/Main.fragment.zig +31 -18
  146. package/project/.sdk/tm/c/core/sdk.h +1 -0
  147. package/project/.sdk/tm/c/utility/make_options.c +5 -1
  148. package/project/.sdk/tm/clojure/src/sdk/core.clj +51 -3
  149. package/project/.sdk/tm/cpp/core/types.hpp +4 -1
  150. package/project/.sdk/tm/cpp/utility/pipeline.hpp +5 -1
  151. package/project/.sdk/tm/csharp/test/PipelineTest.cs +15 -0
  152. package/project/.sdk/tm/csharp/utility/MakeOptions.cs +67 -1
  153. package/project/.sdk/tm/dart/lib/utility/MakeOptionsUtility.dart +43 -0
  154. package/project/.sdk/tm/elixir/lib/projectname/utility.ex +58 -2
  155. package/project/.sdk/tm/haskell/{test/TestJson.hs → src/SdkJson.hs} +8 -4
  156. package/project/.sdk/tm/haskell/src/SdkRuntime.hs +55 -2
  157. package/project/.sdk/tm/haskell/test/Runner.hs +1 -1
  158. package/project/.sdk/tm/haskell/test/StructCorpus.hs +1 -1
  159. package/project/.sdk/tm/java/core/SdkClient.java +3 -1
  160. package/project/.sdk/tm/java/utility/MakeOptions.java +5 -1
  161. package/project/.sdk/tm/kotlin/core/SdkClient.kt +3 -1
  162. package/project/.sdk/tm/kotlin/utility/MakeOptions.kt +5 -1
  163. package/project/.sdk/tm/ocaml/Makefile +3 -2
  164. package/project/.sdk/tm/ocaml/sdk_json.ml +106 -0
  165. package/project/.sdk/tm/ocaml/sdk_runtime.ml +56 -0
  166. package/project/.sdk/tm/ocaml/test/struct_corpus.ml +4 -86
  167. package/project/.sdk/tm/perl/utility/make_options.pm +5 -1
  168. package/project/.sdk/tm/rust/utility/make_options.rs +79 -1
  169. package/project/.sdk/tm/scala/core/SdkClient.scala +3 -1
  170. package/project/.sdk/tm/scala/utility/Make.scala +5 -1
  171. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/MakeOptions.swift +5 -1
  172. package/project/.sdk/tm/zig/core/utility.zig +85 -1
  173. package/project/.sdk/tm/zig/test/gotcha_test.zig +8 -8
  174. package/project/sdkgen-package.json +57 -0
  175. package/src/action/action.ts +136 -6
  176. package/src/action/check.ts +495 -0
  177. package/src/action/dispatch.ts +105 -0
  178. package/src/action/docs.ts +310 -0
  179. package/src/action/doctor.ts +692 -81
  180. package/src/action/feature.ts +109 -17
  181. package/src/action/kind.ts +375 -0
  182. package/src/action/package.ts +1173 -0
  183. package/src/action/resolve.ts +362 -0
  184. package/src/action/target.ts +290 -108
  185. package/src/cmp/Docs.ts +125 -0
  186. package/src/cmp/ExternalDocs.ts +54 -0
  187. package/src/cmp/ReadmeTop.ts +6 -1
  188. package/src/helpers/definition.ts +82 -0
  189. package/src/helpers/featureSource.ts +83 -13
  190. package/src/helpers/junk.ts +168 -0
  191. package/src/helpers/manifest.ts +474 -0
  192. package/src/helpers/modelcheck.ts +262 -0
  193. package/src/helpers/semver.ts +211 -0
  194. package/src/helpers/shipped.ts +41 -0
  195. package/src/helpers/stdrep.ts +70 -0
  196. package/src/sdkgen.ts +294 -66
  197. package/src/types.ts +33 -3
  198. package/src/utility.ts +17 -0
@@ -0,0 +1,1173 @@
1
+ // `package add` / `package list` — the whole-package verbs.
2
+ //
3
+ // See docs/design/sdkgen-packages.md §9.
4
+ //
5
+ // WHAT `package add` IS
6
+ //
7
+ // Not a new copy pipeline. It resolves a package ROOT, validates its manifest,
8
+ // and then runs the SAME per-kind add that `target add` / `feature add` run,
9
+ // once per provided item. Index handling, provenance stamping, feature
10
+ // fan-out, dry run and logging all come along unchanged, because they are not
11
+ // reimplemented. The value it adds over typing the individual adds is:
12
+ //
13
+ // - the manifest is REQUIRED and validated first, so a package that lies
14
+ // about what it provides fails before anything is written rather than
15
+ // halfway through;
16
+ // - the engine range is checked once, for the package, rather than never;
17
+ // - ordering: targets before features, because `feature add` fans a
18
+ // feature's source out across the targets already in the model, so a
19
+ // feature installed first would find none of the package's own targets.
20
+ //
21
+ // WHY VALIDATION COMES FIRST, ALL OF IT
22
+ //
23
+ // The items are installed in a loop. If the fourth one turns out not to be in
24
+ // the package, the first three are already written and the project is left
25
+ // half-installed with a partial index. Validating the whole claim up front is
26
+ // what makes the loop safe to run at all.
27
+
28
+ import Path from 'node:path'
29
+
30
+ import { KIT } from '../types'
31
+
32
+ import type {
33
+ ActionContext,
34
+ ActionResult,
35
+ } from '../types'
36
+
37
+ import { SdkGenError } from '../utility'
38
+
39
+ import {
40
+ MANIFEST,
41
+ ITEM_NAME_RE,
42
+ probePackage,
43
+ readManifest,
44
+ validateManifest,
45
+ } from '../helpers/manifest'
46
+
47
+ import type { Manifest, Finding } from '../helpers/manifest'
48
+
49
+ import { satisfies } from '../helpers/semver'
50
+
51
+ import { KINDS, kindDef } from './kind'
52
+
53
+ import { cmd_package_check } from './check'
54
+
55
+ import { doctor } from './doctor'
56
+
57
+ import { resolveSource, registerInstalled, nameConflict } from './resolve'
58
+ import type { Source } from './resolve'
59
+
60
+
61
+ const CMD_MAP: any = Object.assign(Object.create(null), {
62
+ add: cmd_package_add,
63
+ check: cmd_package_check,
64
+ list: cmd_package_list,
65
+ update: cmd_package_update,
66
+ })
67
+
68
+
69
+ // The kinds `package add` installs, IN ORDER.
70
+ //
71
+ // Targets first. `feature add` copies a feature's per-target source into every
72
+ // target already in the model, so a feature installed before the package's own
73
+ // targets would silently ship no source for them — the `feature-source-missing`
74
+ // warning, once per target, and a feature that does nothing.
75
+ //
76
+ // Docs LAST, for the mirror-image reason: a docs item is ABOUT the targets and
77
+ // features a project has (a page per SDK, a page per feature), and while it
78
+ // reads them at generate time rather than at add time, installing it last
79
+ // keeps the add log in the order a reader would expect and leaves the model
80
+ // complete before anything reads it.
81
+ const ADD_ORDER = ['target', 'feature', 'docs']
82
+
83
+
84
+ async function action_package(
85
+ args: string[], actx: ActionContext,
86
+ ): Promise<ActionResult> {
87
+ const cmdname = args[1]
88
+ const cmd = CMD_MAP[cmdname]
89
+
90
+ if (null == cmd) {
91
+ throw new SdkGenError(
92
+ 'Unknown package cmd: ' + cmdname + ' (expected: ' +
93
+ Object.keys(CMD_MAP).sort().join(', ') + ')')
94
+ }
95
+
96
+ return await cmd(args, actx)
97
+ }
98
+
99
+
100
+ // Where a package ROOT is, given a ref.
101
+ //
102
+ // The same probe chain `resolveSource` uses for an item, one level up: an
103
+ // installed npm package first, then a path relative to the project, then an
104
+ // absolute path. A package root is the folder HOLDING `.sdk`, which is what
105
+ // makes `<root>/<item>` the item ref — so the two resolvers agree by
106
+ // construction about what a package is.
107
+ type PackageSource = {
108
+ ref: string
109
+ root: string
110
+ sdk: string
111
+ manifest: Manifest
112
+ }
113
+
114
+
115
+ function resolvePackage(ref: string, actx: ActionContext): PackageSource {
116
+ const { found, search } = probePackage(actx.fs(), actx.folder ?? '.', ref)
117
+
118
+ if (null == found) {
119
+ throw new SdkGenError(
120
+ 'Package not found: ' + ref + '\n looked for a `.sdk` folder in:\n ' +
121
+ search.join('\n '))
122
+ }
123
+
124
+ const { root, sdk, read } = found
125
+
126
+ // A `.sdk` folder with no manifest is a legal source for a DIRECT ref and
127
+ // is what every pre-manifest fixture is — but `package add` is the verb
128
+ // that acts on a manifest, so here its absence is the error, and it says
129
+ // which of the two commands the user wants.
130
+ if (null == read.manifest) {
131
+ throw new SdkGenError(
132
+ 'No package manifest: ' + read.file +
133
+ (null == read.err ? '' : '\n ' + read.err) +
134
+ '\n `package add` installs what a manifest declares. For a folder' +
135
+ ' without one, add its items directly:' +
136
+ '\n voxgig-sdkgen target add ' + ref + '/<name>')
137
+ }
138
+
139
+ return { ref, root, sdk, manifest: read.manifest }
140
+ }
141
+
142
+
143
+ // Everything wrong with the package, or nothing. Reported as ONE message
144
+ // rather than one-per-throw, because an author fixing a manifest wants the
145
+ // whole list, not the first line of it.
146
+ function refuse(src: PackageSource, found: Finding[], log: any) {
147
+ // The non-errors are still worth saying — an unclaimed extra is nearly
148
+ // always a forgotten manifest edit — but they do not stop the install.
149
+ for (const f of found) {
150
+ if ('error' !== f.level) {
151
+ log[f.level](f)
152
+ }
153
+ }
154
+
155
+ const errors = found.filter((f: Finding) => 'error' === f.level)
156
+
157
+ if (0 === errors.length) {
158
+ return
159
+ }
160
+
161
+ throw new SdkGenError(
162
+ src.ref + ': package manifest does not match the package (' +
163
+ Path.join(src.root, MANIFEST) + ')\n ' +
164
+ errors.map((f: Finding) => f.note).join('\n '))
165
+ }
166
+
167
+
168
+ // Is this generator new enough for the package?
169
+ //
170
+ // `undefined` from `satisfies` means the range is outside the subset it
171
+ // understands — see helpers/semver. That is reported and ALLOWED: refusing on
172
+ // a range nobody could parse would block a package that works, which is worse
173
+ // than the incompatibility being guarded against.
174
+ function checkEngine(src: PackageSource, actx: ActionContext) {
175
+ const range = src.manifest.engines?.sdkgen
176
+
177
+ if (null == range || '' === range) {
178
+ return
179
+ }
180
+
181
+ const ok = satisfies(SDKGEN_VERSION, range)
182
+
183
+ if (false === ok) {
184
+ throw new SdkGenError(
185
+ src.ref + ': needs @voxgig/sdkgen ' + range +
186
+ ', this is ' + SDKGEN_VERSION +
187
+ '\n upgrade @voxgig/sdkgen, or install an earlier version of ' + src.ref)
188
+ }
189
+
190
+ if (null == ok) {
191
+ actx.log.warn({
192
+ point: 'package-engine-unparsed', package: src.manifest.name,
193
+ range, version: SDKGEN_VERSION,
194
+ note: src.ref + ': cannot compare `engines.sdkgen` range ' +
195
+ JSON.stringify(range) + ' against ' + SDKGEN_VERSION +
196
+ '; proceeding — see helpers/semver for the supported subset'
197
+ })
198
+ }
199
+ }
200
+
201
+
202
+ // This generator's own version, read from the package.json beside `dist`.
203
+ // Not from the consumer's model: `engines.sdkgen` is about the GENERATOR
204
+ // doing the installing, and the model describes the SDK being generated.
205
+ const SDKGEN_VERSION: string = (() => {
206
+ try {
207
+ return require('../../package.json').version
208
+ }
209
+ catch (err: any) {
210
+ return '0.0.0'
211
+ }
212
+ })()
213
+
214
+
215
+ // The items to install, as `<kind>` -> names, after `--only` is applied.
216
+ //
217
+ // `--only target:iot-go,feature:circuitbreaker` — a subset of what the
218
+ // manifest provides, named the same way the manifest keys them. A name that
219
+ // the package does not provide is an ERROR listing what it does, rather than
220
+ // a silent no-op: a typo'd `--only` that installed nothing and reported
221
+ // success is the failure this whole verb exists to remove.
222
+ function selectItems(
223
+ src: PackageSource, only: string | undefined, log: any,
224
+ ): Record<string, string[]> {
225
+ const provides = src.manifest.provides ?? {}
226
+
227
+ // ABSENT means "everything"; EXPLICITLY EMPTY does not.
228
+ //
229
+ // `--only=` and `{ only: '' }` are what a script gets when it builds the
230
+ // flag from an empty variable, and treating that as absent installs the
231
+ // whole package — the opposite of what the operator asked for, at the one
232
+ // moment nobody is watching.
233
+ if (null == only) {
234
+ return provides
235
+ }
236
+
237
+ const wanted: Record<string, string[]> = Object.create(null)
238
+ const missing: string[] = []
239
+ const specs = only.split(',').map((s) => s.trim()).filter(Boolean)
240
+
241
+ if (0 === specs.length) {
242
+ throw new SdkGenError(
243
+ '--only was given but selects nothing: ' + JSON.stringify(only) +
244
+ '\n omit the flag to install everything the package provides')
245
+ }
246
+
247
+ for (const spec of specs) {
248
+ const colon = spec.indexOf(':')
249
+
250
+ if (colon < 0) {
251
+ throw new SdkGenError(
252
+ '--only expects <kind>:<name> entries, got: ' + spec +
253
+ '\n for example: --only target:iot-go,feature:circuitbreaker')
254
+ }
255
+
256
+ const kind = spec.slice(0, colon)
257
+ const name = spec.slice(colon + 1)
258
+
259
+ if (!(provides[kind] ?? []).includes(name)) {
260
+ missing.push(spec)
261
+ continue
262
+ }
263
+
264
+ (wanted[kind] = wanted[kind] ?? []).push(name)
265
+ }
266
+
267
+ if (0 < missing.length) {
268
+ throw new SdkGenError(
269
+ src.ref + ': does not provide ' + missing.join(', ') +
270
+ '\n it provides: ' + describeProvides(provides))
271
+ }
272
+
273
+ return wanted
274
+ }
275
+
276
+
277
+ function describeProvides(provides: Record<string, string[]>): string {
278
+ const parts: string[] = []
279
+
280
+ for (const kind of Object.keys(provides).sort()) {
281
+ for (const name of provides[kind]) {
282
+ parts.push(kind + ' `' + name + '`')
283
+ }
284
+ }
285
+
286
+ return 0 === parts.length ? '(nothing)' : parts.join(', ')
287
+ }
288
+
289
+
290
+ // `--alias iot-go=acme-go,other=thing` — the install-time renames, by ORIGIN
291
+ // name. Only kinds that permit aliasing may appear; a feature alias is
292
+ // refused here with the same explanation `feature add` gives, so the two
293
+ // entry points cannot disagree about what is allowed.
294
+ function parseAliases(
295
+ alias: string | undefined, wanted: Record<string, string[]>,
296
+ ): Record<string, string> {
297
+ // NULL-PROTOTYPE. A manifest may legally provide an item called
298
+ // `constructor` or `toString` — the name grammar admits them — and on a
299
+ // plain object `aliases['constructor']` is Object.prototype.constructor,
300
+ // which is truthy, so an unaliased item got `~function Object() { … }`
301
+ // appended and installed under that as a name.
302
+ const out: Record<string, string> = Object.create(null)
303
+
304
+ if (null == alias || '' === alias) {
305
+ return out
306
+ }
307
+
308
+ for (const spec of alias.split(',').map((s) => s.trim()).filter(Boolean)) {
309
+ const eq = spec.indexOf('=')
310
+
311
+ if (eq < 0) {
312
+ throw new SdkGenError(
313
+ '--alias expects <name>=<alias> entries, got: ' + spec +
314
+ '\n for example: --alias iot-go=acme-go')
315
+ }
316
+
317
+ const from = spec.slice(0, eq)
318
+ const to = spec.slice(eq + 1)
319
+
320
+ // Checked HERE as well as in the resolver, because the two catch
321
+ // different things. The resolver sees whatever survives ref parsing, so
322
+ // it catches `iotgo=..`; but `iotgo=../../elsewhere` is concatenated into
323
+ // `<root>/iotgo~../../elsewhere`, whose last segment is `elsewhere` and
324
+ // which therefore stops looking like an alias at all — it resolves as a
325
+ // ref to a different item and fails confusingly instead. Same grammar in
326
+ // both places, so they cannot disagree about what a name is.
327
+ if (!ITEM_NAME_RE.test(to)) {
328
+ throw new SdkGenError(
329
+ 'Invalid alias in --alias ' + JSON.stringify(spec) + ': ' +
330
+ JSON.stringify(to) + ' is not a name (matching ' +
331
+ ITEM_NAME_RE.source + ')' +
332
+ '\n an alias becomes the directory the item is installed into')
333
+ }
334
+
335
+ const kind = Object.keys(wanted)
336
+ .find((k: string) => (wanted[k] ?? []).includes(from))
337
+
338
+ if (null == kind) {
339
+ throw new SdkGenError(
340
+ '--alias names ' + JSON.stringify(from) +
341
+ ', which is not among the items being installed: ' +
342
+ describeProvides(wanted))
343
+ }
344
+
345
+ if (!kindDef(kind).alias) {
346
+ throw new SdkGenError(
347
+ capitalise(kind) + ' aliasing is not supported: ' + spec +
348
+ '\n A ' + kind + ' name is part of the generated config ' +
349
+ '(options.' + kind + '.<name>) and of the hook wiring in every ' +
350
+ 'target, so it cannot be renamed at install time.')
351
+ }
352
+
353
+ out[from] = to
354
+ }
355
+
356
+ return out
357
+ }
358
+
359
+
360
+ async function cmd_package_add(
361
+ args: string[], actx: ActionContext,
362
+ ): Promise<ActionResult> {
363
+ const refs = args.slice(2).flatMap(
364
+ (a: any) => 'string' === typeof a ? a.split(',') : a)
365
+ .filter((r: any) => null != r && '' !== r)
366
+
367
+ if (0 === refs.length) {
368
+ throw new SdkGenError('package add: no package given')
369
+ }
370
+
371
+ return package_add(refs, actx)
372
+ }
373
+
374
+
375
+ async function package_add(
376
+ refs: string[], actx: ActionContext,
377
+ ): Promise<ActionResult> {
378
+ const log = actx.log
379
+ const flags = actx.flags ?? {}
380
+
381
+ // `--only` and `--alias` name items, so they only make sense for ONE
382
+ // package. Silently applying them to each of several would install the same
383
+ // alias twice.
384
+ if (1 < refs.length && (null != flags.only || null != flags.alias)) {
385
+ throw new SdkGenError(
386
+ '--only and --alias apply to a single package; ' + refs.length +
387
+ ' were given: ' + refs.join(', '))
388
+ }
389
+
390
+ // PREFLIGHT EVERY PACKAGE BEFORE INSTALLING ANY OF THEM.
391
+ //
392
+ // The same argument that makes this verb validate a manifest in full before
393
+ // writing anything applies across refs: `package add good,bad` that
394
+ // installed `good` and then failed would leave exactly the half-completed
395
+ // command the guarantee is about. Resolution, the engine gate, manifest
396
+ // validation, `--only` selection and the name-collision check all happen
397
+ // here, for all of them, before the first file is written.
398
+ const plan = refs.map((ref: string) => plan_one(ref, flags, actx))
399
+
400
+ checkCollisions(plan, actx)
401
+
402
+ const results: any[] = []
403
+
404
+ for (const { src, wanted, items } of plan) {
405
+ log.info({
406
+ point: 'package-add-start', package: src.manifest.name, ref: src.ref,
407
+ version: src.manifest.version, root: src.root,
408
+ note: src.manifest.name +
409
+ (null == src.manifest.version ? '' : '@' + src.manifest.version) +
410
+ ' <- ' + src.root
411
+ })
412
+
413
+ for (const kind of orderedKinds(wanted)) {
414
+ const add = adderFor(kind)
415
+ const itemrefs = items[kind] ?? []
416
+
417
+ if (0 === itemrefs.length) {
418
+ continue
419
+ }
420
+
421
+ if (null == add) {
422
+ // Validation already rejected an unknown kind, so this is a kind the
423
+ // registry knows and nothing can install yet — a `docs` entry before
424
+ // its action exists. Say so rather than skipping in silence.
425
+ log.warn({
426
+ point: 'package-kind-unsupported', package: src.manifest.name, kind,
427
+ names: wanted[kind],
428
+ note: src.manifest.name + ': nothing can install `' + kind +
429
+ '` items yet; skipped ' + (wanted[kind] ?? []).join(', ')
430
+ })
431
+ continue
432
+ }
433
+
434
+ results.push(await add(itemrefs, actx))
435
+
436
+ registerInstalled(kind, itemrefs, actx)
437
+ }
438
+
439
+ log.info({
440
+ point: 'package-add-end', package: src.manifest.name, ref: src.ref,
441
+ note: src.manifest.name + ': added ' + describeProvides(wanted)
442
+ })
443
+ }
444
+
445
+ // The LAST jostraca result, for the CLI's change summary. Each per-kind add
446
+ // already reported its own changes as it ran.
447
+ return { jres: results[results.length - 1]?.jres }
448
+ }
449
+
450
+
451
+ type Planned = {
452
+ src: PackageSource
453
+ wanted: Record<string, string[]>
454
+ // Item refs, by kind, in the grammar the per-kind add already takes:
455
+ // `<package-root>/<name>` with `~alias` appended when renamed. Building a
456
+ // ref rather than calling an internal entry point is what keeps
457
+ // `package add` and a hand-typed `target add` on exactly the same path.
458
+ items: Record<string, string[]>
459
+ }
460
+
461
+
462
+ function plan_one(
463
+ ref: string, flags: Record<string, any>, actx: ActionContext,
464
+ ): Planned {
465
+ const src = resolvePackage(ref, actx)
466
+
467
+ checkEngine(src, actx)
468
+ refuse(src, validateManifest(actx.fs(), src.sdk, src.manifest, KINDS),
469
+ actx.log)
470
+
471
+ const wanted = selectItems(src, flags.only, actx.log)
472
+ const aliases = parseAliases(flags.alias, wanted)
473
+
474
+ const items: Record<string, string[]> = Object.create(null)
475
+
476
+ for (const kind of Object.keys(wanted)) {
477
+ items[kind] = (wanted[kind] ?? []).map((name: string) =>
478
+ Path.join(src.root, name) +
479
+ (Object.prototype.hasOwnProperty.call(aliases, name) ?
480
+ '~' + aliases[name] : ''))
481
+ }
482
+
483
+ return { src, wanted, items }
484
+ }
485
+
486
+
487
+ // Would any of this REPLACE something the project got from elsewhere?
488
+ //
489
+ // `add` is overwrite, deliberately — that is how a resync works. But
490
+ // overwriting one package's `go` with a different package's `go` is not a
491
+ // resync: it silently replaces a working target's model, components and
492
+ // templates. The project asked for a package, not for that.
493
+ //
494
+ // Checked across the WHOLE plan, so two packages in one command claiming the
495
+ // same name are caught too — the second would otherwise conflict with nothing,
496
+ // because the first is not installed yet either.
497
+ function checkCollisions(plan: Planned[], actx: ActionContext) {
498
+ const claimed = new Map<string, string>()
499
+ const clashes: string[] = []
500
+
501
+ for (const { src, items } of plan) {
502
+ for (const kind of Object.keys(items)) {
503
+ for (const ref of items[kind]) {
504
+ let source: Source
505
+ try {
506
+ source = resolveSource(ref, kind, actx)
507
+ }
508
+ catch (err: any) {
509
+ // Unresolvable here means the add will fail too, with a better
510
+ // message than this check could give. Let it.
511
+ continue
512
+ }
513
+
514
+ const key = kind + ':' + source.name
515
+
516
+ const earlier = claimed.get(key)
517
+ if (null != earlier && earlier !== src.manifest.name) {
518
+ clashes.push(kind + ' `' + source.name + '`: both ' + earlier +
519
+ ' and ' + src.manifest.name + ' provide it')
520
+ continue
521
+ }
522
+ claimed.set(key, src.manifest.name)
523
+
524
+ const conflict = nameConflict(kind, source, actx)
525
+
526
+ if (null != conflict) {
527
+ clashes.push(
528
+ kind + ' `' + source.name + '`: already installed from ' +
529
+ (conflict.package || conflict.base) +
530
+ ', and ' + src.manifest.name + ' provides it too')
531
+ }
532
+ }
533
+ }
534
+ }
535
+
536
+ if (0 === clashes.length) {
537
+ return
538
+ }
539
+
540
+ throw new SdkGenError(
541
+ 'Name collision, nothing installed:\n ' + clashes.join('\n ') +
542
+ '\n\n Either install the one you want by its own ref:' +
543
+ '\n voxgig-sdkgen target add <package>/<name>' +
544
+ '\n or install this package\'s under a different name:' +
545
+ '\n voxgig-sdkgen package add <package> --alias <name>=<alias>')
546
+ }
547
+
548
+
549
+ // `ADD_ORDER` first, then anything else the registry knows, so a kind added
550
+ // later installs without editing this list.
551
+ function orderedKinds(wanted: Record<string, string[]>): string[] {
552
+ const rest = Object.keys(wanted)
553
+ .filter((k: string) => !ADD_ORDER.includes(k)).sort()
554
+
555
+ return [...ADD_ORDER.filter((k: string) => null != wanted[k]), ...rest]
556
+ }
557
+
558
+
559
+ // Registered by `dispatch`, to keep this module out of a require cycle with
560
+ // `target.ts` (which imports `feature.ts`, which imports `kind.ts`).
561
+ const ADDERS: Record<string, (refs: string[], actx: ActionContext) => Promise<any>> =
562
+ Object.create(null)
563
+
564
+
565
+ function registerAdder(
566
+ kind: string, add: (refs: string[], actx: ActionContext) => Promise<any>,
567
+ ) {
568
+ ADDERS[kind] = add
569
+ }
570
+
571
+
572
+ // The adder for a kind, LOADING THE REGISTRATIONS IF NOBODY HAS YET.
573
+ //
574
+ // `dispatch` is what calls `registerAdder`, and until this, whether it had
575
+ // been loaded depended on the importer: `sdkgen.ts` pulls it in, so the CLI
576
+ // worked, but anything importing this module directly got an empty table —
577
+ // and `package add` / `package update` then skipped every item with a
578
+ // `package-kind-unsupported` warning and reported success. It silently did
579
+ // nothing.
580
+ //
581
+ // Found the hard way: a throwaway script written to reproduce a review
582
+ // finding imported this module alone, and the "update" it performed was a
583
+ // no-op that looked like a passing result. A capability that depends on some
584
+ // other module having been imported first is not a capability.
585
+ //
586
+ // Required lazily rather than at the top, because `dispatch` imports THIS
587
+ // module; by call time the cycle has resolved.
588
+ function adderFor(
589
+ kind: string,
590
+ ): ((refs: string[], actx: ActionContext) => Promise<any>) | undefined {
591
+ if (0 === Object.keys(ADDERS).length) {
592
+ require('./dispatch')
593
+ }
594
+
595
+ return ADDERS[kind]
596
+ }
597
+
598
+
599
+ // `package update <pkg>` — refresh everything a package supplied.
600
+ //
601
+ // See docs/design/sdkgen-packages.md §13.
602
+ //
603
+ // THE ORDER IS THE SAFETY PROPERTY, which is why this command owns the fetch
604
+ // instead of telling the operator to run `npm update` first:
605
+ //
606
+ // 1. CHECK the project's copies against the source AS CURRENTLY INSTALLED
607
+ // 2. FETCH the new version
608
+ // 3. RE-ADD each item
609
+ //
610
+ // Measured at step 1, a copy that differs from its source means the project
611
+ // changed it. Run the other way round — fetch first, then check — every item
612
+ // legitimately differs from the new source, the gate fires on all of them,
613
+ // and the operator learns to pass `--force` every time. That ordering bug
614
+ // would make the gate worse than useless, because the same signal (copy
615
+ // differs from source) carries both meanings and only sequence separates
616
+ // them.
617
+ //
618
+ // WHAT THE GATE CANNOT DO, and says so
619
+ //
620
+ // It cannot prove which meaning applies. If the operator already ran
621
+ // `npm update` in another shell, step 1 is measuring against the NEW source
622
+ // and a difference means "stale", not "forked" — and nothing recorded in the
623
+ // project distinguishes the two. So the refusal states both readings and
624
+ // gives a runnable out for each, rather than asserting a fork it cannot
625
+ // diagnose. (A per-file digest recorded at add time would make it exact;
626
+ // deferred, design §17.9.)
627
+ async function cmd_package_update(
628
+ args: string[], actx: ActionContext,
629
+ ): Promise<ActionResult> {
630
+ const names = args.slice(2).flatMap(
631
+ (a: any) => 'string' === typeof a ? a.split(',') : a)
632
+ .filter((r: any) => null != r && '' !== r)
633
+
634
+ if (0 === names.length) {
635
+ throw new SdkGenError(
636
+ 'package update: no package given' +
637
+ '\n `voxgig-sdkgen package list` shows what this project has installed')
638
+ }
639
+
640
+ return package_update(names, actx)
641
+ }
642
+
643
+
644
+ // One item this project got from the package being updated.
645
+ type Installed = {
646
+ kind: string
647
+ name: string
648
+ origname: string
649
+ base: string
650
+ aliased: boolean
651
+ }
652
+
653
+
654
+ // Everything the model says came from `pkgname`.
655
+ //
656
+ // By RECORDED PROVENANCE, not by asking the package what it provides: what
657
+ // this refreshes is what the project actually installed, which may be a
658
+ // subset (`--only`) or carry aliases the package never mentions. Asking the
659
+ // package would refresh things the project does not have and miss the ones it
660
+ // renamed.
661
+ function installedFrom(pkgname: string, actx: ActionContext): Installed[] {
662
+ const kit: any = (actx.model as any)?.main?.[KIT] ?? {}
663
+ const found: Installed[] = []
664
+
665
+ for (const kind of Object.keys(KINDS).sort()) {
666
+ const items = kit[kind] ?? {}
667
+
668
+ for (const name of Object.keys(items).sort()) {
669
+ const item = items[name]
670
+
671
+ if (null == item || 'object' !== typeof item ||
672
+ item.package !== pkgname) {
673
+ continue
674
+ }
675
+
676
+ const origname = item.origname || name
677
+
678
+ found.push({
679
+ kind, name, origname,
680
+ base: item.base || '',
681
+ aliased: kindDef(kind).alias && origname !== name,
682
+ })
683
+ }
684
+ }
685
+
686
+ return found
687
+ }
688
+
689
+
690
+ async function package_update(
691
+ names: string[], actx: ActionContext,
692
+ ): Promise<ActionResult> {
693
+ const log = actx.log
694
+ const flags = actx.flags ?? {}
695
+
696
+ const results: any[] = []
697
+
698
+ // EVERY package's items resolved and CHECKED before any of them is
699
+ // fetched. `package update A,B` that finished A and then refused B would
700
+ // exit as failed having already changed A's dependencies and `.sdk` — the
701
+ // partial command the gate exists to prevent, one level up from where it
702
+ // was already prevented.
703
+ const plan = names.map((pkgname: string) => {
704
+ const installed = installedFrom(pkgname, actx)
705
+
706
+ if (0 === installed.length) {
707
+ throw new SdkGenError(
708
+ 'Nothing installed from ' + pkgname +
709
+ '\n `voxgig-sdkgen package list` shows which packages this project ' +
710
+ 'has, and what each supplied')
711
+ }
712
+
713
+ return { pkgname, installed }
714
+ })
715
+
716
+ // STEP 1 — before anything moves, for all of them.
717
+ for (const { pkgname, installed } of plan) {
718
+ await preCheck(pkgname, installed, actx)
719
+ }
720
+
721
+ for (const { pkgname, installed } of plan) {
722
+ log.info({
723
+ point: 'package-update-start', package: pkgname,
724
+ items: installed.length,
725
+ note: pkgname + ': updating ' + installed.length + ' item(s)'
726
+ })
727
+
728
+ // STEP 2 — now the source may change.
729
+ await fetchPackage(pkgname, installed, actx)
730
+
731
+ // STEP 2b — the fetched version is a DIFFERENT package from the one
732
+ // step 1 measured, and nothing has validated it. `package add` refuses a
733
+ // package whose manifest lies or whose `engines.sdkgen` is beyond this
734
+ // generator; an update that skipped those checks would overwrite `.sdk`
735
+ // with components written for a generator this is not.
736
+ validateFetched(pkgname, installed, actx)
737
+
738
+ // STEP 3.
739
+ results.push(...await reAdd(pkgname, installed, actx))
740
+
741
+ log.info({
742
+ point: 'package-update-end', package: pkgname,
743
+ note: pkgname + ': updated'
744
+ })
745
+ }
746
+
747
+ return { jres: results[results.length - 1]?.jres }
748
+ }
749
+
750
+
751
+ // EVERYTHING THE RE-ADD WILL WRITE, not just the package's own items.
752
+ //
753
+ // The gate is only worth having if it covers what step 3 actually touches,
754
+ // and step 3 touches more than it is asked to: `target_add` re-runs
755
+ // `feature_add` for EVERY active feature in the model, whoever supplied it.
756
+ // So updating a target package rewrites the model file of a feature that came
757
+ // from somewhere else — and a scope of "this package's items" never looked at
758
+ // it, so a local edit there was overwritten without `--force`, by the command
759
+ // whose whole promise is that it asks first.
760
+ //
761
+ // The features are added to the scope, not the fan-out narrowed: what the
762
+ // re-add does is `target add`'s long-standing behaviour, and changing it here
763
+ // would make `package update` write something different from what a
764
+ // hand-typed add writes — the equivalence every other part of this verb is
765
+ // built on.
766
+ function blastRadius(
767
+ installed: Installed[], actx: ActionContext,
768
+ ): Set<string> {
769
+ const wanted = new Set(installed.map((i: Installed) => i.kind + ':' + i.name))
770
+
771
+ if (!installed.some((i: Installed) => 'target' === i.kind)) {
772
+ return wanted
773
+ }
774
+
775
+ const features: any = (actx.model as any)?.main?.[KIT]?.feature ?? {}
776
+
777
+ for (const name of Object.keys(features)) {
778
+ if (false !== features[name]?.active) {
779
+ wanted.add('feature:' + name)
780
+ }
781
+ }
782
+
783
+ return wanted
784
+ }
785
+
786
+
787
+ // STEP 1: is the project's copy of this package's items unmodified?
788
+ //
789
+ // Runs the SAME comparison `doctor` runs, scoped to these items — a gate that
790
+ // decides whether to overwrite a project's files must not have its own idea
791
+ // of what counts as a difference.
792
+ async function preCheck(
793
+ pkgname: string, installed: Installed[], actx: ActionContext,
794
+ ) {
795
+ const flags = actx.flags ?? {}
796
+
797
+ const wanted = blastRadius(installed, actx)
798
+
799
+ const res: any = await doctor(
800
+ actx, (kind: string, name: string) => wanted.has(kind + ':' + name))
801
+
802
+ const report = res.report
803
+
804
+ // `forked` and `edited` only. `missing` means the project is short of what
805
+ // add would write, which an update FIXES; `resyncPending` is provenance
806
+ // catching up, which an update also fixes; `aliasedDiff` is the project's
807
+ // own differentiation of an alias, which step 3 does not touch anyway.
808
+ const changed = [...report.forked, ...report.edited]
809
+
810
+ if (0 === changed.length) {
811
+ return
812
+ }
813
+
814
+ if (true === flags.force) {
815
+ actx.log.warn({
816
+ point: 'package-update-forced', package: pkgname, files: changed,
817
+ note: pkgname + ': --force, overwriting ' + changed.length +
818
+ ' locally-changed file(s): ' + changed.join(', ')
819
+ })
820
+ return
821
+ }
822
+
823
+ throw new SdkGenError(
824
+ pkgname + ': ' + changed.length + ' file(s) differ from the installed ' +
825
+ 'source, so updating would overwrite them:\n ' + changed.join('\n ') +
826
+ '\n\n This means one of two things, and nothing recorded in the project ' +
827
+ 'tells them apart:' +
828
+ '\n - they are LOCAL EDITS, and `--force` will discard them;' +
829
+ '\n - or ' + pkgname + ' was already updated out of band (an ' +
830
+ '`npm update` in another shell), in which case they are merely STALE ' +
831
+ 'and nothing is at risk.' +
832
+ '\n\n If you did not update it: copy anything you want to keep into ' +
833
+ '.sdk/model/, then re-run with --force.' +
834
+ '\n If you did: reinstall the version you had, re-run this command, ' +
835
+ 'and it will check against the right source.')
836
+ }
837
+
838
+
839
+ // STEP 2: fetch. Injectable, so tests do not shell out and a caller with its
840
+ // own dependency management can supply one.
841
+ //
842
+ // `--no-fetch` covers the operator who has already fetched deliberately and
843
+ // accepts that step 1 measured against the new source. It is not the default
844
+ // because then this command would only ever re-apply the source it already
845
+ // has, which is `package add`.
846
+ async function fetchPackage(
847
+ pkgname: string, installed: Installed[], actx: ActionContext,
848
+ ) {
849
+ const flags = actx.flags ?? {}
850
+
851
+ if (true === flags.nofetch) {
852
+ actx.log.info({
853
+ point: 'package-update-nofetch', package: pkgname,
854
+ note: pkgname + ': --no-fetch, using the source already installed'
855
+ })
856
+ return
857
+ }
858
+
859
+ // A DRY RUN MUST NOT FETCH. The adders honour `actx.opts.dryrun` and write
860
+ // nothing, but an unconditional `npm install --save-dev` rewrites
861
+ // package.json, the lockfile and node_modules — mutating dependency state
862
+ // in the one mode whose entire promise is that nothing changes. This
863
+ // workstream has already fixed one dry-run defect (pruneStaleTemplates
864
+ // deleting files during a dry run); the fetch is the same failure in a
865
+ // louder place, because it reaches outside the project.
866
+ if (true === actx.opts?.dryrun) {
867
+ actx.log.info({
868
+ point: 'package-update-dryrun-fetch', package: pkgname,
869
+ note: pkgname + ': ** DRY RUN ** not fetching; the check and the ' +
870
+ 're-add below run against the source already installed'
871
+ })
872
+ return
873
+ }
874
+
875
+ const fetch = actx.fetchPackage ?? npmFetch
876
+
877
+ // npm CAN ONLY UPDATE WHAT NPM INSTALLED. A package added by a local path
878
+ // or an absolute checkout records that base, and the re-add reads from it —
879
+ // so an `npm install` would write a fresh copy into `node_modules`, leave
880
+ // the recorded source untouched, and the command would then recopy the OLD
881
+ // content while reporting success and having changed the project's
882
+ // dependencies. Refused rather than half-done, naming the way to do it.
883
+ if (null == actx.fetchPackage) {
884
+ const local = installed.find((i: Installed) =>
885
+ '' !== i.base && !isNodeModules(i.base))
886
+
887
+ if (null != local) {
888
+ throw new SdkGenError(
889
+ pkgname + ': installed from ' + local.base + ', which npm does not ' +
890
+ 'manage, so fetching would update a different copy and change ' +
891
+ 'nothing here.' +
892
+ '\n Update that source yourself (git pull, rebuild, …) and re-run ' +
893
+ 'with --no-fetch.')
894
+ }
895
+ }
896
+
897
+ await fetch(pkgname, actx)
898
+ }
899
+
900
+
901
+ // Is this base inside a `node_modules` directory — i.e. is it npm's to
902
+ // update? Checked on the '/'-normalised recorded value, which is how `base`
903
+ // is written (see helpers/stdrep), plus the platform separator for an
904
+ // absolute base recorded on Windows.
905
+ function isNodeModules(base: string): boolean {
906
+ const norm = base.split(Path.sep).join('/')
907
+ return norm.startsWith('node_modules/') || norm.includes('/node_modules/')
908
+ }
909
+
910
+
911
+ // The default fetch: hand it to npm, in the project's own directory.
912
+ //
913
+ // SHELLING OUT IS DELIBERATE and is the one place this generator runs another
914
+ // tool. The alternative — telling the operator to fetch first — is what makes
915
+ // the pre-check unable to distinguish a fork from a stale copy, which is the
916
+ // entire point of the ordering above.
917
+ async function npmFetch(pkgname: string, actx: ActionContext) {
918
+ const { execFile } = require('node:child_process')
919
+ const { promisify } = require('node:util')
920
+
921
+ const run = promisify(execFile)
922
+ const cwd = actx.folder ?? '.'
923
+
924
+ actx.log.info({
925
+ point: 'package-update-fetch', package: pkgname, cwd,
926
+ note: pkgname + ': npm install ' + pkgname + '@latest'
927
+ })
928
+
929
+ try {
930
+ // `npm.cmd` ON WINDOWS. `execFile` does no PATHEXT resolution, so plain
931
+ // `npm` — which is a `.cmd` shim there — fails with ENOENT. Not caught by
932
+ // CI, because every test injects its own fetcher and this function never
933
+ // runs; found by reading it rather than by it breaking.
934
+ //
935
+ // Arguments as an ARRAY and no `shell: true`, so a package name is never
936
+ // interpreted by a shell.
937
+ //
938
+ // maxBuffer raised well past execFile's 1MB default: `npm install` output
939
+ // for a large tree exceeds it, and the resulting ENOBUFS would be caught
940
+ // below and reported as a failed fetch — after npm had in fact SUCCEEDED,
941
+ // leaving the project's dependencies updated and its `.sdk` not.
942
+ const out = await run(
943
+ 'win32' === process.platform ? 'npm.cmd' : 'npm',
944
+ ['install', '--save-dev', pkgname + '@latest'],
945
+ { cwd, maxBuffer: 64 * 1024 * 1024 })
946
+
947
+ actx.log.debug({
948
+ point: 'package-update-fetched', package: pkgname,
949
+ stdout: out.stdout, stderr: out.stderr
950
+ })
951
+ }
952
+ catch (err: any) {
953
+ throw new SdkGenError(
954
+ pkgname + ': fetch failed — ' + (err.message || String(err)) +
955
+ '\n nothing has been overwritten. Fetch it yourself and re-run with ' +
956
+ '--no-fetch, or fix the install and try again.' +
957
+ (null == err.stderr ? '' : '\n\n' + err.stderr))
958
+ }
959
+ }
960
+
961
+
962
+ // STEP 2b: the fetched package is a DIFFERENT package from the one checked.
963
+ //
964
+ // Resolved from a recorded base rather than by name, because that is where
965
+ // the re-add will read from — validating some other copy of the package would
966
+ // be validating the wrong thing.
967
+ //
968
+ // A source with no manifest is not an error here, unlike in `package add`:
969
+ // the project demonstrably installed from it once, and refusing to refresh it
970
+ // now would strand a project whose package predates the manifest. Nothing is
971
+ // validated in that case, which is the same amount as before this command
972
+ // existed.
973
+ function validateFetched(
974
+ pkgname: string, installed: Installed[], actx: ActionContext,
975
+ ) {
976
+ const base = installed.find((i: Installed) => '' !== i.base)?.base
977
+
978
+ if (null == base) {
979
+ return
980
+ }
981
+
982
+ const root = Path.join(
983
+ Path.isAbsolute(base) ? base : Path.join(actx.folder ?? '.', base), '..')
984
+
985
+ const read = readManifest(actx.fs(), Path.join(root, '.sdk'))
986
+
987
+ if (null == read.manifest) {
988
+ actx.log.info({
989
+ point: 'package-update-unmanifested', package: pkgname, file: read.file,
990
+ note: pkgname + ': the source declares no manifest, so the fetched ' +
991
+ 'version could not be validated'
992
+ })
993
+ return
994
+ }
995
+
996
+ const src: PackageSource = {
997
+ ref: pkgname, root, sdk: Path.join(root, '.sdk'), manifest: read.manifest,
998
+ }
999
+
1000
+ checkEngine(src, actx)
1001
+ refuse(src, validateManifest(actx.fs(), src.sdk, src.manifest, KINDS),
1002
+ actx.log)
1003
+ }
1004
+
1005
+
1006
+ // STEP 3: re-add each item from its recorded base.
1007
+ //
1008
+ // An ALIASED item's model file is left alone, and that is not special-cased
1009
+ // here: `kindModel` already creates it `exclude: true` for a kind whose
1010
+ // aliased definition is project-owned, so a re-add refreshes `src/cmp` and
1011
+ // `tm` from the new origin and leaves the file the project is MEANT to edit
1012
+ // untouched. Reported, so the author knows to port upstream model changes by
1013
+ // hand rather than discovering later that they were never applied.
1014
+ async function reAdd(
1015
+ pkgname: string, installed: Installed[], actx: ActionContext,
1016
+ ): Promise<any[]> {
1017
+ const log = actx.log
1018
+ const results: any[] = []
1019
+
1020
+ const skipped = installed.filter((i: Installed) => i.aliased)
1021
+
1022
+ if (0 < skipped.length) {
1023
+ log.info({
1024
+ point: 'package-update-alias-model-kept', package: pkgname,
1025
+ items: skipped.map((i: Installed) => i.kind + '/' + i.name),
1026
+ note: pkgname + ': keeping the model file of ' + skipped.length +
1027
+ ' aliased item(s) — that file is where an alias is differentiated, ' +
1028
+ 'so upstream model changes to ' +
1029
+ skipped.map((i: Installed) => i.origname).join(', ') +
1030
+ ' must be ported by hand: ' +
1031
+ skipped.map((i: Installed) =>
1032
+ 'model/' + i.kind + '/' + i.name + '.aontu').join(', ')
1033
+ })
1034
+ }
1035
+
1036
+ // Same order as `package add`, for the same reason: `feature add` fans a
1037
+ // feature's source across the targets in the model.
1038
+ const byKind: Record<string, string[]> = Object.create(null)
1039
+
1040
+ for (const item of installed) {
1041
+ // The ref that reinstalls it — exactly what `recordedRef` reconstructs
1042
+ // for doctor, so an update and a check agree about where an item is from.
1043
+ const ref = Path.join(item.base, '..', item.origname) +
1044
+ (item.origname === item.name ? '' : '~' + item.name)
1045
+
1046
+ ;(byKind[item.kind] = byKind[item.kind] ?? []).push(ref)
1047
+ }
1048
+
1049
+ for (const kind of orderedKinds(byKind)) {
1050
+ const add = adderFor(kind)
1051
+
1052
+ if (null == add) {
1053
+ log.warn({
1054
+ point: 'package-kind-unsupported', package: pkgname, kind,
1055
+ note: pkgname + ': nothing can install `' + kind + '` items yet'
1056
+ })
1057
+ continue
1058
+ }
1059
+
1060
+ results.push(await add(byKind[kind], actx))
1061
+ }
1062
+
1063
+ return results
1064
+ }
1065
+
1066
+
1067
+ // `package list` — what this project has installed, and where each item came
1068
+ // from. Read entirely from the MODEL's recorded provenance (§4), which is why
1069
+ // there is no lockfile to consult and nothing that can disagree with it.
1070
+ async function cmd_package_list(
1071
+ _args: string[], actx: ActionContext,
1072
+ ): Promise<ActionResult> {
1073
+ const log = actx.log
1074
+ const fs = actx.fs()
1075
+ const kit: any = (actx.model as any)?.main?.[KIT] ?? {}
1076
+
1077
+ // package name -> kind -> [{name, base, origname}]
1078
+ const groups: Record<string, any[]> = Object.create(null)
1079
+
1080
+ for (const kind of Object.keys(KINDS).sort()) {
1081
+ const items = kit[kind] ?? {}
1082
+
1083
+ for (const name of Object.keys(items).sort()) {
1084
+ const item = items[name]
1085
+
1086
+ if (null == item || 'object' !== typeof item) {
1087
+ continue
1088
+ }
1089
+
1090
+ // An item with no recorded base predates provenance; it is still
1091
+ // installed, and saying "(unrecorded)" is more use than omitting it.
1092
+ const pkg = ('' === item.package || null == item.package) ?
1093
+ UNRECORDED : item.package
1094
+
1095
+ ;(groups[pkg] = groups[pkg] ?? []).push({
1096
+ kind, name,
1097
+ origname: item.origname || name,
1098
+ base: item.base || '',
1099
+ })
1100
+ }
1101
+ }
1102
+
1103
+ const packages = Object.keys(groups).sort()
1104
+
1105
+ for (const pkg of packages) {
1106
+ // The version ON DISK, not one recorded at add time: what `package
1107
+ // update` would compare against is the source as it is now.
1108
+ const version = installedVersion(fs, actx.folder ?? '.', groups[pkg])
1109
+
1110
+ log.info({
1111
+ point: 'package-list-entry', package: pkg, version,
1112
+ items: groups[pkg],
1113
+ note: pkg + (null == version ? '' : '@' + version) + ': ' +
1114
+ groups[pkg].map((i: any) =>
1115
+ i.kind + ' `' + i.name + '`' +
1116
+ (i.origname === i.name ? '' : ' (' + i.origname + ')')).join(', ')
1117
+ })
1118
+ }
1119
+
1120
+ log.info({
1121
+ point: 'package-list-end', packages: packages.length,
1122
+ note: 0 === packages.length ?
1123
+ 'nothing installed' : packages.length + ' package(s)'
1124
+ })
1125
+
1126
+ return { jres: undefined as any, report: { packages, groups } } as any
1127
+ }
1128
+
1129
+
1130
+ const UNRECORDED = '(unrecorded)'
1131
+
1132
+
1133
+ // Read the version from the manifest the items say they came from. All items
1134
+ // of one package share a base in practice; the first that yields a manifest
1135
+ // answers.
1136
+ function installedVersion(
1137
+ fs: any, project: string, items: any[],
1138
+ ): string | undefined {
1139
+ for (const item of items) {
1140
+ if ('' === item.base) {
1141
+ continue
1142
+ }
1143
+
1144
+ const sdk = Path.isAbsolute(item.base) ?
1145
+ item.base : Path.join(project, item.base)
1146
+
1147
+ const read = readManifest(fs, sdk)
1148
+
1149
+ if (null != read.manifest?.version) {
1150
+ return read.manifest.version
1151
+ }
1152
+ }
1153
+
1154
+ return undefined
1155
+ }
1156
+
1157
+
1158
+ function capitalise(s: string): string {
1159
+ return s.charAt(0).toUpperCase() + s.slice(1)
1160
+ }
1161
+
1162
+
1163
+ export {
1164
+ action_package,
1165
+ package_add,
1166
+ package_update,
1167
+ installedFrom,
1168
+ resolvePackage,
1169
+ selectItems,
1170
+ parseAliases,
1171
+ registerAdder,
1172
+ SDKGEN_VERSION,
1173
+ }