@prestack/thread 0.1.21 → 0.1.22

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 (137) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/dist/core/attach.js +21 -0
  3. package/dist/core/descend.js +6 -3
  4. package/dist/core/generators/commit.js +55 -3
  5. package/dist/core/generators/dev.d.ts +1 -0
  6. package/dist/core/generators/dev.js +68 -0
  7. package/dist/core/tree.d.ts +1 -0
  8. package/dist/core/tree.js +7 -1
  9. package/dist/derivation/generators/build.d.ts +3 -1
  10. package/dist/derivation/generators/build.js +103 -22
  11. package/dist/derivation/generators/release.js +74 -26
  12. package/dist/derivation/release.js +3 -3
  13. package/dist/integrations/cli.js +69 -10
  14. package/dist/integrations/pglite.js +13 -3
  15. package/dist/integrations/target.d.ts +3 -0
  16. package/dist/integrity/attach.js +1 -5
  17. package/dist/integrity/slopnators/sl_stale_path.js +10 -0
  18. package/dist/operations/access.js +3 -2
  19. package/dist/operations/capabilities.d.ts +2 -0
  20. package/dist/operations/capabilities.js +2 -0
  21. package/dist/operations/generators/capability.js +234 -24
  22. package/dist/operations/generators/compile.js +150 -50
  23. package/dist/operations/generators/seed.d.ts +3 -0
  24. package/dist/operations/generators/seed.js +25 -3
  25. package/dist/operations/nodes.d.ts +2 -2
  26. package/package.json +1 -1
  27. package/dist/core/actor.ts +0 -21
  28. package/dist/core/attach.ts +0 -613
  29. package/dist/core/chain.ts +0 -145
  30. package/dist/core/condition.ts +0 -227
  31. package/dist/core/descend.ts +0 -1185
  32. package/dist/core/errors/catalogue.ts +0 -314
  33. package/dist/core/errors/classes.ts +0 -51
  34. package/dist/core/errors/error.ts +0 -152
  35. package/dist/core/errors/namespaces.ts +0 -19
  36. package/dist/core/finding.ts +0 -37
  37. package/dist/core/generators/commit.ts +0 -157
  38. package/dist/core/init.ts +0 -222
  39. package/dist/core/invoked.ts +0 -52
  40. package/dist/core/round.ts +0 -74
  41. package/dist/core/rows.ts +0 -506
  42. package/dist/core/scope.ts +0 -20
  43. package/dist/core/thread.ts +0 -29
  44. package/dist/core/tree.ts +0 -90
  45. package/dist/core/types.ts +0 -351
  46. package/dist/derivation/attach.ts +0 -57
  47. package/dist/derivation/generators/build.ts +0 -371
  48. package/dist/derivation/generators/check.ts +0 -118
  49. package/dist/derivation/generators/determinism.ts +0 -145
  50. package/dist/derivation/generators/release.ts +0 -236
  51. package/dist/derivation/release.ts +0 -43
  52. package/dist/integrations/attach.ts +0 -187
  53. package/dist/integrations/cli.ts +0 -606
  54. package/dist/integrations/declared.ts +0 -34
  55. package/dist/integrations/disk.ts +0 -466
  56. package/dist/integrations/generators/read.ts +0 -127
  57. package/dist/integrations/generators/vault.ts +0 -210
  58. package/dist/integrations/https.ts +0 -216
  59. package/dist/integrations/pglite.ts +0 -206
  60. package/dist/integrations/secret.ts +0 -54
  61. package/dist/integrations/target.ts +0 -672
  62. package/dist/integrations/vault.ts +0 -148
  63. package/dist/integrity/attach.ts +0 -150
  64. package/dist/integrity/check-threads.ts +0 -166
  65. package/dist/integrity/constraint.ts +0 -1246
  66. package/dist/integrity/generators/compiler.ts +0 -138
  67. package/dist/integrity/generators/gate.ts +0 -227
  68. package/dist/integrity/generators/issues.ts +0 -160
  69. package/dist/integrity/generators/publish.ts +0 -233
  70. package/dist/integrity/generators/trial.ts +0 -359
  71. package/dist/integrity/lint.ts +0 -214
  72. package/dist/integrity/lsp/bin.ts +0 -44
  73. package/dist/integrity/lsp/diagnostics.ts +0 -92
  74. package/dist/integrity/lsp/findings.ts +0 -63
  75. package/dist/integrity/lsp/lint.ts +0 -124
  76. package/dist/integrity/lsp/protocol.ts +0 -78
  77. package/dist/integrity/lsp/server.ts +0 -224
  78. package/dist/integrity/slopnator.ts +0 -324
  79. package/dist/integrity/slopnators/sl_broken_lineage.ts +0 -38
  80. package/dist/integrity/slopnators/sl_declared_elsewhere.ts +0 -60
  81. package/dist/integrity/slopnators/sl_derived_drift.ts +0 -101
  82. package/dist/integrity/slopnators/sl_door_in_a_node.ts +0 -250
  83. package/dist/integrity/slopnators/sl_hardcoded_style.ts +0 -55
  84. package/dist/integrity/slopnators/sl_inert_island.ts +0 -84
  85. package/dist/integrity/slopnators/sl_inert_slopnator.ts +0 -93
  86. package/dist/integrity/slopnators/sl_missing_row.ts +0 -140
  87. package/dist/integrity/slopnators/sl_no_barrel.ts +0 -48
  88. package/dist/integrity/slopnators/sl_no_child_process.ts +0 -100
  89. package/dist/integrity/slopnators/sl_no_comment.ts +0 -43
  90. package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +0 -276
  91. package/dist/integrity/slopnators/sl_no_import_cycle.ts +0 -66
  92. package/dist/integrity/slopnators/sl_no_such_command.ts +0 -81
  93. package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +0 -63
  94. package/dist/integrity/slopnators/sl_outside_an_operation.ts +0 -25
  95. package/dist/integrity/slopnators/sl_screen_holds_only_declared.ts +0 -70
  96. package/dist/integrity/slopnators/sl_secret_literal.ts +0 -68
  97. package/dist/integrity/slopnators/sl_stale_path.ts +0 -178
  98. package/dist/integrity/slopnators/sl_twice_meant.ts +0 -57
  99. package/dist/integrity/slopnators/sl_twice_parented.ts +0 -82
  100. package/dist/integrity/slopnators/sl_undeclared_crossing.ts +0 -481
  101. package/dist/integrity/slopnators/sl_undeclared_destination.ts +0 -113
  102. package/dist/integrity/slopnators/sl_undeclared_element.ts +0 -44
  103. package/dist/integrity/slopnators/sl_undeclared_list.ts +0 -358
  104. package/dist/integrity/slopnators/sl_undeclared_node.ts +0 -75
  105. package/dist/integrity/slopnators/sl_undeclared_operation.ts +0 -257
  106. package/dist/integrity/slopnators/sl_undeclared_variant.ts +0 -152
  107. package/dist/integrity/slopnators/sl_undeclared_webhook.ts +0 -108
  108. package/dist/integrity/slopnators/sl_underived_route.ts +0 -72
  109. package/dist/integrity/slopnators/sl_undescribed.ts +0 -65
  110. package/dist/integrity/slopnators/sl_ungoverned_island.ts +0 -60
  111. package/dist/integrity/slopnators/sl_unlisted_program.ts +0 -46
  112. package/dist/integrity/slopnators/sl_unnamed_island.ts +0 -41
  113. package/dist/integrity/slopnators/sl_unpaired_member.ts +0 -139
  114. package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +0 -71
  115. package/dist/integrity/slopnators/sl_unprefixed_member.ts +0 -110
  116. package/dist/integrity/slopnators/sl_unthreaded.ts +0 -421
  117. package/dist/integrity/slopnators/sl_untranslated_key.ts +0 -140
  118. package/dist/integrity/slopnators/sl_unvaulted_secret.ts +0 -132
  119. package/dist/integrity/slopnators/sl_unversioned.ts +0 -52
  120. package/dist/integrity/sweep.ts +0 -395
  121. package/dist/knowledge/attach.ts +0 -327
  122. package/dist/knowledge/generators/docsgen.ts +0 -186
  123. package/dist/knowledge/generators/glossary.ts +0 -73
  124. package/dist/knowledge/glossary.ts +0 -6
  125. package/dist/operations/access.ts +0 -131
  126. package/dist/operations/attach.ts +0 -408
  127. package/dist/operations/capabilities.ts +0 -88
  128. package/dist/operations/dispatch.ts +0 -294
  129. package/dist/operations/error-results.ts +0 -31
  130. package/dist/operations/generators/capability.ts +0 -1054
  131. package/dist/operations/generators/compile.ts +0 -701
  132. package/dist/operations/generators/permits.ts +0 -229
  133. package/dist/operations/generators/seed.ts +0 -359
  134. package/dist/operations/generators/trial.ts +0 -666
  135. package/dist/operations/nodes.ts +0 -53
  136. package/dist/operations/operations.ts +0 -91
  137. package/dist/operations/result.ts +0 -28
@@ -1,1054 +0,0 @@
1
- import ts from 'typescript'
2
-
3
- import { existsSync, readdirSync, readFileSync, rmSync, writeFileSync } from '../../integrations/disk.ts'
4
- import { dirname, resolve } from 'node:path'
5
- import { argv, stdout } from 'node:process'
6
- import { pathToFileURL } from 'node:url'
7
-
8
- import { ROOT, TREE, descend, forget } from '../../core/descend.ts'
9
- import { exit, invoked } from '../../core/invoked.ts'
10
- import { named as packageOf } from '../../core/scope.ts'
11
- import { PREFIXES } from '../../core/thread.ts'
12
-
13
- import { secrecyOf, type Secret } from '../../integrations/secret.ts'
14
- import { reachOf, servesBrowser, targetOf, type Call, type Target, type Webhook } from '../../integrations/target.ts'
15
-
16
- import {
17
- CAPABILITIES_AT,
18
- CLIENT_AT,
19
- MCP_AT,
20
- OPENAPI_AT,
21
- REACHING_AT,
22
- ACTOR_AT,
23
- DEFAULTS_AT,
24
- DISPATCHING_AT,
25
- CHECKED_AT,
26
- inboundAt,
27
- isSurface,
28
- ROUTE,
29
- routeAt,
30
- SURFACES,
31
- switched,
32
- UNSTATED,
33
- type Capability,
34
- type Surface,
35
- } from '../capabilities.ts'
36
- import { ANONYMOUS, ENVIRONMENT } from '../../core/actor.ts'
37
- import { DISPATCH_AT } from '../dispatch.ts'
38
- import { STREAMED, TOLD } from '../../integrations/https.ts'
39
- import { holdsRow, listBounds, marked, nestedBounds, rowsIn, unread, valuesIn, withField, withRow, withTopField, withoutRow } from '../../core/rows.ts'
40
- import { said } from '../attach.ts'
41
- import { MEMBERS_LIST } from '../operations.ts'
42
- import { beside, operations, quoted, runtime, types } from './seed.ts'
43
-
44
- interface Written {
45
- readonly at: string
46
- readonly text: string
47
- }
48
-
49
- const compare = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0)
50
-
51
- const listedIn = (held: ts.Expression | undefined): readonly string[] =>
52
- held !== undefined && ts.isArrayLiteralExpression(held)
53
- ? held.elements.filter((one) => ts.isStringLiteral(one)).map((one) => (one as ts.StringLiteral).text)
54
- : []
55
-
56
- const trueIn = (held: ts.Expression | undefined): boolean => held !== undefined && held.kind === ts.SyntaxKind.TrueKeyword
57
-
58
- function stable(value: unknown): unknown {
59
- if (Array.isArray(value)) return value.map(stable)
60
- if (typeof value === 'object' && value !== null) {
61
- const found: Record<string, unknown> = {}
62
- for (const key of Object.keys(value as Record<string, unknown>).sort(compare)) {
63
- found[key] = stable((value as Record<string, unknown>)[key])
64
- }
65
- return found
66
- }
67
- return value === undefined ? null : value
68
- }
69
-
70
- const printed = (value: unknown): string => `${JSON.stringify(stable(value), null, 2)}\n`
71
-
72
- function verbOf(id: string): 'GET' | 'POST' | undefined {
73
- if (id.startsWith(PREFIXES.view)) return 'GET'
74
- if (id.startsWith(PREFIXES.workflow) || id.startsWith(PREFIXES.agent)) return 'POST'
75
- return undefined
76
- }
77
-
78
- async function schemaOf(at: string): Promise<unknown | string> {
79
- const from = resolve(ROOT, at)
80
- if (!existsSync(from)) return `${at} is not there, so nothing states what this operation takes`
81
-
82
- const held = (await import(pathToFileURL(from).href)) as { readonly input?: unknown }
83
- const input = held.input
84
- if (input === undefined) return `${at} exports no \`input\`, so nothing states what this operation takes`
85
-
86
- const zod = (await import('zod')) as { readonly toJSONSchema?: (one: unknown) => unknown }
87
- if (zod.toJSONSchema === undefined) {
88
- return 'this app\'s zod states no `toJSONSchema`, so an input schema cannot be written from it'
89
- }
90
- try {
91
- return zod.toJSONSchema(input)
92
- } catch (thrown) {
93
- return `${at} states an input that cannot be written as a schema: ${thrown instanceof Error ? thrown.message : String(thrown)}`
94
- }
95
- }
96
-
97
- function sheet<T>(file: string, list: string): readonly T[] {
98
- const at = resolve(ROOT, `_threads/${file}`)
99
- if (!existsSync(at)) return []
100
- const text = readFileSync(at, 'utf8')
101
- if (unread(text).includes(list)) {
102
- throw new Error(
103
- `_threads/${file} works out \`${list}\` when the file runs instead of writing it down, and this command reads sheets ` +
104
- 'without running them. Write each row of it out in full',
105
- )
106
- }
107
- return (valuesIn(text) as Record<string, readonly T[] | undefined>)[list] ?? []
108
- }
109
-
110
- function browsing(requires: readonly string[], targets: readonly Target[], secrets: readonly Secret[]): boolean {
111
- for (const word of requires) {
112
- const target = targets.find((one) => one.id === word)
113
- if (target === undefined) continue
114
- if (servesBrowser(target, secrets) !== true) return false
115
- }
116
- return true
117
- }
118
-
119
- export interface Surfaced {
120
- readonly live: readonly Surface[]
121
- readonly stated: boolean
122
- readonly wrong: readonly string[]
123
- }
124
-
125
- export function defaulted(): Surfaced | undefined {
126
- const at = resolve(ROOT, DEFAULTS_AT)
127
- if (!existsSync(at)) return undefined
128
- const row = Object.values(valuesIn(readFileSync(at, 'utf8'))).find(
129
- (one): one is { readonly live?: unknown } =>
130
- typeof one === 'object' && one !== null && (one as { readonly at?: unknown }).at === DEFAULTS_AT,
131
- )
132
- if (row === undefined || !Array.isArray(row.live)) return { live: UNSTATED, stated: false, wrong: [] }
133
- const given = row.live.map(String)
134
- return { live: SURFACES.filter((each) => given.includes(each)), stated: true, wrong: given.filter((each) => !isSurface(each)) }
135
- }
136
-
137
- export async function derive({ schemas = true }: { readonly schemas?: boolean } = {}): Promise<{
138
- readonly found: readonly Capability[]
139
- readonly refusals: readonly string[]
140
- readonly live: readonly Surface[]
141
- }> {
142
- const pass = descend()
143
- const known = types(pass)
144
- const refusals: string[] = []
145
- const found: Capability[] = []
146
-
147
- const targets = sheet<Target>('targets.ts', 'TARGETS')
148
- const secrets = sheet<Secret>('secrets.ts', 'SECRETS')
149
-
150
- for (const one of targets) {
151
- if (reachOf(one.id) !== 'sdk' || (one.declares ?? []).length > 0) continue
152
- refusals.push(
153
- `\`${one.id}\` calls a package inside this app, and its row declares no file that does the calling. That file is the one ` +
154
- 'place the package is imported, and it exports `calls`, holding the function each call names as its path. Give the row ' +
155
- `one, and the file with it, with \`thread edit ${one.id} --declares <id> --at <path> --describes <sentence> --body <text>\``,
156
- )
157
- }
158
-
159
- const tree = defaulted()
160
- if (tree === undefined) {
161
- refusals.push(
162
- `${DEFAULTS_AT} is not here, so nothing says which surfaces an operation answers on, and working it out anyway would ` +
163
- `take away every route this app has. \`thread attach ${packageOf('operations')}\` writes it`,
164
- )
165
- }
166
- for (const one of tree?.wrong ?? []) {
167
- refusals.push(
168
- `${DEFAULTS_AT} says \`${one}\` is live, and there is no such surface. There are ${String(SURFACES.length)}: ` +
169
- `${SURFACES.join(', ')}. \`thread create capability edit defaults\` rewrites it`,
170
- )
171
- }
172
-
173
- for (const [id, one] of [...operations(pass, known)].sort((a, b) => compare(a[0], b[0]))) {
174
- const stated = pass.stated.get(id)
175
- const declared = {
176
- webhooks: listedIn(stated?.fields.get('webhooks')),
177
- signed: trueIn(stated?.fields.get('signed')),
178
- }
179
- const describes = stated?.describes
180
- if (describes === undefined) {
181
- refusals.push(`\`${id}\` states no \`describes\`, and a caller told nothing about an operation cannot choose it`)
182
- continue
183
- }
184
-
185
- const method = verbOf(id)
186
- if (method === undefined) {
187
- refusals.push(
188
- `\`${id}\` wears none of the prefixes an operation may wear (${Object.values(PREFIXES).join(', ')}), so there is no verb to give it`,
189
- )
190
- continue
191
- }
192
-
193
- const takes = one.takes[0]?.at
194
- if (takes === undefined) {
195
- refusals.push(`\`${id}\` names nothing it takes, so nothing states its input`)
196
- continue
197
- }
198
-
199
- const schema = schemas ? await schemaOf(takes) : undefined
200
- if (typeof schema === 'string') {
201
- refusals.push(`\`${id}\`: ${schema}`)
202
- continue
203
- }
204
-
205
- const off = listedIn(stated?.fields.get('off'))
206
- const stray = off.filter((each) => !isSurface(each))
207
- if (stray.length > 0) {
208
- refusals.push(
209
- `\`${id}\` turns off ${stray.map((each) => `\`${each}\``).join(', ')}, and there is no such surface. There are ` +
210
- `${String(SURFACES.length)}: ${SURFACES.join(', ')}. \`thread create capability edit ${id}\` rewrites it`,
211
- )
212
- continue
213
- }
214
-
215
- const live = (tree?.live ?? UNSTATED).filter((each) => !off.includes(each))
216
-
217
- found.push({
218
- operation: id,
219
- describes,
220
- requires: [...one.requires].sort(compare),
221
- nodes: one.nodes.map((each) => each.id ?? '').filter((each) => each !== ''),
222
- input: schema,
223
- live: [...live].sort(compare) as readonly Surface[],
224
- webhooks: [...declared.webhooks].sort(compare),
225
- signed: declared.signed,
226
- browsable: browsing(one.requires, targets, secrets),
227
- mcp: { name: id, description: describes, inputSchema: schema },
228
- cli: { invocation: `${id} --input '<json>'`, describes },
229
- api: { method, path: `/operations/${id}` },
230
- })
231
- }
232
-
233
- for (const one of sheet<Webhook>('targets.ts', 'WEBHOOKS')) {
234
- const runs = found.find((each) => each.operation === one.invokes)
235
- if (runs === undefined) {
236
- refusals.push(
237
- `\`${one.id}\` invokes \`${one.invokes}\`, and this app declares no operation by that name. A webhook is a way in ` +
238
- `to something already declared, so declare that operation or point this webhook at one that exists`,
239
- )
240
- continue
241
- }
242
- const route = (one.derives ?? []).some((each) => each.at === inboundAt(one.id) && each.by !== undefined && each.runs !== undefined)
243
- if (!route) {
244
- refusals.push(
245
- `\`${one.id}\` derives no route. A webhook is an address anybody may post to, so the file that answers it has to be a row ` +
246
- `this tree declares: state a \`derives\` on this webhook naming ${inboundAt(one.id)}, with \`by\` and \`runs\` set to ` +
247
- '`thread capabilities` and `thread capabilities --check`, so what is written there is checked against what writes it',
248
- )
249
- }
250
- if (!runs.webhooks.includes(one.id)) {
251
- refusals.push(
252
- `\`${one.id}\` invokes \`${one.invokes}\`, and that operation does not name it back. An operation is run from outside ` +
253
- `only by a webhook it named, so add ${one.id} to \`webhooks\` on that operation's row in ${TREE}operations.ts, or point this webhook elsewhere`,
254
- )
255
- }
256
- if (runs.signed && (one.arrives === undefined || (one.requires ?? []).length === 0)) {
257
- refusals.push(
258
- `\`${one.invokes}\` says it will only be run by a signed webhook, and \`${one.id}\` carries no signature to check. ` +
259
- `Give this webhook the header its signature arrives on and the secret it is signed with`,
260
- )
261
- }
262
- if (!runs.live.includes('webhook')) {
263
- refusals.push(
264
- `\`${one.id}\` invokes \`${one.invokes}\`, and that operation does not answer on the webhook surface. Both halves have to ` +
265
- `agree before anything outside can run it: webhook has to be live in ${DEFAULTS_AT}, which \`thread create capability edit defaults\` changes, and not in the \`off\` on its row, which \`thread create capability edit ${one.invokes}\` changes`,
266
- )
267
- }
268
- const holds = (one.requires ?? []).filter((each) => secrecyOf(each) !== undefined)
269
- if (one.arrives !== undefined && holds.length === 0) {
270
- refusals.push(
271
- `\`${one.id}\` says a signature arrives on \`${one.arrives}\` and requires no secret to check it against, so every ` +
272
- `request would be taken on trust. Name the secret it is signed with, or take \`arrives\` off the row`,
273
- )
274
- }
275
- if (one.arrives === undefined && holds.length > 0) {
276
- refusals.push(
277
- `\`${one.id}\` requires ${holds.join(', ')} and says no header a signature arrives on, so nothing would ever be checked ` +
278
- `against it. Say which header carries the signature, or take the secret off the row`,
279
- )
280
- }
281
- }
282
-
283
- return { found, refusals, live: tree?.live ?? UNSTATED }
284
- }
285
-
286
- function named(): { readonly title: string; readonly version: string } {
287
- try {
288
- const held = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as {
289
- readonly name?: unknown
290
- readonly version?: unknown
291
- }
292
- return {
293
- title: typeof held.name === 'string' ? held.name : 'this app',
294
- version: typeof held.version === 'string' ? held.version : '0.0.0',
295
- }
296
- } catch {
297
- return { title: 'this app', version: '0.0.0' }
298
- }
299
- }
300
-
301
- function openapi(found: readonly Capability[]): unknown {
302
- const paths: Record<string, unknown> = {}
303
- for (const one of found) {
304
- if (!one.live.includes('api')) continue
305
- const method = one.api.method.toLowerCase()
306
- const responses = {
307
- '200': {
308
- description: 'the operation ran',
309
- content: { 'application/json': { schema: { type: 'object', properties: { ok: { const: true }, value: {} }, required: ['ok'] } } },
310
- },
311
- '400': {
312
- description: 'dispatch refused it — the code says which gate',
313
- content: {
314
- 'application/json': {
315
- schema: {
316
- type: 'object',
317
- properties: {
318
- ok: { const: false },
319
- code: { enum: ['denied', 'invalid', 'rate_limited', 'feature_denied', 'quota_exceeded', 'error'] },
320
- error: { type: 'string' },
321
- because: { type: 'string' },
322
- },
323
- required: ['ok', 'code', 'error'],
324
- },
325
- },
326
- },
327
- },
328
- }
329
- paths[one.api.path] = {
330
- [method]: {
331
- operationId: one.operation,
332
- summary: one.describes,
333
- ...(one.api.method === 'GET'
334
- ? { parameters: [{ name: 'input', in: 'query', required: true, content: { 'application/json': { schema: one.input } } }] }
335
- : { requestBody: { required: true, content: { 'application/json': { schema: one.input } } } }),
336
- responses,
337
- },
338
- }
339
- }
340
- const who = named()
341
- return {
342
- openapi: '3.1.0',
343
- info: { title: who.title, version: who.version, description: 'Every operation this app declares that is live on its API surface.' },
344
- paths,
345
- }
346
- }
347
-
348
- const mcp = (found: readonly Capability[]): unknown => ({
349
- tools: found.filter((one) => one.live.includes('mcp')).map((one) => one.mcp),
350
- })
351
-
352
- const compiled = (operation: string): boolean => existsSync(resolve(ROOT, `operations/${operation}.ts`))
353
-
354
- function client(found: readonly Capability[]): string {
355
- const rows = found
356
- .filter(held)
357
- .map((one) =>
358
- [
359
- ' {',
360
- ` operation: ${JSON.stringify(one.operation)},`,
361
- ` describes: ${JSON.stringify(one.describes)},`,
362
- ` method: ${JSON.stringify(one.api.method)},`,
363
- ` path: ${JSON.stringify(one.api.path)},`,
364
- ' },',
365
- ].join('\n'),
366
- )
367
-
368
- const near = found.filter((one) => one.browsable && compiled(one.operation))
369
-
370
- const bound = near.flatMap((one) => [
371
- 'export async function ' + one.operation + 'Locally(input: unknown, actor: Actor): Promise<Result<unknown>> {',
372
- ` const held = await dispatch(${one.operation}, input, actor, DECLARED, opening, counting)`,
373
- ' return held',
374
- '}',
375
- '',
376
- ])
377
-
378
- return [
379
- ...(near.length === 0
380
- ? []
381
- : [
382
- `import type { Actor } from '${runtime(CLIENT_AT, '../../core/actor.ts')}'`,
383
- `import type { Result } from '${runtime(CLIENT_AT, '../result.ts')}'`,
384
- '',
385
- "import { dispatch } from './_dispatch.ts'",
386
- `import { counting, DECLARED, opening } from '${beside(CLIENT_AT, REACHING_AT)}'`,
387
- ...near.map((one) => `import { operation as ${one.operation} } from './${one.operation}.ts'`),
388
- '',
389
- ]),
390
- 'export interface Reachable {',
391
- ' readonly operation: string',
392
- ' readonly describes: string',
393
- " readonly method: 'GET' | 'POST'",
394
- ' readonly path: string',
395
- '}',
396
- '',
397
- `export const CLIENT: readonly Reachable[] = [${rows.length === 0 ? '' : `\n${rows.join('\n')}\n`}]`,
398
- '',
399
- 'export const reachable = (operation: string): Reachable | undefined =>',
400
- ' CLIENT.find((one) => one.operation === operation)',
401
- '',
402
- ...bound,
403
- '',
404
- ].join('\n')
405
- }
406
-
407
- function route(one: Capability): string {
408
- const at = routeAt(one.operation)
409
- return [
410
- `import { existsSync } from ${quoted(runtime(at, '../../integrations/disk.ts'))}`,
411
- "import { join } from 'node:path'",
412
- '',
413
- 'import { counting, DECLARED, opening } from ' + quoted(beside(at, REACHING_AT)),
414
- 'import { actor } from ' + quoted(beside(at, ACTOR_AT)),
415
- '',
416
- "import { dispatch } from '../../../operations/_dispatch.ts'",
417
- `import { operation } from '../../../operations/${one.operation}.ts'`,
418
- '',
419
- 'const STATUS: Record<string, number> = {',
420
- ' denied: 403,',
421
- ' invalid: 400,',
422
- ' rate_limited: 429,',
423
- ' feature_denied: 403,',
424
- ' quota_exceeded: 429,',
425
- ' error: 500,',
426
- '}',
427
- '',
428
- "const PASSED = existsSync(join(process.cwd(), '.thread', 'gate.json'))",
429
- '',
430
- `export async function ${one.api.method}(request: Request): Promise<Response> {`,
431
- ' if (!PASSED) {',
432
- ' return Response.json(',
433
- " { ok: false, code: 'error', error: 'failed', because: 'this tree has no passing gate, so nothing in it may answer a request. Run `thread gate` — a gate that has never run is not a gate that passed' },",
434
- ' { status: 500 },',
435
- ' )',
436
- ' }',
437
- ...(one.api.method === 'GET'
438
- ? [' const input = Object.fromEntries(new URL(request.url).searchParams)']
439
- : [
440
- ' const input = await request.json().catch(() => undefined)',
441
- " if ((request.headers.get('accept') ?? '').includes(" + quoted(STREAMED) + ')) {',
442
- ' const lines = new TransformStream<string, string>()',
443
- ' const writing = lines.writable.getWriter()',
444
- " const sent = (said: unknown): void => { writing.write(JSON.stringify(said) + '\\n').catch(() => undefined) }",
445
- ' void dispatch(operation, input, await actor(request), DECLARED, opening, counting, (said) => sent({ ' + TOLD + ': said })).then((held) => {',
446
- ' sent(held)',
447
- ' return writing.close()',
448
- ' }).catch(() => undefined)',
449
- ' return new Response(lines.readable.pipeThrough(new TextEncoderStream()), { headers: { ' + quoted('content-type') + ': ' + quoted(STREAMED) + ' } })',
450
- ' }',
451
- ]),
452
- ' const held = await dispatch(operation, input, await actor(request), DECLARED, opening, counting)',
453
- ' return Response.json(held, { status: held.ok ? 200 : (STATUS[held.code] ?? 500) })',
454
- '}',
455
- '',
456
- ].join('\n')
457
- }
458
-
459
- function inbound(one: Webhook): string {
460
- const at = inboundAt(one.id)
461
- const secret = (one.requires ?? [])[0]
462
- const signed = one.arrives !== undefined && secret !== undefined
463
- const refuse = (why: string): string =>
464
- " return Response.json({ ok: false, code: " + quoted("denied") + ", error: " + quoted("not allowed") +
465
- ", because: " + quoted(why) + " }, { status: 403 })"
466
- return [
467
- ...(signed ? ["import { createHmac, timingSafeEqual } from " + quoted("node:crypto")] : []),
468
- "import { existsSync } from " + quoted(runtime(at, "../../integrations/disk.ts")),
469
- "import { join } from " + quoted("node:path"),
470
- "",
471
- "import { counting, DECLARED, opening } from " + quoted(beside(at, REACHING_AT)),
472
- "",
473
- "import { dispatch } from " + quoted(beside(at, DISPATCH_AT)),
474
- "import { operation } from " + quoted(beside(at, "operations/" + one.invokes + ".ts")),
475
- "",
476
- "const ARRIVING = " + JSON.stringify(ANONYMOUS),
477
- "",
478
- "const PASSED = existsSync(join(process.cwd(), " + quoted(".thread") + ", " + quoted("gate.json") + "))",
479
- "",
480
- "export async function POST(request: Request): Promise<Response> {",
481
- " if (!PASSED) {",
482
- refuse("this tree has no passing gate, so nothing in it may answer a request. Run thread gate"),
483
- " }",
484
- " const raw = await request.text()",
485
- ...(signed
486
- ? [
487
- " const carried = request.headers.get(" + quoted(one.arrives as string) + ")",
488
- " if (carried === null) {",
489
- refuse("this webhook is signed and the request carried nothing on " + String(one.arrives)),
490
- " }",
491
- " const held = await opening.resolve([" + quoted(secret as string) + "])",
492
- " const mine = Buffer.from(createHmac(" + quoted("sha256") + ", held[" + quoted(secret as string) +
493
- "] ?? " + quoted("") + ").update(raw).digest(" + quoted("hex") + "))",
494
- " const said = Buffer.from(carried)",
495
- " if (said.length !== mine.length || !timingSafeEqual(said, mine)) {",
496
- refuse("the signature on this request is not what " + String(secret) + " signs a body with"),
497
- " }",
498
- ]
499
- : []),
500
- " const input: unknown = JSON.parse(raw)",
501
- " const answer = await dispatch(operation, input, ARRIVING, DECLARED, opening, counting)",
502
- " return Response.json(answer, { status: answer.ok ? 200 : 500 })",
503
- "}",
504
- "",
505
- ].join("\n")
506
- }
507
-
508
- function declaring(webhooks: readonly Webhook[]): readonly string[] {
509
- const sheet = resolve(ROOT, TREE + 'targets.ts')
510
- if (!existsSync(sheet)) return []
511
- let text = readFileSync(sheet, 'utf8')
512
- const wrote: string[] = []
513
- for (const one of webhooks) {
514
- const id = one.id + '_route'
515
- const where = listBounds(text, 'WEBHOOKS')
516
- if (where === undefined) break
517
- const inside = nestedBounds(text, where, one.id, 'derives')
518
- if (inside !== undefined && holdsRow(text, inside, id)) continue
519
- const row = [
520
- ' {',
521
- " id: " + quoted(id) + ",",
522
- " at: " + quoted(inboundAt(one.id)) + ",",
523
- " describes: " + quoted(
524
- 'The file that answers what ' + one.id + ' posts. It checks the signature before anything runs and then hands the body ' +
525
- 'to the same dispatch every other caller reaches, so an inbound event is refused or dispatched and never anything else.',
526
- ) + ",",
527
- ' version: 1,',
528
- " by: 'thread capabilities',",
529
- " runs: 'thread capabilities --check',",
530
- ' },',
531
- ].join('\n')
532
- const next = inside === undefined
533
- ? withField(text, where, one.id, 'derives', '[\n' + row + '\n ]')
534
- : withRow(text, inside, row)
535
- if (next === undefined || next === text) continue
536
- text = next
537
- wrote.push(id)
538
- }
539
- if (wrote.length > 0) writeFileSync(sheet, text, 'utf8')
540
- return wrote
541
- }
542
-
543
- const STORED = /export\s+const\s+store\b/
544
-
545
- const STORES_AT = 'src/stores'
546
-
547
- function kept(): readonly string[] {
548
- const at = resolve(ROOT, STORES_AT)
549
- if (!existsSync(at)) return []
550
- return readdirSync(at)
551
- .filter((one) => one.endsWith('.ts'))
552
- .filter((one) => {
553
- try {
554
- return STORED.test(readFileSync(resolve(at, one), 'utf8'))
555
- } catch {
556
- return false
557
- }
558
- })
559
- .sort()
560
- .map((one) => `${STORES_AT}/${one}`)
561
- }
562
-
563
- const ENTITIES_AT = `${TREE}entities.ts`
564
-
565
- const OPERATIONS_AT = `${TREE}operations.ts`
566
-
567
- const VAULT_AT = `src/vault.${'ts'}`
568
-
569
- const served = (one: Capability): boolean => one.live.includes('api') && compiled(one.operation)
570
-
571
- const held = (one: Capability): boolean => one.browsable && one.live.includes('api')
572
-
573
- function dispatching(found: readonly Capability[]): string {
574
-
575
- const reachable = found.filter(served)
576
- const checked = quoted(beside(DISPATCHING_AT, CHECKED_AT))
577
- return [
578
- 'import type {} from ' + checked,
579
- 'import type { Calling } from ' + quoted(runtime(DISPATCHING_AT, '../../integrations/target.ts')),
580
- '',
581
- 'declare module ' + checked + ' {',
582
- ...(reachable.length === 0
583
- ? [' interface Dispatching {}']
584
- : [' interface Dispatching {', ...reachable.map((one) => ` readonly ${one.operation}: true`), ' }']),
585
- '}',
586
- '',
587
- ...(reachable.length === 0
588
- ? []
589
- : [
590
- 'interface Reaches {',
591
- ' readonly operation: string',
592
- " readonly method: 'GET' | 'POST'",
593
- ' readonly path: string',
594
- '}',
595
- '',
596
- 'async function sent(one: Reaches, input?: unknown): Promise<unknown> {',
597
- ' const answered = await fetch(one.path, {',
598
- ' method: one.method,',
599
- " headers: { 'content-type': 'application/json' },",
600
- " ...(one.method === 'GET' ? {} : { body: JSON.stringify(input ?? {}) }),",
601
- ' })',
602
- ' return await answered.json()',
603
- '}',
604
- '',
605
- ]),
606
- `export const calls: Calling<${reachable.length === 0 ? 'never' : reachable.map((one) => quoted(one.operation)).join(' | ')}> = {${
607
- reachable.length === 0
608
- ? ''
609
- : `\n${reachable
610
- .map(
611
- (one) =>
612
- ` ${one.operation}: (given) => sent({ operation: ${JSON.stringify(one.operation)}, method: ${JSON.stringify(one.api?.method)}, path: ${JSON.stringify(one.api?.path)} }, given),`,
613
- )
614
- .join('\n')}\n`
615
- }}`,
616
- '',
617
- 'const CALLS: Readonly<Record<string, ((given?: unknown) => Promise<unknown>) | undefined>> = calls',
618
- '',
619
- 'export async function dispatchTo(operation: string, input?: Readonly<Record<string, unknown>>): Promise<unknown> {',
620
- ' const one = CALLS[operation]',
621
- ' if (one === undefined) {',
622
- ' throw new Error(',
623
- " `thread: nothing declares an operation called ${operation}, so there is nowhere to send this. ` +",
624
- " `This app reaches: ${Object.keys(CALLS).join(', ') || '(nothing)'}`,",
625
- ' )',
626
- ' }',
627
- ' return await one(input)',
628
- '}',
629
- '',
630
- ].join('\n')
631
- }
632
-
633
- export function reaching(
634
- targets: readonly Target[],
635
- calls: readonly Call[],
636
- stores: readonly string[],
637
- vaulted: boolean,
638
- carried: boolean,
639
- ): string {
640
- const taken = (one: Target): readonly string[] =>
641
- calls.filter((each) => each.of === one.id && one.takes.includes(each.id)).map((each) => each.id.replace(/^cl_/, ''))
642
-
643
- const rows = targets.map((one) => {
644
- const mine = taken(one).map((each) => quoted(each))
645
- return ' readonly ' + one.id + ': ' + (mine.length === 0 ? 'never' : mine.join(' | '))
646
- })
647
-
648
- const named = stores.map((one, at) => ({
649
- from: beside(REACHING_AT, one),
650
- as: 's' + String(at),
651
- of: (one.split('/').pop() ?? '').replace(/\.ts$/, ''),
652
- }))
653
-
654
- const adapted = targets
655
- .filter((one) => reachOf(one.id) === 'sdk')
656
- .flatMap((one, at) => {
657
- const file = (one.declares ?? [])[0]?.at
658
- return file === undefined ? [] : [{ from: beside(REACHING_AT, file), as: 'c' + String(at), of: one.id }]
659
- })
660
-
661
- const used = new Set<string>()
662
- const opened = targets
663
- .map((one) => {
664
- const reach = reachOf(one.id)
665
- if (reach === 'sdk') {
666
- const held = adapted.find((each) => each.of === one.id)
667
- if (held === undefined) return ''
668
- const each = calls
669
- .filter((call) => call.of === one.id && one.takes.includes(call.id))
670
- .map((call) => quoted(call.id.replace(/^cl_/, '')) + ': ' + held.as + '[' + quoted(call.path) + ']')
671
- if (each.length > 0) used.add(held.as)
672
- return ' ' + one.id + ': { ' + each.join(', ') + ' },'
673
- }
674
- if (reach === 'disk') {
675
- const through = one.requires.find((each) => reachOf(each) !== undefined)
676
- if (through !== undefined) return ''
677
- used.add('HERE')
678
- return ' ' + one.id + ': HERE,'
679
- }
680
- if (reach === 'cli') {
681
- const each = calls
682
- .filter((call) => call.of === one.id && one.takes.includes(call.id))
683
- .map((call) => quoted(call.id.replace(/^cl_/, '')) + ': ' + quoted(call.path))
684
- used.add('running')
685
- return ' ' + one.id + ': running(' + quoted(one.id.replace(/^cli_/, '')) + ', { ' + each.join(', ') + ' }, process.cwd()),'
686
- }
687
- if (reach === 'https') {
688
- used.add('sendingHttp')
689
- return ' ' + one.id + ': sendingHttp(),'
690
- }
691
- const held = named.find((each) => each.of === one.id)
692
- if (held === undefined) return ''
693
- used.add(held.as)
694
- return ' ' + one.id + ': ' + held.as + ','
695
- })
696
- .filter((each) => each !== '')
697
-
698
- return [
699
- ...(used.has('HERE') ? ['import { HERE } from ' + quoted(runtime(REACHING_AT, '../../integrations/disk.ts'))] : []),
700
- ...(used.has('running') ? ['import { running } from ' + quoted(runtime(REACHING_AT, '../../integrations/cli.ts'))] : []),
701
- ...(used.has('sendingHttp') ? ['import { sendingHttp } from ' + quoted(runtime(REACHING_AT, '../../integrations/https.ts'))] : []),
702
- 'import type { Counting } from ' +
703
- quoted(carried ? packageOf('operations') + '/operations/operations' : runtime(REACHING_AT, '../operations.ts')),
704
- ...named.filter((each) => used.has(each.as)).map((each) => 'import { store as ' + each.as + ' } from ' + quoted(each.from)),
705
- ...adapted.filter((each) => used.has(each.as)).map((each) => 'import { calls as ' + each.as + ' } from ' + quoted(each.from)),
706
- ...(vaulted ? ['import { vault } from ' + quoted(beside(REACHING_AT, VAULT_AT))] : []),
707
- '',
708
- 'declare module ' +
709
- quoted(carried ? packageOf('operations') + '/operations/operations' : runtime(REACHING_AT, '../operations.ts')) +
710
- ' {',
711
- ...(rows.length === 0 ? [' interface Reaching {}'] : [' interface Reaching {', ...rows, ' }']),
712
- '}',
713
- '',
714
- 'export const DECLARED: Readonly<Record<string, unknown>> = {',
715
- ...opened,
716
- '}',
717
- '',
718
- vaulted ? 'export const opening = { resolve: vault }' : 'export const opening = undefined',
719
- '',
720
- 'export const counting: Counting | undefined = undefined',
721
- '',
722
- ].join('\n')
723
- }
724
-
725
- const reading = (header: string): string => 'request.headers.get(' + quoted(header) + ')'
726
-
727
- const CARRIED = ENVIRONMENT.flatMap(([fact, header, fills]) => {
728
- const taken = header === null ? fills : fills === null ? reading(header) : reading(header) + ' ?? ' + fills
729
- return taken === null ? [] : [{ fact, taken, always: fills !== null }]
730
- })
731
-
732
- function caller(keeping: boolean, kept: string | undefined): string {
733
- const looking = keeping && kept !== undefined
734
- return [
735
- 'import type { Actor, Environment } from ' + quoted(runtime(ACTOR_AT, '../../core/actor.ts')),
736
- ...(looking ? ['import { ENTITIES } from ' + quoted(beside(ACTOR_AT, ENTITIES_AT))] : []),
737
- ...(looking ? ['import { DECLARED } from ' + quoted(beside(ACTOR_AT, REACHING_AT))] : []),
738
- ...(looking ? ['import type { Store } from ' + quoted(runtime(ACTOR_AT, '../../integrations/target.ts'))] : []),
739
- '',
740
- 'const ANONYMOUS: Actor = ' + JSON.stringify(ANONYMOUS),
741
- '',
742
- 'function environment(request: Request): Environment {',
743
- ...CARRIED.map((one) => ' const ' + one.fact + ' = ' + one.taken),
744
- ' return {',
745
- ...CARRIED.map((one) =>
746
- one.always ? ' ' + one.fact + ',' : ' ...(' + one.fact + ' === null ? {} : { ' + one.fact + ' }),',
747
- ),
748
- ' }',
749
- '}',
750
- '',
751
- ...(looking
752
- ? [
753
- 'const worn = (held: unknown): readonly string[] =>',
754
- ' Array.isArray(held) ? held.filter((one) => typeof one === ' + quoted('string') + ') : typeof held === ' + quoted('string') + ' ? [held] : []',
755
- '',
756
- 'function addressed(request: Request, from: string): string | null {',
757
- ' if (from.startsWith(' + quoted('cookie:') + ')) {',
758
- ' const jar = request.headers.get(' + quoted('cookie') + ') ?? ' + quoted(''),
759
- ' const want = from.slice(' + quoted('cookie:') + '.length).trim() + ' + quoted('='),
760
- ' const found = jar.split(' + quoted(';') + ').map((one) => one.trim()).find((one) => one.startsWith(want))',
761
- ' return found === undefined ? null : decodeURIComponent(found.slice(want.length))',
762
- ' }',
763
- ' const held = request.headers.get(from)',
764
- ' if (held === null) return null',
765
- ' const bare = held.startsWith(' + quoted('Bearer ') + ') ? held.slice(' + quoted('Bearer ') + '.length) : held',
766
- ' return bare.trim() === ' + quoted('') + ' ? null : bare.trim()',
767
- '}',
768
- '',
769
- ]
770
- : []),
771
- 'export async function actor(request: Request): Promise<Actor> {',
772
- ' const anyone: Actor = { ...ANONYMOUS, ...environment(request) }',
773
- ...(looking
774
- ? [
775
- ' const who = ENTITIES.find((one) => one.actor !== undefined)',
776
- ' if (who?.actor === undefined) return anyone',
777
- ' const address = (who.must ?? []).flatMap((one) => (' + quoted('unique') + ' in one ? one.unique : []))[0]',
778
- ' if (address === undefined) return anyone',
779
- ' const held = addressed(request, who.actor.from)',
780
- ' if (held === null) return anyone',
781
- ' const keeper = DECLARED[' + quoted(kept ?? '') + '] as Store | undefined',
782
- ' if (keeper === undefined) return anyone',
783
- ' const token = who.actor.verifies',
784
- ' const row =',
785
- ' token === undefined',
786
- ' ? await keeper.get(anyone, who.id, held, [',
787
- ' address,',
788
- ' who.actor.roles,',
789
- ' ...(who.actor.entitlements === undefined ? [] : [who.actor.entitlements]),',
790
- ' ])',
791
- ' : ((await keeper.list(anyone, who.id, { where: { [token]: held }, limit: 1 }))[0] ?? null)',
792
- ' if (row === null) return anyone',
793
- ' return {',
794
- ' ...anyone,',
795
- ' id: String(row[address] ?? held),',
796
- ' roles: worn(row[who.actor.roles]),',
797
- ' entitlements: who.actor.entitlements === undefined ? [] : worn(row[who.actor.entitlements]),',
798
- ' }',
799
- ]
800
- : [' return anyone']),
801
- '}',
802
- '',
803
- ].join('\n')
804
- }
805
-
806
- const carries = (): boolean => existsSync(resolve(ROOT, 'node_modules', packageOf('operations')))
807
-
808
- const emitted = (): boolean => existsSync(resolve(ROOT, CHECKED_AT))
809
-
810
- const files = (
811
- found: readonly Capability[],
812
- targets: readonly Target[],
813
- calls: readonly Call[],
814
- keeps: string | undefined,
815
- webhooks: readonly Webhook[] = [],
816
- ): readonly Written[] => [
817
- { at: CAPABILITIES_AT, text: printed(found) },
818
- { at: OPENAPI_AT, text: printed(openapi(found)) },
819
- { at: MCP_AT, text: printed(mcp(found)) },
820
- { at: CLIENT_AT, text: client(found) },
821
-
822
- { at: REACHING_AT, text: reaching(targets, calls, kept(), existsSync(resolve(ROOT, VAULT_AT)), carries()) },
823
- { at: ACTOR_AT, text: caller(existsSync(resolve(ROOT, ENTITIES_AT)), keeps) },
824
- ...(emitted() ? [{ at: DISPATCHING_AT, text: dispatching(found) }] : []),
825
- ...found.filter(served).map((one) => ({ at: routeAt(one.operation), text: route(one) })),
826
- ...webhooks
827
- .filter((one) => found.some((each) => each.operation === one.invokes && each.live.includes('webhook') && each.webhooks.includes(one.id) && compiled(one.invokes)))
828
- .flatMap((one) => {
829
- const at = (one.derives ?? []).map((each) => each.at).find((each) => each === inboundAt(one.id))
830
- return at === undefined ? [] : [{ at, text: inbound(one) }]
831
- }),
832
- ]
833
-
834
- interface Carried {
835
- readonly live: readonly Surface[]
836
- readonly off: ReadonlyMap<string, readonly Surface[]>
837
- }
838
-
839
- function unmoved(): Carried | undefined {
840
- const tree = defaulted()
841
- if (tree === undefined || tree.stated) return undefined
842
- const at = resolve(ROOT, CAPABILITIES_AT)
843
- const switches = switched(existsSync(at) ? readFileSync(at, 'utf8') : undefined)
844
- if (switches.size === 0) return undefined
845
- const live = SURFACES.filter((each) => [...switches.values()].some((one) => one.includes(each)))
846
- const off = new Map(
847
- [...switches]
848
- .map(([id, one]) => [id, live.filter((each) => !one.includes(each))] as const)
849
- .filter(([, one]) => one.length > 0),
850
- )
851
- const same = off.size === 0 && live.length === UNSTATED.length && live.every((each) => UNSTATED.includes(each))
852
- return same ? undefined : { live, off }
853
- }
854
-
855
- export const asArray = (surfaces: readonly Surface[]): string => `[${surfaces.map((one) => quoted(one)).join(', ')}]`
856
-
857
- function carry(plan: Carried): readonly string[] {
858
- const at = resolve(ROOT, DEFAULTS_AT)
859
- const put = withTopField(readFileSync(at, 'utf8'), 'live', asArray(plan.live))
860
- if (put === undefined) return []
861
- writeFileSync(at, put, 'utf8')
862
- const sheet = resolve(ROOT, OPERATIONS_AT)
863
- let text = readFileSync(sheet, 'utf8')
864
- const told = [`${DEFAULTS_AT} is live on ${plan.live.join(', ') || 'nothing'}`]
865
- for (const [id, off] of plan.off) {
866
- const where = listBounds(text, MEMBERS_LIST)
867
- const next = where === undefined ? undefined : withField(text, where, id, 'off', asArray(off))
868
- if (next === undefined) continue
869
- text = next
870
- told.push(`${id} turns off ${off.join(', ')}`)
871
- }
872
- writeFileSync(sheet, text, 'utf8')
873
- return told
874
- }
875
-
876
- function routed(found: readonly Capability[], writing: boolean): readonly string[] {
877
- const sheet = resolve(ROOT, OPERATIONS_AT)
878
- if (!existsSync(sheet)) return []
879
- let text = readFileSync(sheet, 'utf8')
880
- const wrong: string[] = []
881
- for (const one of found) {
882
- const id = `${one.operation}_route`
883
- const row = said(
884
- { id, at: routeAt(one.operation), describes: ROUTE, by: 'thread capabilities', runs: 'thread capabilities --check' },
885
- ' ',
886
- )
887
- const members = listBounds(text, MEMBERS_LIST)
888
- const inside = members === undefined ? undefined : nestedBounds(text, members, one.operation, 'derives')
889
- const standing = inside === undefined ? undefined : rowsIn(text, inside).find((each) => each.id === id)
890
- const wants = one.live.includes('api')
891
- if (wants ? standing?.source.trim() === row.trim() : standing === undefined) continue
892
- wrong.push(
893
- wants
894
- ? `${one.operation} answers on api and its row ${standing === undefined ? 'declares no route' : 'says something else about its route'}`
895
- : `${one.operation} does not answer on api and its row still declares ${routeAt(one.operation)}`,
896
- )
897
- if (!writing) continue
898
- let next = inside !== undefined && standing !== undefined ? withoutRow(text, inside, id) : text
899
- if (wants) {
900
- const list = listBounds(next, MEMBERS_LIST)
901
- const within = list === undefined ? undefined : nestedBounds(next, list, one.operation, 'derives')
902
- const put =
903
- within !== undefined
904
- ? withRow(next, within, row)
905
- : list === undefined
906
- ? undefined
907
- : withField(next, list, one.operation, 'derives', `[\n${row}\n ]`)
908
- if (put !== undefined) next = put
909
- }
910
- text = next
911
- }
912
- if (writing && wrong.length > 0) writeFileSync(sheet, text, 'utf8')
913
- return wrong
914
- }
915
-
916
- const unserved = (found: readonly Capability[]): readonly string[] =>
917
- found
918
- .filter((one) => !one.live.includes('api') && existsSync(resolve(ROOT, routeAt(one.operation))))
919
- .map((one) => routeAt(one.operation))
920
-
921
- if (invoked(import.meta.url)) {
922
- const words = argv.slice(2).filter((one) => !one.startsWith('-'))
923
- if (words[0] !== undefined && words[0] !== 'read') {
924
- console.error(
925
- 'capabilities: `' + words[0] + '` is not something this command does. `thread capabilities` writes what can be reached, and ' +
926
- '`thread capabilities read <mark>` prints one. Nothing was written.',
927
- )
928
- exit(1)
929
- }
930
- if (!argv.includes('--check')) {
931
- const declared = declaring(sheet<Webhook>('targets.ts', 'WEBHOOKS'))
932
- for (const one of declared) console.log(`capabilities: declared ${one} in ${TREE}targets.ts, so the route it writes has a row that says the address was agreed to`)
933
- if (declared.length > 0) forget()
934
- const pending = unmoved()
935
- if (pending !== undefined) {
936
- console.log(
937
- `capabilities: carried what ${CAPABILITIES_AT} had switched onto the rows it is worked out from now — ` +
938
- `${carry(pending).join('; ')}. Nothing in it is set by hand any more.`,
939
- )
940
- forget()
941
- }
942
- }
943
- const { found, refusals, live } = await derive()
944
- for (const one of refusals) console.error(`capabilities: ${one}`)
945
-
946
- const mark = (one: Capability): string =>
947
- marked([one.operation, one.describes, one.requires.join(','), one.nodes.join(','), one.live.join(',')])
948
-
949
- const verb = words[0]
950
-
951
- if (verb === 'read') {
952
- const which = words[1]
953
- const one = found.find((each) => mark(each) === which)
954
- if (one === undefined) {
955
- console.error(
956
- `capabilities: \`${which ?? '(nothing)'}\` is not a mark this tree issued. Run \`thread capabilities\` to see them — each is printed in brackets beside the capability it belongs to.`,
957
- )
958
- exit(1)
959
- }
960
- stdout.write(`${one.operation} — ${one.describes}\n`)
961
- stdout.write(`requires ${one.requires.length === 0 ? 'nothing' : one.requires.join(', ')}\n`)
962
- stdout.write(`nodes: ${one.nodes.length === 0 ? '(none)' : one.nodes.join(', ')}\n`)
963
- stdout.write(
964
- `live: ${SURFACES.map((each) => `${one.live.includes(each) ? '[on] ' : '[off]'} ${each}`).join(' ')}\n`,
965
- )
966
- stdout.write(
967
- `${one.browsable ? 'may be called from a page' : 'server only — it reaches a target that needs a server secret'}\n`,
968
- )
969
- stdout.write(`api: ${one.api.method} ${one.api.path}\n`)
970
- stdout.write(`cli: ${one.cli.invocation}\n`)
971
- stdout.write(`mcp: ${one.mcp.name} — ${one.mcp.description}\n`)
972
- stdout.write('input:\n')
973
- for (const line of printed(one.input).trimEnd().split('\n')) stdout.write(` ${line}\n`)
974
- exit(0)
975
- }
976
-
977
- const checking = argv.includes('--check')
978
- let adrift = 0
979
-
980
- if (found.length === 0 && refusals.length === 0) {
981
- console.log('capabilities: this app declares no operation, so nothing is reachable through one yet.')
982
- exit(adrift > 0 ? 1 : 0)
983
- }
984
-
985
- const all = sheet<Target>('targets.ts', 'TARGETS')
986
- const people = sheet<{ readonly id: string; readonly actor?: unknown }>('entities.ts', 'ENTITIES')
987
- const one = people.find((each) => each.actor !== undefined)
988
- const where = one === undefined ? undefined : targetOf(one.id, all)
989
- const written = files(
990
- found,
991
- all,
992
- sheet<Call>('targets.ts', 'CALLS'),
993
- where === undefined || typeof where === 'string' ? undefined : where.id,
994
- sheet<Webhook>('targets.ts', 'WEBHOOKS'),
995
- )
996
-
997
- if (checking) {
998
- const drifted = written.filter((one) => {
999
- const path = resolve(ROOT, one.at)
1000
- return (existsSync(path) ? readFileSync(path, 'utf8') : '') !== one.text
1001
- })
1002
- for (const one of drifted) {
1003
- console.error(
1004
- `capabilities: ${one.at} is not what the operations derive. Run \`thread capabilities\` and commit the result.`,
1005
- )
1006
- }
1007
- const unsettled = [
1008
- ...(unmoved() === undefined ? [] : [`${CAPABILITIES_AT} switches surfaces that no row states yet`]),
1009
- ...routed(found, false),
1010
- ...unserved(found).map((at) => `${at} is still on disk to be served, and its operation does not answer on api`),
1011
- ]
1012
- for (const one of unsettled) console.error(`capabilities: ${one}. Run \`thread capabilities\` and commit the result.`)
1013
- adrift += unsettled.length
1014
- const off = found.filter((one) => one.live.length < live.length)
1015
- console.log(
1016
- `capabilities: ${String(found.length)} operation(s) reachable, ${String(off.length)} with a surface switched off, ` +
1017
- `${String(drifted.length + adrift)} finding(s).`,
1018
- )
1019
- exit(drifted.length + adrift > 0 || refusals.length > 0 ? 1 : 0)
1020
- }
1021
-
1022
- if (refusals.length > 0) {
1023
- console.error(
1024
- `capabilities: ${String(refusals.length)} refusal(s) above, so nothing was written`,
1025
- )
1026
- exit(1)
1027
- }
1028
-
1029
- if (!emitted()) {
1030
- const gone = resolve(ROOT, DISPATCHING_AT)
1031
- if (existsSync(gone)) rmSync(gone)
1032
- }
1033
-
1034
- for (const one of routed(found, true)) console.log(`capabilities: ${one}, so ${OPERATIONS_AT} was changed to match`)
1035
- for (const at of unserved(found)) {
1036
- rmSync(resolve(ROOT, at))
1037
- const folder = dirname(resolve(ROOT, at))
1038
- if (readdirSync(folder).length === 0) rmSync(folder, { recursive: true, force: true })
1039
- console.log(`capabilities: deleted ${at}, because its operation does not answer on api and nothing may serve it`)
1040
- }
1041
-
1042
- for (const one of written) {
1043
- const path = resolve(ROOT, one.at)
1044
- writeFileSync(path, one.text)
1045
- }
1046
-
1047
- const shipped = found.filter(held).length
1048
- stdout.write(
1049
- `capabilities: wrote ${written.map((one) => one.at).join(', ')} — ${String(found.length)} operation(s), ` +
1050
- `${String(shipped)} of them in the client bundle.\n`,
1051
- )
1052
- exit(0)
1053
- }
1054
-