@voxgig/sdkgen 4.30.2 → 4.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.
Files changed (74) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/cmp/ReadmeTop.js +6 -5
  3. package/dist/cmp/ReadmeTop.js.map +1 -1
  4. package/dist/helpers/definitionPlan.d.ts +27 -0
  5. package/dist/helpers/definitionPlan.js +219 -0
  6. package/dist/helpers/definitionPlan.js.map +1 -0
  7. package/dist/helpers/naming.d.ts +2 -1
  8. package/dist/helpers/naming.js +13 -4
  9. package/dist/helpers/naming.js.map +1 -1
  10. package/dist/sdkgen.d.ts +3 -2
  11. package/dist/sdkgen.js +6 -3
  12. package/dist/sdkgen.js.map +1 -1
  13. package/dist/tsconfig.tsbuildinfo +1 -1
  14. package/package.json +2 -2
  15. package/project/.sdk/src/cmp/c/PrepareAuth_c.ts +5 -3
  16. package/project/.sdk/src/cmp/clojure/PrepareAuth_clojure.ts +7 -5
  17. package/project/.sdk/src/cmp/cpp/PrepareAuth_cpp.ts +5 -3
  18. package/project/.sdk/src/cmp/csharp/PrepareAuth_csharp.ts +6 -4
  19. package/project/.sdk/src/cmp/elixir/PrepareAuth_elixir.ts +8 -6
  20. package/project/.sdk/src/cmp/go/PrepareAuth_go.ts +7 -9
  21. package/project/.sdk/src/cmp/java/PrepareAuth_java.ts +5 -3
  22. package/project/.sdk/src/cmp/js/PrepareAuth_js.ts +6 -4
  23. package/project/.sdk/src/cmp/js/ReadmeInstall_js.ts +22 -5
  24. package/project/.sdk/src/cmp/js/TestDefinition_js.ts +40 -0
  25. package/project/.sdk/src/cmp/js/Test_js.ts +2 -0
  26. package/project/.sdk/src/cmp/kotlin/PrepareAuth_kotlin.ts +5 -3
  27. package/project/.sdk/src/cmp/lua/PrepareAuth_lua.ts +7 -3
  28. package/project/.sdk/src/cmp/ocaml/PrepareAuth_ocaml.ts +7 -5
  29. package/project/.sdk/src/cmp/perl/PrepareAuth_perl.ts +6 -3
  30. package/project/.sdk/src/cmp/php/PrepareAuth_php.ts +5 -3
  31. package/project/.sdk/src/cmp/php/ReadmeInstall_php.ts +11 -5
  32. package/project/.sdk/src/cmp/py/PrepareAuth_py.ts +5 -3
  33. package/project/.sdk/src/cmp/rb/PrepareAuth_rb.ts +5 -3
  34. package/project/.sdk/src/cmp/rb/ReadmeInstall_rb.ts +14 -4
  35. package/project/.sdk/src/cmp/rust/PrepareAuth_rust.ts +5 -3
  36. package/project/.sdk/src/cmp/scala/PrepareAuth_scala.ts +5 -3
  37. package/project/.sdk/src/cmp/swift/PrepareAuth_swift.ts +5 -3
  38. package/project/.sdk/src/cmp/ts/PrepareAuth_ts.ts +6 -4
  39. package/project/.sdk/src/cmp/ts/ReadmeInstall_ts.ts +10 -4
  40. package/project/.sdk/src/cmp/ts/TestDefinition_ts.ts +40 -0
  41. package/project/.sdk/src/cmp/ts/Test_ts.ts +2 -0
  42. package/project/.sdk/src/cmp/zig/PrepareAuth_zig.ts +5 -3
  43. package/project/.sdk/tm/c/utility/prepare_query.c +34 -2
  44. package/project/.sdk/tm/clojure/src/sdk/core.clj +20 -2
  45. package/project/.sdk/tm/cpp/utility/pipeline.hpp +31 -2
  46. package/project/.sdk/tm/csharp/utility/PrepareQuery.cs +32 -4
  47. package/project/.sdk/tm/elixir/lib/projectname/utility.ex +35 -1
  48. package/project/.sdk/tm/go/utility/prepare_query.go +31 -1
  49. package/project/.sdk/tm/java/utility/PrepareQuery.java +29 -5
  50. package/project/.sdk/tm/js/src/utility/PrepareQueryUtility.js +16 -2
  51. package/project/.sdk/tm/js/test/definition-runner.js +161 -0
  52. package/project/.sdk/tm/js/test/pipeline.test.js +43 -3
  53. package/project/.sdk/tm/js/test/sdk-test-control.json +4 -1
  54. package/project/.sdk/tm/kotlin/utility/Prepare.kt +29 -5
  55. package/project/.sdk/tm/lua/utility/prepare_query.lua +31 -2
  56. package/project/.sdk/tm/ocaml/sdk_runtime.ml +22 -1
  57. package/project/.sdk/tm/perl/utility/prepare_query.pm +25 -2
  58. package/project/.sdk/tm/php/utility/PrepareQuery.php +28 -1
  59. package/project/.sdk/tm/py/pkg/utility/prepare_query.py +23 -2
  60. package/project/.sdk/tm/rb/utility/prepare_query.rb +24 -2
  61. package/project/.sdk/tm/rust/utility/prepare_query.rs +34 -2
  62. package/project/.sdk/tm/scala/utility/Prepare.scala +31 -4
  63. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/utility/Prepare.swift +23 -5
  64. package/project/.sdk/tm/ts/src/utility/PrepareQueryUtility.ts +16 -2
  65. package/project/.sdk/tm/ts/test/definition-runner.ts +179 -0
  66. package/project/.sdk/tm/ts/test/pipeline.test.ts +43 -3
  67. package/project/.sdk/tm/ts/test/sdk-test-control.json +4 -1
  68. package/project/.sdk/tm/ts/test/utility.ts +3 -2
  69. package/project/.sdk/tm/zig/core/utility.zig +29 -1
  70. package/project/sdkgen-package.json +1 -1
  71. package/src/cmp/ReadmeTop.ts +7 -6
  72. package/src/helpers/definitionPlan.ts +265 -0
  73. package/src/helpers/naming.ts +17 -4
  74. package/src/sdkgen.ts +4 -1
@@ -64,22 +64,40 @@ func prepareParamsUtil(_ ctx: Context) -> VMap {
64
64
  func prepareQueryUtil(_ ctx: Context) -> VMap {
65
65
  let reqmatch = ctx.reqmatch
66
66
 
67
- var paramnames: VList = VList()
68
- if let pl = gp(ctx.point, "params").asList { paramnames = pl }
67
+ var paramnames: [Value] = []
68
+ if let pl = gp(ctx.point, "params").asList { paramnames.append(contentsOf: pl.items) }
69
+ // A path parameter travels in the path. The generated config lists them as
70
+ // args.params, which prepareParams reads; params is the older list of names.
71
+ if let apl = gpath(ctx.point, "args", "params").asList {
72
+ for pd in apl.items {
73
+ paramnames.append(gp(pd, "name"))
74
+ }
75
+ }
76
+
77
+ // A query parameter travels under the name the definition gives it, its
78
+ // orig, which the model may have renamed for the caller.
79
+ var wire: [String: String] = [:]
80
+ if let aql = gpath(ctx.point, "args", "query").asList {
81
+ for qd in aql.items {
82
+ if let name = gp(qd, "name").asString, let orig = gp(qd, "orig").asString, !orig.isEmpty {
83
+ wire[name] = orig
84
+ }
85
+ }
86
+ }
69
87
 
70
88
  let query = VMap()
71
89
  for item in items(.map(reqmatch)) {
72
90
  let key = item[0].asString ?? ""
73
91
  let val = item[1]
74
92
  if !isNil(val) && "$action" != key && !containsStr(paramnames, key) {
75
- query.entries[key] = val
93
+ query.entries[wire[key] ?? key] = val
76
94
  }
77
95
  }
78
96
  return query
79
97
  }
80
98
 
81
- private func containsStr(_ list: VList, _ s: String) -> Bool {
82
- return list.items.contains { $0.asString == s }
99
+ private func containsStr(_ list: [Value], _ s: String) -> Bool {
100
+ return list.contains { $0.asString == s }
83
101
  }
84
102
 
85
103
  func prepareBodyUtil(_ ctx: Context) -> Value {
@@ -14,10 +14,24 @@ function prepareQuery(ctx: Context) {
14
14
  params = params || []
15
15
  reqmatch = reqmatch || {}
16
16
 
17
+ // A path parameter travels in the path. The generated config lists them as
18
+ // args.params, which prepareParams reads; params is the older list of names.
19
+ const inpath: string[] = params.concat(
20
+ (point.args?.params || []).map((p: any) => p?.name))
21
+
22
+ // A query parameter travels under the name the definition gives it, its
23
+ // orig, which the model may have renamed for the caller.
24
+ const wire: Record<string, string> = Object.create(null)
25
+ for (const q of (point.args?.query || [])) {
26
+ if ('string' === typeof q?.name && 'string' === typeof q?.orig && '' !== q.orig) {
27
+ wire[q.name] = q.orig
28
+ }
29
+ }
30
+
17
31
  const out: any = {}
18
32
  for (let [key, val] of items(reqmatch)) {
19
- if (null != val && '$action' !== key && !params.includes(key)) {
20
- out[key] = val
33
+ if (null != val && '$action' !== key && !inpath.includes(key)) {
34
+ out[wire[key] ?? key] = val
21
35
  }
22
36
  }
23
37
 
@@ -0,0 +1,179 @@
1
+ // Checks one operation against the API definition rather than the model the
2
+ // SDK was generated from. A mock transport answers with the definition's own
3
+ // response example, so nothing here depends on how the model reads it.
4
+
5
+ import assert from 'node:assert/strict'
6
+
7
+
8
+ type Credential = { in: string, name: string, scheme?: string }
9
+
10
+ type DefinitionPoint = {
11
+ entity: string
12
+ accessor: string
13
+ op: string
14
+ method: string
15
+ path: string
16
+ action?: string
17
+ args: { name: string, wire: string, value: any }[]
18
+ select: Record<string, any>
19
+ query: string[]
20
+ auth: Credential[][] | null
21
+ status: number
22
+ sample: any
23
+ idField: string
24
+ }
25
+
26
+
27
+ const KEY = 'definition-test-key'
28
+ const BASE = 'http://definition.test'
29
+
30
+
31
+ async function runDefinitionPoint(SDK: any, point: DefinitionPoint): Promise<void> {
32
+ const sent: { url: URL, init: any }[] = []
33
+ const body = null == point.sample ? null : JSON.stringify(point.sample)
34
+
35
+ const client = new SDK({
36
+ apikey: KEY,
37
+ base: BASE,
38
+ feature: { test: { active: false } },
39
+ system: {
40
+ fetch: async (url: any, init: any) => {
41
+ sent.push({ url: new URL(String(url)), init: init || {} })
42
+ return new Response(204 === point.status ? null : body, {
43
+ status: point.status,
44
+ headers: { 'content-type': 'application/json' },
45
+ })
46
+ },
47
+ },
48
+ })
49
+
50
+ const input: any = { ...point.select }
51
+ for (const arg of point.args) input[arg.name] = arg.value
52
+ if (null != point.action) input.$action = point.action
53
+
54
+ let result: any
55
+ let error: any
56
+ try {
57
+ result = await client[point.accessor]()[point.op](input)
58
+ }
59
+ catch (err) {
60
+ error = err
61
+ }
62
+
63
+ assert.equal(sent.length, 1, 'expected one request, sent ' + sent.length +
64
+ (error ? ': ' + (error as any).message : ''))
65
+ const { url, init } = sent[0]
66
+
67
+ assert.equal(String(init.method || 'GET').toUpperCase(), point.method, 'method')
68
+
69
+ const route = point.path.replace(/\{([^}]+)\}/g, (_: string, name: string) => {
70
+ const arg = point.args.find((a) => a.wire === name)
71
+ return null == arg ? '{' + name + '}' : encodeURIComponent(String(arg.value))
72
+ })
73
+ assert.equal(url.pathname, route, 'route')
74
+
75
+ // Only what the definition declares, so never a path parameter again.
76
+ const credentialQuery = (point.auth || []).flat()
77
+ .filter((c) => 'query' === c.in).map((c) => c.name)
78
+ for (const key of url.searchParams.keys()) {
79
+ assert(point.query.includes(key) || credentialQuery.includes(key),
80
+ 'query parameter not in the definition: ' + key)
81
+ }
82
+
83
+ // From the apikey alone, placed as the definition's security scheme says.
84
+ if (null != point.auth && 0 < point.auth.length) {
85
+ const headers = new Headers(init.headers)
86
+ assert(point.auth.some((set) => set.every((c) => placed(c, headers, url))),
87
+ 'credential not sent as the definition declares it: ' + JSON.stringify(point.auth))
88
+ }
89
+
90
+ if (null != error) {
91
+ throw error
92
+ }
93
+
94
+ if (null == point.sample) {
95
+ return
96
+ }
97
+
98
+ if ('list' === point.op) {
99
+ const records = recordsOf(point.sample)
100
+ if (null != records) {
101
+ assert(Array.isArray(result), 'list did not return a list')
102
+ assert.equal(result.length, records.length,
103
+ 'list read ' + result.length + ' records where the definition example holds ' +
104
+ records.length)
105
+ }
106
+ }
107
+ else if ('load' === point.op || 'create' === point.op || 'update' === point.op) {
108
+ const record = recordOf(point.sample, point.idField)
109
+ if (null != record) {
110
+ assert.equal(result?.data?.()?.[point.idField], record[point.idField],
111
+ 'the entity does not hold the record the definition example returns')
112
+ }
113
+ }
114
+ }
115
+
116
+
117
+ function placed(cred: Credential, headers: Headers, url: URL): boolean {
118
+ if ('query' === cred.in) {
119
+ return KEY === url.searchParams.get(cred.name)
120
+ }
121
+
122
+ if ('cookie' === cred.in) {
123
+ return String(headers.get('cookie') || '').split(';')
124
+ .some((part) => cred.name + '=' + KEY === part.trim())
125
+ }
126
+
127
+ const value = headers.get(cred.name)
128
+ if (null == value) {
129
+ return false
130
+ }
131
+
132
+ const [kind, token] = value.split(' ')
133
+
134
+ // The client carries no secret, so the password must be empty.
135
+ if ('basic' === cred.scheme) {
136
+ return 'basic' === String(kind).toLowerCase() &&
137
+ Buffer.from(String(token), 'base64').toString('utf8') === KEY + ':'
138
+ }
139
+
140
+ if ('bearer' === cred.scheme) {
141
+ return 'bearer' === String(kind).toLowerCase() && KEY === token
142
+ }
143
+
144
+ return value.includes(KEY)
145
+ }
146
+
147
+
148
+ // The records a list response holds: the body itself, or its one property
149
+ // that is a non-empty list of objects. Anything else proves nothing.
150
+ function recordsOf(sample: any): any[] | null {
151
+ if (Array.isArray(sample)) {
152
+ return sample
153
+ }
154
+ if (null == sample || 'object' !== typeof sample) {
155
+ return null
156
+ }
157
+ const lists = Object.values(sample).filter((v: any) => Array.isArray(v) && 0 < v.length &&
158
+ v.every((x: any) => null != x && 'object' === typeof x && !Array.isArray(x)))
159
+ return 1 === lists.length ? lists[0] as any[] : null
160
+ }
161
+
162
+
163
+ // The record a single-item response returns: the body, or its one property
164
+ // holding an object with the identity field.
165
+ function recordOf(sample: any, idField: string): any {
166
+ if (null == sample || 'object' !== typeof sample || Array.isArray(sample)) {
167
+ return null
168
+ }
169
+ if (null != sample[idField]) {
170
+ return sample
171
+ }
172
+ const inner = Object.values(sample).filter((v: any) => null != v && 'object' === typeof v &&
173
+ !Array.isArray(v) && null != v[idField])
174
+ return 1 === inner.length ? inner[0] : null
175
+ }
176
+
177
+
178
+ export { runDefinitionPoint, recordsOf, recordOf }
179
+ export type { DefinitionPoint }
@@ -333,9 +333,9 @@ describe('pipeline:prepareAuth', () => {
333
333
  }
334
334
 
335
335
  // `basic: false` is explicit: an HTTP Basic API's generated config carries
336
- // `auth.basic: true`, and a client that merges it in takes a branch needing
337
- // a secret as well. With none supplied that branch writes nothing, which
338
- // the probe below would then read as a public API.
336
+ // `auth.basic: true`, and a client that merges it in takes the Basic
337
+ // branch, which base64-joins the key with a password rather than placing
338
+ // the key as it is.
339
339
  function auth(prefix: string): any {
340
340
  return { prefix, basic: false }
341
341
  }
@@ -423,6 +423,46 @@ describe('pipeline:prepareAuth', () => {
423
423
  test('a missing apikey option drops the credential', () => {
424
424
  strictEqual(placed({ auth: auth('Bearer') }, 'stale'), undefined)
425
425
  })
426
+
427
+ // HTTP Basic joins the key with a password, which may be empty (RFC 7617):
428
+ // Lob documents its key as the user with a blank password. An SDK for an
429
+ // API that is not HTTP Basic has no such branch, and the first placement
430
+ // below tells the two apart.
431
+ test('HTTP Basic sends the key with a blank password', () => {
432
+ if (null == CRED || 'headers' !== CRED.where || '' !== CRED.pair) return
433
+ const basic = { prefix: 'Basic', basic: true }
434
+ const b64 = (s: string) => Buffer.from(s).toString('base64')
435
+ if (placed({ apikey: 'K', secret: 'S', auth: basic }) !== 'Basic ' + b64('K:S')) return
436
+ strictEqual(placed({ apikey: 'K', auth: basic }), 'Basic ' + b64('K:'))
437
+ strictEqual(placed({ apikey: 'K', secret: '', auth: basic }), 'Basic ' + b64('K:'))
438
+ strictEqual(placed({ apikey: '', secret: 'S', auth: basic }, 'stale'), undefined)
439
+ })
440
+ })
441
+
442
+
443
+ describe('pipeline:prepareQuery', () => {
444
+
445
+ // The generated config lists path parameters as args.params. A match field
446
+ // that fills the path must not be sent again as a query parameter, which a
447
+ // strict server rejects.
448
+ test('a path parameter stays out of the query', () => {
449
+ const point = { args: { params: [{ name: 'id', kind: 'param' }] } }
450
+ const ctx = base({ point, reqmatch: { id: 'i1', q: 'x' } })
451
+ deepStrictEqual((stdutil as any).prepareQuery(ctx), { q: 'x' })
452
+ })
453
+
454
+ test('an action is never a query parameter', () => {
455
+ const ctx = base({ point: { args: { params: [] } }, reqmatch: { $action: 'go', q: 'x' } })
456
+ deepStrictEqual((stdutil as any).prepareQuery(ctx), { q: 'x' })
457
+ })
458
+
459
+ // A query argument's orig is the name the API definition gives it, which
460
+ // the model may have renamed for the caller.
461
+ test('a query argument goes out under its orig', () => {
462
+ const point = { args: { query: [{ name: 'item_id', orig: 'itemIds', kind: 'query' }] } }
463
+ const ctx = base({ point, reqmatch: { item_id: ['i1'], q: 'x' } })
464
+ deepStrictEqual((stdutil as any).prepareQuery(ctx), { itemIds: ['i1'], q: 'x' })
465
+ })
426
466
  })
427
467
 
428
468
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "_doc": "Per-SDK test control. Lists tests/operations to skip in unit and live modes; tunes per-test pacing. Edit by hand. Loaded by ts/test/utility.ts.",
3
+ "_doc": "Per-SDK test control. Lists tests/operations to skip in unit, live and definition modes; tunes per-test pacing. Edit by hand. Loaded by ts/test/utility.ts.",
4
4
  "test": {
5
5
  "skip": {
6
6
  "live": {
@@ -10,6 +10,9 @@
10
10
  "unit": {
11
11
  "direct": [],
12
12
  "entityOp": []
13
+ },
14
+ "definition": {
15
+ "entityOp": []
13
16
  }
14
17
  },
15
18
  "live": {
@@ -77,6 +77,7 @@ type TestControl = {
77
77
  skip?: {
78
78
  live?: { direct?: any[], entityOp?: any[] }
79
79
  unit?: { direct?: any[], entityOp?: any[] }
80
+ definition?: { direct?: any[], entityOp?: any[] }
80
81
  }
81
82
  live?: { delayMs?: number }
82
83
  client?: { options?: Record<string, any> }
@@ -105,11 +106,11 @@ function loadTestControl(): TestControl {
105
106
 
106
107
  // Returns the skip decision for a given test name from sdk-test-control.json.
107
108
  // `kind` is 'direct' (matches by `test` field) or 'entityOp' (matches by
108
- // `entity` + `op`). `mode` is 'live' or 'unit'.
109
+ // `entity` + `op`). `mode` is 'live', 'unit' or 'definition'.
109
110
  function isControlSkipped(
110
111
  kind: 'direct' | 'entityOp',
111
112
  name: string,
112
- mode: 'live' | 'unit'
113
+ mode: 'live' | 'unit' | 'definition'
113
114
  ): { skip: boolean, reason?: string } {
114
115
  const ctrl = loadTestControl()
115
116
  const list = ctrl?.test?.skip?.[mode]?.[kind] ?? []
@@ -1104,6 +1104,12 @@ pub fn prepare_query_util(ctx: *Context) Value {
1104
1104
  .array => h.getp(point, "params"),
1105
1105
  else => h.olist(),
1106
1106
  };
1107
+ // A path parameter travels in the path. The generated config lists them
1108
+ // as args.params, which prepare_params reads; params is the older list.
1109
+ const aparams: Value = h.getpath(&.{ "args", "params" }, point);
1110
+ // A query parameter travels under the name the definition gives it, its
1111
+ // orig, which the model may have renamed for the caller.
1112
+ const aquery: Value = h.getpath(&.{ "args", "query" }, point);
1107
1113
 
1108
1114
  const out = h.omap();
1109
1115
  if (reqmatch == .object) {
@@ -1120,7 +1126,29 @@ pub fn prepare_query_util(ctx: *Context) Value {
1120
1126
  }
1121
1127
  }
1122
1128
  }
1123
- if (!h.is_noval(val) and !std.mem.eql(u8, key, "$action") and !contained) h.setp(out, key, val);
1129
+ if (!contained and aparams == .array) {
1130
+ for (aparams.array.data.items) |pd| {
1131
+ const name = h.getp(pd, "name");
1132
+ if (name == .string and std.mem.eql(u8, name.string, key)) {
1133
+ contained = true;
1134
+ break;
1135
+ }
1136
+ }
1137
+ }
1138
+ var wire: []const u8 = key;
1139
+ if (aquery == .array) {
1140
+ for (aquery.array.data.items) |qd| {
1141
+ const name = h.getp(qd, "name");
1142
+ const orig = h.getp(qd, "orig");
1143
+ if (name == .string and orig == .string and orig.string.len != 0 and
1144
+ std.mem.eql(u8, name.string, key))
1145
+ {
1146
+ wire = orig.string;
1147
+ break;
1148
+ }
1149
+ }
1150
+ }
1151
+ if (!h.is_noval(val) and !std.mem.eql(u8, key, "$action") and !contained) h.setp(out, wire, val);
1124
1152
  }
1125
1153
  }
1126
1154
  return out;
@@ -3,7 +3,7 @@
3
3
  "package": 1
4
4
  },
5
5
  "name": "@voxgig/sdkgen",
6
- "version": "4.30.2",
6
+ "version": "4.31.0",
7
7
  "provides": {
8
8
  "target": [
9
9
  "c",
@@ -8,6 +8,7 @@ import {
8
8
 
9
9
  import { requirePath } from '../utility'
10
10
  import { featureDocs } from './FeatureDocs'
11
+ import type { FeatureDoc } from './FeatureDocs'
11
12
 
12
13
  import {
13
14
  entityPrimaryOp, entityIdField, opRequestShape, entityPath, entityActions,
@@ -23,7 +24,6 @@ import {
23
24
  vendorCommand,
24
25
  apiName,
25
26
  nonAffiliation,
26
- repoInfo,
27
27
  docsSiteUrl,
28
28
  SECURITY_EMAIL,
29
29
  } from '../helpers/packageMeta'
@@ -192,7 +192,7 @@ Learn more about Voxgig SDKs at [voxgig.com/sdk](${VOXGIG_SDK}).
192
192
  // Named here, in one line, with the detail left to each target's README.
193
193
  const features = featureDocs(model)
194
194
  if (0 < features.length) {
195
- Content(`> **Features:** ${features.map((f: any) => '`' + f.n + '`').join(', ')} — opt-in,
195
+ Content(`> **Features:** ${features.map((f: FeatureDoc) => '`' + f.name + '`').join(', ')} — opt-in,
196
196
  > inactive until switched on, and configured per client. See the Features
197
197
  > section of any SDK README below for what each one does.
198
198
 
@@ -308,10 +308,9 @@ network, and no credentials:
308
308
  // 3. Packages — real published package name + install command per
309
309
  // ecosystem. A package that is NOT yet live on its registry (the fleet
310
310
  // default: 'pending') must NOT advertise a `npm install ...` that 404s —
311
- // its Install cell links to the git-tag releases page instead. The go
312
- // family resolves from the tag directly (`go get <mod>@latest`).
311
+ // its Install cell links to the target README's install section instead.
312
+ // The go family resolves from the default branch (`go get <mod>@latest`).
313
313
  if (pkgTargets.length > 0) {
314
- const { tagsUrl } = repoInfo(model)
315
314
  Content(`## Packages
316
315
 
317
316
  | Language | Package | Install |
@@ -327,7 +326,9 @@ network, and no credentials:
327
326
  } else if ('tag' === state) {
328
327
  cell = '`' + vendorCommand(model, tgt.name) + '`'
329
328
  } else {
330
- cell = `publish pending — [install from git tag](${tagsUrl})`
329
+ // The target README always gives a route that works without a
330
+ // tag, which a repository may not have.
331
+ cell = `publish pending — [install from source](${tgt.name}/README.md#install)`
331
332
  }
332
333
  Content(`| ${tgt.title} | \`${packageName(model, tgt.name)}\` | ${cell} |
333
334
  `)