@prestack/thread 0.0.1 → 0.0.2

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 (150) hide show
  1. package/dist/core/actor.ts +20 -0
  2. package/dist/core/asking.d.ts +0 -1
  3. package/dist/core/asking.js +0 -1
  4. package/dist/core/asking.ts +1342 -0
  5. package/dist/core/attach.js +2 -2
  6. package/dist/core/attach.ts +585 -0
  7. package/dist/core/chain.js +8 -1
  8. package/dist/core/chain.ts +148 -0
  9. package/dist/core/cli.js +97 -18
  10. package/dist/core/cli.ts +2004 -0
  11. package/dist/core/condition.ts +227 -0
  12. package/dist/core/descend.d.ts +1 -0
  13. package/dist/core/descend.js +36 -0
  14. package/dist/core/descend.ts +1001 -0
  15. package/dist/core/finding.ts +38 -0
  16. package/dist/core/generators/meta.js +6 -0
  17. package/dist/core/generators/meta.ts +1006 -0
  18. package/dist/core/init.ts +170 -0
  19. package/dist/core/invoked.ts +47 -0
  20. package/dist/core/round.ts +57 -0
  21. package/dist/core/rows.ts +369 -0
  22. package/dist/core/scope.ts +18 -0
  23. package/dist/core/thread.ts +29 -0
  24. package/dist/core/tree.ts +67 -0
  25. package/dist/core/types.ts +350 -0
  26. package/dist/derivation/attach.js +1 -1
  27. package/dist/derivation/attach.ts +68 -0
  28. package/dist/derivation/generators/build.ts +232 -0
  29. package/dist/derivation/generators/check.ts +118 -0
  30. package/dist/derivation/generators/derivation.ts +347 -0
  31. package/dist/derivation/generators/determinism.ts +145 -0
  32. package/dist/derivation/generators/release.js +19 -9
  33. package/dist/derivation/generators/release.ts +213 -0
  34. package/dist/derivation/makers/release.ts +135 -0
  35. package/dist/derivation/release.ts +23 -0
  36. package/dist/integrations/attach.js +2 -2
  37. package/dist/integrations/attach.ts +207 -0
  38. package/dist/integrations/generators/read.ts +127 -0
  39. package/dist/integrations/generators/secret.ts +289 -0
  40. package/dist/integrations/generators/target.js +16 -6
  41. package/dist/integrations/generators/target.ts +944 -0
  42. package/dist/integrations/generators/vault.js +46 -14
  43. package/dist/integrations/generators/vault.ts +212 -0
  44. package/dist/integrations/{sdk.js → module.js} +4 -4
  45. package/dist/integrations/module.ts +189 -0
  46. package/dist/integrations/secret.ts +50 -0
  47. package/dist/integrations/target.d.ts +3 -1
  48. package/dist/integrations/target.js +14 -11
  49. package/dist/integrations/target.ts +340 -0
  50. package/dist/integrations/vault.d.ts +2 -0
  51. package/dist/integrations/vault.js +4 -2
  52. package/dist/integrations/vault.ts +136 -0
  53. package/dist/integrity/attach.ts +234 -0
  54. package/dist/integrity/check-threads.js +2 -26
  55. package/dist/integrity/check-threads.ts +166 -0
  56. package/dist/integrity/constraint.ts +1202 -0
  57. package/dist/integrity/generators/compiler.ts +161 -0
  58. package/dist/integrity/generators/gate.ts +201 -0
  59. package/dist/integrity/generators/issues.ts +160 -0
  60. package/dist/integrity/generators/publish.js +14 -5
  61. package/dist/integrity/generators/publish.ts +247 -0
  62. package/dist/integrity/generators/rule.ts +271 -0
  63. package/dist/integrity/generators/seal.ts +117 -0
  64. package/dist/integrity/generators/trial.ts +320 -0
  65. package/dist/integrity/lint.ts +227 -0
  66. package/dist/integrity/lsp/bin.ts +42 -0
  67. package/dist/integrity/lsp/diagnostics.ts +92 -0
  68. package/dist/integrity/lsp/findings.ts +63 -0
  69. package/dist/integrity/lsp/lint.ts +140 -0
  70. package/dist/integrity/lsp/protocol.ts +78 -0
  71. package/dist/integrity/lsp/server.ts +224 -0
  72. package/dist/integrity/sealed.js +2 -2
  73. package/dist/integrity/sealed.ts +56 -0
  74. package/dist/integrity/slopnator.ts +350 -0
  75. package/dist/integrity/slopnators/sl_broken_lineage.ts +36 -0
  76. package/dist/integrity/slopnators/sl_declared_elsewhere.ts +39 -0
  77. package/dist/integrity/slopnators/sl_derived_drift.ts +98 -0
  78. package/dist/integrity/slopnators/sl_hardcoded_style.ts +55 -0
  79. package/dist/integrity/slopnators/sl_inert_island.ts +91 -0
  80. package/dist/integrity/slopnators/sl_inert_slopnator.ts +93 -0
  81. package/dist/integrity/slopnators/sl_missing_row.ts +182 -0
  82. package/dist/integrity/slopnators/sl_no_barrel.ts +48 -0
  83. package/dist/integrity/slopnators/sl_no_child_process.ts +74 -0
  84. package/dist/integrity/slopnators/sl_no_comment.ts +43 -0
  85. package/dist/integrity/slopnators/sl_no_dispatch_bypass.ts +328 -0
  86. package/dist/integrity/slopnators/sl_no_import_cycle.ts +50 -0
  87. package/dist/integrity/slopnators/sl_no_such_command.ts +64 -0
  88. package/dist/integrity/slopnators/sl_only_takes_a_literal.ts +63 -0
  89. package/dist/integrity/slopnators/sl_secret_literal.ts +100 -0
  90. package/dist/integrity/slopnators/sl_stale_path.ts +94 -0
  91. package/dist/integrity/slopnators/sl_twice_meant.ts +31 -0
  92. package/dist/integrity/slopnators/sl_twice_parented.ts +37 -0
  93. package/dist/integrity/slopnators/sl_undeclared_crossing.ts +87 -0
  94. package/dist/integrity/slopnators/sl_undeclared_destination.ts +65 -0
  95. package/dist/integrity/slopnators/sl_undeclared_element.ts +44 -0
  96. package/dist/integrity/slopnators/sl_undeclared_node.ts +69 -0
  97. package/dist/integrity/slopnators/sl_undeclared_operation.ts +147 -0
  98. package/dist/integrity/slopnators/sl_undeclared_variant.ts +148 -0
  99. package/dist/integrity/slopnators/sl_undescribed.ts +65 -0
  100. package/dist/integrity/slopnators/sl_ungoverned_island.ts +61 -0
  101. package/dist/integrity/slopnators/sl_unkept_rows.ts +111 -0
  102. package/dist/integrity/slopnators/sl_unnamed_island.ts +41 -0
  103. package/dist/integrity/slopnators/sl_unpaired_member.ts +131 -0
  104. package/dist/integrity/slopnators/sl_unpaired_slopnator.ts +58 -0
  105. package/dist/integrity/slopnators/sl_unprefixed_member.ts +136 -0
  106. package/dist/integrity/slopnators/sl_unseamed_reach.ts +79 -0
  107. package/dist/integrity/slopnators/sl_unthreaded.ts +419 -0
  108. package/dist/integrity/slopnators/sl_untranslated_key.ts +164 -0
  109. package/dist/integrity/slopnators/sl_unversioned.ts +36 -0
  110. package/dist/integrity/sweep.ts +307 -0
  111. package/dist/knowledge/attach.ts +350 -0
  112. package/dist/knowledge/generators/docsgen.ts +174 -0
  113. package/dist/knowledge/generators/glossary.ts +73 -0
  114. package/dist/knowledge/generators/skills.ts +263 -0
  115. package/dist/knowledge/glossary.ts +6 -0
  116. package/dist/operations/access.ts +88 -0
  117. package/dist/operations/attach.ts +475 -0
  118. package/dist/operations/capabilities.ts +65 -0
  119. package/dist/operations/cost.ts +58 -0
  120. package/dist/operations/dispatch.ts +348 -0
  121. package/dist/operations/error-results.ts +31 -0
  122. package/dist/operations/errors/catalog.ts +168 -0
  123. package/dist/operations/errors/classes.ts +51 -0
  124. package/dist/operations/errors/errors.ts +314 -0
  125. package/dist/operations/generators/capability.js +29 -19
  126. package/dist/operations/generators/capability.ts +704 -0
  127. package/dist/operations/generators/compile.js +20 -1
  128. package/dist/operations/generators/compile.ts +643 -0
  129. package/dist/operations/generators/grant.ts +336 -0
  130. package/dist/operations/generators/operation.ts +972 -0
  131. package/dist/operations/generators/permits.ts +223 -0
  132. package/dist/operations/generators/seed.ts +347 -0
  133. package/dist/operations/generators/trial.js +4 -1
  134. package/dist/operations/generators/trial.ts +529 -0
  135. package/dist/operations/http.ts +164 -0
  136. package/dist/operations/makers/capability.ts +166 -0
  137. package/dist/operations/makers/trial.ts +520 -0
  138. package/dist/operations/memory.ts +470 -0
  139. package/dist/operations/metering.ts +328 -0
  140. package/dist/operations/nodes.ts +54 -0
  141. package/dist/operations/operations.ts +32 -0
  142. package/dist/operations/queue-policy.ts +10 -0
  143. package/dist/operations/result.ts +28 -0
  144. package/dist/operations/seams.d.ts +4 -4
  145. package/dist/operations/seams.ts +364 -0
  146. package/dist/operations/store.ts +29 -0
  147. package/package.json +8 -1
  148. package/dist/integrations/generators/sdk.d.ts +0 -1
  149. package/dist/integrations/generators/sdk.js +0 -148
  150. /package/dist/integrations/{sdk.d.ts → module.d.ts} +0 -0
@@ -0,0 +1,1342 @@
1
+ import { createHash, randomBytes } from 'node:crypto'
2
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { dirname, join, relative } from 'node:path'
5
+ import { argv, env, exit, stderr, stdin, stdout } from 'node:process'
6
+ import { createInterface } from 'node:readline'
7
+ import { pathToFileURL } from 'node:url'
8
+ import { CONSTRAINTS } from '../integrity/constraint.ts'
9
+ import type { Row } from './descend.ts'
10
+ import { LISTS, descend, listFields, listShape, shapeFields, shapesNamed, type Field } from './descend.ts'
11
+ import { quoted } from './rows.ts'
12
+ import type { Tree } from './tree.ts'
13
+
14
+ const RECEIPTS = join(tmpdir(), 'thread-create')
15
+
16
+ export const RECEIPT = '--receipt'
17
+
18
+ export const ENDS = '.'
19
+
20
+ export const FROM = '--from'
21
+
22
+ export const CHAINED = '--chained'
23
+
24
+ export const BODY = 'body'
25
+
26
+ export const WRITES = 'writes'
27
+
28
+ export const asFlag = (key: string): string => `--${key}`
29
+
30
+ export function refuse(because: string): never {
31
+ stderr.write(`create: ${because}\n`)
32
+ exit(1)
33
+ }
34
+
35
+ export function slug(one: string): string {
36
+ return one
37
+ .trim()
38
+ .toLowerCase()
39
+ .replace(/[^a-z0-9]+/g, '_')
40
+ .replace(/^_+|_+$/g, '')
41
+ }
42
+
43
+ export function within(root: string, path: string): string {
44
+ return relative(root, path).split('\\').join('/')
45
+ }
46
+
47
+ const NAMED = /^([a-z][A-Za-z0-9_.-]*):[ \t]?(.*)$/
48
+
49
+ export function parsed(text: string): readonly { readonly at: number; readonly key: string; readonly value: string }[] {
50
+ const lines = text.split('\n')
51
+ const found: { at: number; key: string; value: string }[] = []
52
+ for (let at = 0; at < lines.length; at += 1) {
53
+ const line = lines[at] ?? ''
54
+ if (line.trim() === '' || line.trimStart().startsWith('#')) continue
55
+ const held = NAMED.exec(line)
56
+ if (held === null) {
57
+ refuse(
58
+ `line ${String(at + 1)} is neither blank nor an answer: ${JSON.stringify(line.slice(0, 60))}. ` +
59
+ 'Every answer is written `name: value`, and a body is written under `body:` and ended by a line ' +
60
+ `holding only ${ENDS}.`,
61
+ )
62
+ }
63
+ const key = held[1] ?? ''
64
+ if (key !== 'body') {
65
+ found.push({ at: at + 1, key, value: (held[2] ?? '').trim() })
66
+ continue
67
+ }
68
+
69
+ const body: string[] = []
70
+ const beside = held[2] ?? ''
71
+ if (beside.trim() !== '') body.push(beside)
72
+ let shut = false
73
+ for (at += 1; at < lines.length; at += 1) {
74
+ if ((lines[at] ?? '').trim() === ENDS) {
75
+ shut = true
76
+ break
77
+ }
78
+ body.push(lines[at] ?? '')
79
+ }
80
+ if (!shut) {
81
+ refuse(
82
+ `the \`body:\` opened on line ${String(found.length === 0 ? 1 : at)} is never closed — a body ends at a ` +
83
+ `line holding only ${ENDS}, and this one runs to the end of the input.`,
84
+ )
85
+ }
86
+ found.push({ at: at + 1, key, value: body.join('\n').trim() })
87
+ }
88
+ return found
89
+ }
90
+
91
+ export function scoped(
92
+ given: readonly { readonly at: number; readonly key: string; readonly value: string }[],
93
+ opens: string,
94
+ ): {
95
+ readonly own: readonly { readonly at: number; readonly key: string; readonly value: string }[]
96
+ readonly each: readonly (readonly { readonly at: number; readonly key: string; readonly value: string }[])[]
97
+ } {
98
+ const own: { at: number; key: string; value: string }[] = []
99
+ const each: { at: number; key: string; value: string }[][] = []
100
+ const lists = new Set<string>(LISTS)
101
+ let mine = false
102
+ for (const one of given) {
103
+ if (one.key === opens) {
104
+ each.push([one])
105
+ mine = true
106
+ continue
107
+ }
108
+ if (lists.has(one.key)) {
109
+ mine = false
110
+ continue
111
+ }
112
+ if (mine) (each[each.length - 1] ?? []).push(one)
113
+ else if (each.length === 0) own.push(one)
114
+ }
115
+ return { own, each }
116
+ }
117
+
118
+ const locate = (one: { readonly at: number; readonly key: string }): string =>
119
+ one.at > 0 ? `line ${String(one.at)}` : `\`--${one.key}\``
120
+
121
+ export interface Asked {
122
+ readonly at: string
123
+ readonly guide: string
124
+ readonly asks?: string
125
+ readonly optional?: boolean
126
+ readonly list?: boolean
127
+ readonly map?: boolean
128
+ readonly boolean?: boolean
129
+ readonly worded?: boolean
130
+ }
131
+
132
+ export interface Said {
133
+ readonly guide: string
134
+ readonly asks?: string
135
+ readonly optional?: boolean
136
+ }
137
+
138
+ let meanings: ReadonlyMap<string, string> | undefined
139
+
140
+ function coined(word: string): string | undefined {
141
+ if (meanings === undefined) {
142
+ const found = new Map<string, string>()
143
+ try {
144
+ for (const [id, held] of descend().coined) {
145
+ const first = held.said[0]
146
+ if (first !== undefined && first !== '') found.set(id, first)
147
+ }
148
+ } catch {
149
+ found.clear()
150
+ }
151
+ meanings = found
152
+ }
153
+ return meanings.get(word)
154
+ }
155
+
156
+ export function asksOf(
157
+ noun: string,
158
+ shape: string | readonly Field[],
159
+ said: Readonly<Record<string, Said>>,
160
+ computed: readonly string[],
161
+ ): readonly Asked[] {
162
+ const fields: readonly Field[] = typeof shape === 'string' ? shapeFields(shape).map((one) => ({ ...one, boolean: false })) : shape
163
+ if (fields.length === 0) {
164
+ refuse(
165
+ `nothing installed here states what a ${noun} is made of, so \`thread create ${noun}\` has no questions to ` +
166
+ `ask. It reads that from the shape a ${noun} is declared with rather than from a list kept beside it. ` +
167
+ 'Run `npx thread attach` to see whether the package that states it is taken on.',
168
+ )
169
+ }
170
+
171
+ const spoken: Record<string, Said> = { ...said }
172
+ for (const one of fields) {
173
+ if (spoken[one.name] !== undefined || computed.includes(one.name)) continue
174
+ const meaning = coined(one.name) ?? (one.guide === '' ? undefined : one.guide)
175
+ if (meaning === undefined) continue
176
+ spoken[one.name] = { guide: meaning }
177
+ }
178
+
179
+ const mute = fields
180
+ .filter((one) => one.required && !computed.includes(one.name) && spoken[one.name] === undefined)
181
+ .map((one) => one.name)
182
+ if (mute.length > 0) {
183
+ const one = mute.length === 1
184
+ refuse(
185
+ `every ${noun} states ${mute.join(', ')}, and \`thread create ${noun}\` never asks for ${one ? 'it' : 'them'}. ` +
186
+ `It would write a ${noun} with no ${mute.join(' and no ')}, and tell you it worked. ` +
187
+ `Give ${one ? 'it' : 'each'} a sentence where this command lists what it asks, or name ${one ? 'it' : 'them'} ` +
188
+ 'as something the command fills in.',
189
+ )
190
+ }
191
+
192
+ return Object.keys(spoken).map((one) => {
193
+ const told = spoken[one] as Said
194
+ const known = fields.find((each) => each.name === one)
195
+ const spare = told.optional ?? (known !== undefined && !known.required)
196
+ return {
197
+ at: one,
198
+ guide: known?.boolean === true ? `${told.guide} — yes or no` : told.guide,
199
+ ...(told.asks === undefined ? {} : { asks: told.asks }),
200
+ ...(spare ? { optional: true } : {}),
201
+ ...(known?.list === true ? { list: true } : {}),
202
+ ...(known?.map === true ? { map: true } : {}),
203
+ ...(known?.boolean === true ? { boolean: true } : {}),
204
+ ...(known?.worded === true ? { worded: true } : {}),
205
+ }
206
+ })
207
+ }
208
+
209
+ export const named = (all: readonly Asked[]): readonly string[] => all.map((one) => one.at)
210
+
211
+ export const prompted = (one: Asked): string =>
212
+ one.asks ?? `${one.at} — ${one.guide}${one.optional === true ? ', or blank' : ''}: `
213
+
214
+ export type Given = { readonly at: number; readonly key: string; readonly value: string }
215
+
216
+ export interface Child {
217
+ readonly id: string
218
+ readonly at: string
219
+ readonly describes: string
220
+ readonly by?: string
221
+ readonly runs?: string
222
+ readonly body?: string
223
+ readonly writes?: string
224
+ }
225
+
226
+ export interface Stated {
227
+ readonly field: string
228
+ readonly said: string
229
+ readonly list: boolean
230
+ readonly map?: boolean
231
+ readonly boolean?: boolean
232
+ readonly worded?: boolean
233
+ }
234
+
235
+ export const mapped = (one: { readonly map?: boolean; readonly worded?: boolean }, said: string): boolean =>
236
+ one.map === true && !(one.worded === true && !said.includes('='))
237
+
238
+ export const CARRIES: readonly string[] = ['cites', 'requires', 'takes', 'by', 'runs']
239
+
240
+ export function ownAnswers(all: readonly Given[]): readonly Given[] {
241
+ const opens = new Set<string>(LISTS)
242
+ const at = all.findIndex((one) => opens.has(one.key))
243
+ return at === -1 ? all : all.slice(0, at)
244
+ }
245
+
246
+ export async function stating(
247
+ asks: readonly Asked[],
248
+ all: readonly Given[],
249
+ piped: boolean,
250
+ ask: (name: string, asks: string) => Promise<string>,
251
+ line: () => Promise<string | undefined>,
252
+ settled: readonly string[],
253
+ ): Promise<{ readonly stated: readonly Stated[]; readonly kids: ReadonlyMap<string, readonly Child[]> }> {
254
+ const own = ownAnswers(all)
255
+ const skip = new Set<string>([...settled, ...LISTS])
256
+ const stated: Stated[] = []
257
+ for (const one of asks) {
258
+ if (skip.has(one.at)) continue
259
+ const given = (piped ? (answer(own, one.at) ?? '') : await ask(one.at, prompted(one))).trim()
260
+ if (given === '') continue
261
+ stated.push({ field: one.at, said: given, list: one.list === true, map: one.map === true, boolean: one.boolean === true, worded: one.worded === true })
262
+ }
263
+
264
+ if (piped) return { stated, kids: childrenIn(all) }
265
+
266
+ const kids = new Map<string, readonly Child[]>()
267
+ for (const list of LISTS) {
268
+ if (!asks.some((one) => one.at === list)) continue
269
+ const found = await children(list, all, ask, line)
270
+ if (found.length > 0) kids.set(list, found)
271
+ }
272
+ return { stated, kids }
273
+ }
274
+
275
+ export function arrayValue(pad: string, key: string, said: string, found: readonly Child[] | undefined): string {
276
+ if (found === undefined || found.length === 0) return asList(said)
277
+ const lines = rowLines(pad, [], new Map([[key, found]]))
278
+ return ['[', ...lines.slice(1, -1), `${pad}]`].join('\n')
279
+ }
280
+
281
+ const BARE = /^[A-Za-z_$][A-Za-z0-9_$]*$/
282
+
283
+ const NUMBER = /^-?\d+(?:\.\d+)?$/
284
+
285
+ const nested = (held: Readonly<Record<string, unknown>>): string => {
286
+ const pairs = Object.entries(held).map(([key, value]) => {
287
+ const name = BARE.test(key) ? key : quoted(key)
288
+ if (typeof value === 'object' && value !== null) return `${name}: ${nested(value as Readonly<Record<string, unknown>>)}`
289
+ const said = String(value)
290
+ return `${name}: ${NUMBER.test(said) ? said : quoted(said)}`
291
+ })
292
+ return pairs.length === 0 ? '{}' : `{ ${pairs.join(', ')} }`
293
+ }
294
+
295
+ export const asMap = (said: string): string => {
296
+ const held: Record<string, unknown> = {}
297
+ for (const one of said.split(',').map((each) => each.trim()).filter((each) => each !== '')) {
298
+ const at = one.indexOf('=')
299
+ if (at < 1) {
300
+ refuse(
301
+ `\`${one}\` is one entry of a name-and-value field, and it states no value. Write each one as ` +
302
+ '`name=value`, separated by commas — `core=*` says that package at any version, and a dot in the name ' +
303
+ 'nests it: `dark.background=colour-surface`. Nothing was written.',
304
+ )
305
+ }
306
+ const path = one.slice(0, at).trim().split('.')
307
+ const last = path[path.length - 1] ?? ''
308
+ let into = held
309
+ for (const step of path.slice(0, -1)) {
310
+ const next = into[step]
311
+ if (next !== undefined && (typeof next !== 'object' || next === null)) {
312
+ refuse(`\`${one}\` nests under \`${step}\`, and \`${step}\` is already given a value of its own. One or the other. Nothing was written.`)
313
+ }
314
+ if (next === undefined) into[step] = {}
315
+ into = into[step] as Record<string, unknown>
316
+ }
317
+ if (typeof into[last] === 'object' && into[last] !== null) {
318
+ refuse(`\`${one}\` gives \`${last}\` a value, and entries already nest under it. One or the other. Nothing was written.`)
319
+ }
320
+ into[last] = one.slice(at + 1).trim()
321
+ }
322
+ return nested(held)
323
+ }
324
+
325
+ export function gathered(all: readonly Given[], fields: readonly Field[]): readonly Given[] {
326
+ const maps = new Set(fields.filter((one) => one.map).map((one) => one.name))
327
+ const entries = new Map<string, { readonly at: number; readonly parts: string[] }>()
328
+ for (const one of all) {
329
+ const dot = one.key.indexOf('.')
330
+ if (dot === -1 || !maps.has(one.key.slice(0, dot))) continue
331
+ const head = one.key.slice(0, dot)
332
+ const held = entries.get(head) ?? { at: one.at, parts: [] }
333
+ held.parts.push(`${one.key.slice(dot + 1)}=${one.value}`)
334
+ entries.set(head, held)
335
+ }
336
+ const written = new Set<string>()
337
+ const out: Given[] = []
338
+ for (const one of all) {
339
+ const dot = one.key.indexOf('.')
340
+ const head = dot === -1 ? one.key : one.key.slice(0, dot)
341
+ if (dot === -1 || !entries.has(head)) {
342
+ out.push(one)
343
+ continue
344
+ }
345
+ if (written.has(head)) continue
346
+ written.add(head)
347
+ const held = entries.get(head)
348
+ out.push({ at: held?.at ?? one.at, key: head, value: (held?.parts ?? []).join(', ') })
349
+ }
350
+ return out
351
+ }
352
+
353
+ const asList = (said: string): string =>
354
+ `[${said
355
+ .split(',')
356
+ .map((one) => one.trim())
357
+ .filter((one) => one !== '')
358
+ .map(quoted)
359
+ .join(', ')}]`
360
+
361
+ export const asBoolean = (said: string): string => {
362
+ const word = said.trim().toLowerCase()
363
+ if (word === 'yes' || word === 'y' || word === 'true') return 'true'
364
+ if (word === 'no' || word === 'n' || word === 'false') return 'false'
365
+ refuse(`\`${said}\` is not an answer to a yes-or-no field. Say yes or no. Nothing was written.`)
366
+ }
367
+
368
+ export function rowLines(
369
+ pad: string,
370
+ stated: readonly Stated[],
371
+ kids: ReadonlyMap<string, readonly Child[]>,
372
+ ): readonly string[] {
373
+ const kid = (one: Child): readonly string[] => [
374
+ `${pad} {`,
375
+ `${pad} id: ${quoted(one.id)},`,
376
+ `${pad} at: ${quoted(one.at)},`,
377
+ `${pad} describes: ${quoted(one.describes)},`,
378
+ ...(one.by === undefined || one.by === '' ? [] : [`${pad} by: ${quoted(one.by)},`]),
379
+ ...(one.runs === undefined || one.runs === '' ? [] : [`${pad} runs: ${quoted(one.runs)},`]),
380
+ `${pad} version: 1,`,
381
+ `${pad} },`,
382
+ ]
383
+ return [
384
+ ...stated.map(
385
+ (one) =>
386
+ `${pad}${one.field}: ${
387
+ mapped(one, one.said) ? asMap(one.said) : one.list ? asList(one.said) : one.boolean === true ? asBoolean(one.said) : quoted(one.said)
388
+ },`,
389
+ ),
390
+ ...[...kids].flatMap(([list, found]) => [`${pad}${list}: [`, ...found.flatMap(kid), `${pad}],`]),
391
+ ]
392
+ }
393
+
394
+ export function withChildren(row: string, lines: readonly string[]): string {
395
+ if (lines.length === 0) return row
396
+ const all = row.split('\n')
397
+ let at = all.length - 1
398
+ while (at >= 0 && (all[at] ?? '').trim() !== '},' && (all[at] ?? '').trim() !== '}') at -= 1
399
+ if (at < 0) {
400
+ refuse(
401
+ 'the row just written has no closing brace on a line of its own, so what it declares has nowhere to go. ' +
402
+ 'Nothing more was written, because adding the row and dropping its children quietly is the one outcome ' +
403
+ 'nobody could see.',
404
+ )
405
+ }
406
+ return [...all.slice(0, at), ...lines, ...all.slice(at)].join('\n')
407
+ }
408
+
409
+ export function childrenIn(all: readonly Given[]): ReadonlyMap<string, readonly Child[]> {
410
+ const found = new Map<string, readonly Child[]>()
411
+ for (const list of LISTS) {
412
+ const each: Child[] = []
413
+ for (const group of scoped(all, list).each) {
414
+ const id = (answer(group, list) ?? '').trim()
415
+ if (id === '') continue
416
+ const at = (answer(group, 'at') ?? '').trim()
417
+ const describes = (answer(group, 'describes') ?? '').trim()
418
+ if (at === '' || describes === '') {
419
+ refuse(
420
+ `\`${list} ${id}\` names no ${at === '' ? 'file' : 'meaning'}. Every row says which file it is about and ` +
421
+ 'what that file is, and one missing either is a row the next person cannot act on. Nothing more was written.',
422
+ )
423
+ }
424
+ const body = (answer(group, BODY) ?? '').trim()
425
+ const writes = (answer(group, WRITES) ?? '').trim()
426
+ each.push({
427
+ id,
428
+ at,
429
+ describes,
430
+ ...(writes === '' ? {} : { writes }),
431
+ ...(list === 'declares'
432
+ ? {}
433
+ : { by: (answer(group, 'by') ?? '').trim(), runs: (answer(group, 'runs') ?? '').trim() }),
434
+ ...(body === '' ? {} : { body }),
435
+ })
436
+ }
437
+ if (each.length > 0) found.set(list, each)
438
+ }
439
+ return found
440
+ }
441
+
442
+ export const BODIED: readonly string[] = ['declares', 'seeds']
443
+
444
+ export const WRITTEN: readonly string[] = ['derives', 'seeds']
445
+
446
+ export function seeding(kids: ReadonlyMap<string, readonly Child[]>, root: string): readonly string[] {
447
+ const said: string[] = []
448
+ for (const list of BODIED) {
449
+ for (const one of kids.get(list) ?? []) {
450
+ const into = join(root, one.at)
451
+ const there = existsSync(into)
452
+
453
+ if (one.body === undefined || one.body === '') {
454
+ said.push(
455
+ there
456
+ ? `${one.at} was already on disk, and the row now names it.`
457
+ : `${one.at} is declared and nothing is in it yet — write it with ` +
458
+ `\`${TYPED} edit ${one.at} --from <path>\`.`,
459
+ )
460
+ continue
461
+ }
462
+
463
+ if (there) {
464
+ said.push(
465
+ `${one.at} was already on disk, so the body you wrote was NOT put over it. Nothing was lost — ` +
466
+ `\`${TYPED} read ${one.at}\` prints what is there.`,
467
+ )
468
+ continue
469
+ }
470
+
471
+ mkdirSync(dirname(into), { recursive: true })
472
+ writeFileSync(into, one.body.endsWith('\n') ? one.body : `${one.body}\n`, 'utf8')
473
+ said.push(`wrote ${one.at} with the body you gave it.`)
474
+ }
475
+ }
476
+ return said
477
+ }
478
+
479
+ const ASKED: Readonly<Record<string, string>> = {
480
+ declares: 'name each file a person writes that this one owns',
481
+ derives: 'name each file a command writes from this one, and the command that writes it',
482
+ seeds: 'name each file a command shapes here',
483
+ }
484
+
485
+ export async function children(
486
+ list: string,
487
+ all: readonly Given[],
488
+ ask: (name: string, asks: string) => Promise<string>,
489
+ line: () => Promise<string | undefined>,
490
+ ): Promise<readonly Child[]> {
491
+ const written = WRITTEN.includes(list)
492
+ const out: Child[] = []
493
+
494
+ const owed = (id: string, at: string, describes: string): void => {
495
+ if (at !== '' && describes !== '') return
496
+ refuse(
497
+ `\`${list} ${id}\` names no ${at === '' ? 'file' : 'meaning'}. Every row says which file it is about and what ` +
498
+ 'that file is, and one missing either is a row the next person cannot act on. Nothing more was written.',
499
+ )
500
+ }
501
+
502
+ const bodies = [
503
+ ...(written ? [`${asFlag(WRITES)} is the generator's own body — the code that writes the template`] : []),
504
+ ...(BODIED.includes(list) ? [`${asFlag(BODY)} is the body of the file itself`] : []),
505
+ ]
506
+ stdout.write(
507
+ `\n ${list} — ${ASKED[list] ?? ''}. ` +
508
+ (bodies.length === 0
509
+ ? ''
510
+ : `Each one takes ${bodies.length === 1 ? 'one body' : `${String(bodies.length)} bodies, and they are never the same text`}: ` +
511
+ `${bodies.join('; ')}. `) +
512
+ 'A blank id ends the list.\n',
513
+ )
514
+ for (;;) {
515
+ const id = (await ask(list, ` ${list} — id, or blank to stop: `)).trim()
516
+ if (id === '') break
517
+ const at = (await ask('at', ' at — the file this names: ')).trim()
518
+ const describes = (await ask('describes', ' describes — what that file is, to somebody who has not opened it: ')).trim()
519
+ owed(id, at, describes)
520
+ const by = written ? (await ask('by', ' by — the command that writes it: ')).trim() : undefined
521
+ const runs = written ? (await ask('runs', ' runs — the command that catches it drifting: ')).trim() : undefined
522
+ const writes = written
523
+ ? await bodied(`the generator behind \`${by ?? 'that command'}\` — this is ${asFlag(WRITES)}`, '', false, all, line)
524
+ : undefined
525
+ const body = BODIED.includes(list)
526
+ ? await bodied(`${at} itself — this is ${asFlag(BODY)}`, '', false, all, line)
527
+ : undefined
528
+ out.push({
529
+ id,
530
+ at,
531
+ describes,
532
+ ...(written ? { by, runs } : {}),
533
+ ...(writes === undefined ? {} : { writes }),
534
+ ...(body === undefined ? {} : { body }),
535
+ })
536
+ }
537
+ return out
538
+ }
539
+
540
+ export async function bodied(
541
+ what: string,
542
+ shows: string,
543
+ piped: boolean,
544
+ all: readonly Given[],
545
+ line: () => Promise<string | undefined>,
546
+ ): Promise<string | undefined> {
547
+ const from = argv[argv.indexOf(FROM) + 1]
548
+ if (argv.includes(FROM) && from !== undefined && !from.startsWith('-')) {
549
+ try {
550
+ return refuse(`${FROM} seeds the body out of a file, and a file is not a receipt: nothing here checked it and no descent reached it. Give the body with ${asFlag(BODY)} on the command. Nothing was written.`)
551
+ } catch {
552
+ refuse(
553
+ `\`${FROM} ${from}\` names a file nothing could read, so there is no body to seed and nothing was ` +
554
+ `written. Write it first, then run this again — or drop ${FROM} and type the body when it is asked for.`,
555
+ )
556
+ }
557
+ }
558
+ if (piped) {
559
+ const said = answer(all, BODY)
560
+ return said === undefined || said.trim() === '' ? undefined : said
561
+ }
562
+ if (shows.trim() === '') stdout.write(` body — write what goes in ${what}.\n`)
563
+ else {
564
+ stdout.write(` body — what a ${what} is handed looks like this:\n`)
565
+ for (const one of shows.split('\n')) stdout.write(` ${one}\n`)
566
+ }
567
+ stdout.write(
568
+ ` write yours, then a line holding only ${ENDS}. A first line of ${ENDS} leaves it unwritten, and\n` +
569
+ ` \`${TYPED} trials\` reports it unproven until somebody writes it.\n` +
570
+ ` Or give ${FROM} <path> instead and the file at that path is what gets seeded, which is the one\n` +
571
+ ' form that needs no terminal.\n',
572
+ )
573
+ const held: string[] = []
574
+ for (;;) {
575
+ const next = await line()
576
+ if (next === undefined || next.trim() === ENDS) break
577
+ held.push(next)
578
+ }
579
+ const written = held.join('\n').trim()
580
+ return written === '' ? undefined : written
581
+ }
582
+
583
+ export function guidance(all: readonly Asked[]): readonly string[] {
584
+ const width = Math.max(...all.map((one) => one.at.length)) + 3
585
+ const under = ' '.repeat(width + 2)
586
+ return all.flatMap((one) => {
587
+ const lines = wrapped(one.optional === true ? `${one.guide.replace(/\.$/, '')}. Leave it blank and it is not stated` : one.guide, '')
588
+ return [` ${`${one.at}:`.padEnd(width)}${lines[0] ?? ''}`, ...lines.slice(1).map((each) => `${under}${each}`)]
589
+ })
590
+ }
591
+
592
+ export function only(
593
+ given: readonly { readonly at: number; readonly key: string; readonly value: string }[],
594
+ allowed: readonly string[],
595
+ where: string,
596
+ ): void {
597
+ for (const one of ownAnswers(given)) {
598
+ if (CARRIES.includes(one.key) || one.key === WRITES) continue
599
+ if (allowed.includes(one.key)) continue
600
+ refuse(
601
+ `${locate(one)} answers \`${one.key}\`, and ${where} takes ${allowed.map((each) => `\`${each}\``).join(', ')}. ` +
602
+ 'Nothing was written.',
603
+ )
604
+ }
605
+ }
606
+
607
+ export function answer(
608
+ given: readonly { readonly at: number; readonly key: string; readonly value: string }[],
609
+ key: string,
610
+ ): string | undefined {
611
+ const found = given.filter((one) => one.key === key)
612
+ if (found.length > 1) {
613
+ refuse(
614
+ `\`${key}\` is answered ${String(found.length)} times: ${found.map((one) => locate(one)).join(', ')}. ` +
615
+ 'Nothing was written.',
616
+ )
617
+ }
618
+ const held = found[0]?.value
619
+ return held === undefined ? undefined : guarded(key, held)
620
+ }
621
+
622
+ export function accepted(
623
+ given: readonly { readonly at: number; readonly key: string; readonly value: string }[],
624
+ ): string {
625
+ if (given.length === 0) return 'nothing was accepted before it'
626
+ return `accepted so far: ${given.map((one) => `${one.key} (${locate(one)})`).join(', ')}`
627
+ }
628
+
629
+ export function needed(
630
+ given: readonly { readonly at: number; readonly key: string; readonly value: string }[],
631
+ key: string,
632
+ where: string,
633
+ ): string {
634
+ const held = answer(given, key)
635
+ if (held === undefined || held === '') {
636
+ refuse(`${where} states no \`${key}\`, and it is not optional. ${accepted(given)}. Nothing was written.`)
637
+ }
638
+ return held
639
+ }
640
+
641
+ export function wrapped(text: string, pad: string): readonly string[] {
642
+ const lines: string[] = []
643
+ let held = ''
644
+ for (const word of text.split(/\s+/).filter((one) => one !== '')) {
645
+ if (held !== '' && `${held} ${word}`.length > 92) {
646
+ lines.push(`${pad}${held}`)
647
+ held = word
648
+ } else held = held === '' ? word : `${held} ${word}`
649
+ }
650
+ if (held !== '') lines.push(`${pad}${held}`)
651
+ return lines
652
+ }
653
+
654
+ export function marked(parts: readonly string[]): string {
655
+ return createHash('sha256').update(parts.join('\n')).digest('hex').slice(0, 6)
656
+ }
657
+
658
+ export interface Told {
659
+ readonly on: string
660
+ readonly said: string
661
+ }
662
+
663
+ export interface Declared {
664
+ readonly at: string
665
+ readonly list: string
666
+ readonly shape: string | undefined
667
+ readonly fields: readonly Field[]
668
+ readonly describes: string
669
+ readonly holds: number
670
+ readonly cites: readonly Told[]
671
+ readonly top: { readonly id: string; readonly describes: string } | undefined
672
+ readonly files?: string
673
+ }
674
+
675
+ const citesOf = (held: unknown): readonly Told[] =>
676
+ Array.isArray(held)
677
+ ? held.filter(
678
+ (one): one is Told =>
679
+ one !== null && typeof one === 'object' && typeof (one as Told).on === 'string' && typeof (one as Told).said === 'string',
680
+ )
681
+ : []
682
+
683
+ const HELD = ['holds', 'declares'] as const
684
+
685
+ const NAMED_SHAPE = /^[A-Za-z_][A-Za-z0-9_]*$/
686
+
687
+ const rowLike = (one: unknown): one is Row =>
688
+ one !== null && typeof one === 'object' && typeof (one as Row).id === 'string' && typeof (one as Row).describes === 'string'
689
+
690
+ const kinded = (shape: string): string =>
691
+ shape
692
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
693
+ .toLowerCase()
694
+
695
+ const oneOf = (list: string): string => {
696
+ const word = list.toLowerCase().split('_').join('-')
697
+ return word.endsWith('s') ? word.slice(0, -1) : word
698
+ }
699
+
700
+ export async function declared(tree: Tree): Promise<ReadonlyMap<string, Declared>> {
701
+ const found = new Map<string, Declared>()
702
+ const walked = new Set<string>()
703
+
704
+ const says = new Map<string, string>()
705
+
706
+ const parents = new Map<string, Row & { readonly cites?: unknown }>()
707
+
708
+ const sheetsBy = new Map<string, Set<string>>()
709
+
710
+ const meant = (shape: string | undefined, sheetAt: string): string => {
711
+ if (shape === undefined) return ''
712
+ try {
713
+ const named = shapesNamed()
714
+ for (const at of named.get(shape) ?? []) {
715
+ if (at === sheetAt) continue
716
+ let others = 0
717
+ for (const [, files] of named) if (files.includes(at)) others += 1
718
+ if (others !== 1) continue
719
+ const told = says.get(at)
720
+ if (told !== undefined && told !== '') return told
721
+ }
722
+ } catch {
723
+ return ''
724
+ }
725
+ return ''
726
+ }
727
+
728
+ const sheet = (one: unknown): string | undefined => {
729
+ const at = (one as Row | undefined)?.at
730
+ return typeof at === 'string' && at.startsWith(`${tree.name}/`) && at.endsWith('.ts') ? at : undefined
731
+ }
732
+
733
+ const walk = async (at: string, top: Declared['top']): Promise<void> => {
734
+ if (walked.has(at)) return
735
+ walked.add(at)
736
+
737
+ let read: Record<string, unknown>
738
+ try {
739
+ read = (await import(pathToFileURL(join(tree.at, at)).href)) as Record<string, unknown>
740
+ } catch {
741
+ return
742
+ }
743
+
744
+ for (const [name, value] of Object.entries(read)) {
745
+ const node = value as
746
+ | { readonly id?: unknown; readonly describes?: unknown; readonly cites?: unknown; readonly declares?: unknown; readonly holds?: unknown; readonly files?: unknown }
747
+ | undefined
748
+ const lists: readonly { readonly list: string; readonly rows: readonly unknown[] }[] = Array.isArray(value)
749
+ ? [{ list: name, rows: value }]
750
+ : HELD.flatMap((list) => {
751
+ const rows = node?.[list]
752
+ return Array.isArray(rows) ? [{ list, rows }] : []
753
+ })
754
+ const rowed = lists.filter((one) => one.rows.every(rowLike))
755
+ const first = rowed[0]
756
+ if (first === undefined) continue
757
+
758
+ if (Array.isArray(value)) {
759
+ const noun = oneOf(name)
760
+ sheetsBy.set(name, new Set([...(sheetsBy.get(name) ?? []), at]))
761
+ if (!found.has(noun)) {
762
+ found.set(noun, {
763
+ at,
764
+ list: name,
765
+ shape: listShape(at, name),
766
+ fields: listFields(at, name),
767
+ describes: '',
768
+ holds: first.rows.length,
769
+ cites: [],
770
+ top,
771
+ })
772
+ }
773
+ } else if (at !== within(tree.at, tree.root)) {
774
+ const files = typeof node?.files === 'string' && node.files !== '' ? node.files : undefined
775
+ const held = files === undefined ? first : (rowed.find((one) => one.list === 'declares') ?? first)
776
+ const kind = listShape(at, held.list)
777
+ const noun =
778
+ kind === undefined || (kind === 'Threaded' && files === undefined)
779
+ ? undefined
780
+ : kind !== 'Threaded' && NAMED_SHAPE.test(kind)
781
+ ? kinded(kind)
782
+ : typeof node?.id === 'string' && node.id !== ''
783
+ ? node.id
784
+ : undefined
785
+ if (typeof noun === 'string' && noun !== '' && !found.has(noun)) {
786
+ found.set(noun, {
787
+ at,
788
+ list: held.list,
789
+ shape: kind,
790
+ fields: listFields(at, held.list),
791
+ describes: typeof node?.describes === 'string' ? node.describes : '',
792
+ holds: held.rows.length,
793
+ cites: citesOf(node?.cites),
794
+ top,
795
+ ...(files === undefined ? {} : { files }),
796
+ })
797
+ }
798
+ }
799
+ for (const one of rowed.flatMap((each) => each.rows)) {
800
+ const where = (one as Row).at
801
+ const told = (one as Row).describes
802
+ if (typeof where === 'string' && typeof told === 'string' && !says.has(where)) says.set(where, told)
803
+ const next = sheet(one)
804
+ if (next === undefined) continue
805
+ const row = one as Row & { readonly cites?: unknown }
806
+ if (!parents.has(next)) parents.set(next, row)
807
+ await walk(next, top ?? { id: typeof row.id === 'string' ? row.id : '', describes: typeof row.describes === 'string' ? row.describes : '' })
808
+ }
809
+ }
810
+ }
811
+
812
+ await walk(within(tree.at, tree.root), undefined)
813
+
814
+ const out = new Map<string, Declared>()
815
+ for (const [noun, one] of found) {
816
+ const parent = parents.get(one.at)
817
+ const said = typeof parent?.describes === 'string' ? parent.describes : ''
818
+ const owned = (sheetsBy.get(one.list)?.size ?? 0) <= 1
819
+ out.set(noun, {
820
+ ...one,
821
+ describes: one.describes !== '' ? one.describes : owned && said !== '' ? said : meant(one.shape, one.at),
822
+ cites: one.cites.length > 0 ? one.cites : citesOf(parent?.cites),
823
+ })
824
+ }
825
+ return out
826
+ }
827
+
828
+ export function nouns(all: readonly Row[], verb = 'create'): ReadonlyMap<string, string> {
829
+ const found = new Map<string, string>()
830
+ const opens = `thread ${verb} `
831
+ for (const one of all) {
832
+ const by = one.by
833
+ if (typeof by !== 'string' || !by.startsWith(opens)) continue
834
+ const word = by.slice(opens.length).trim()
835
+ if (word === '' || typeof one.at !== 'string') continue
836
+ const already = found.get(word)
837
+ if (already !== undefined && already !== one.at) {
838
+ refuse(
839
+ `two rows both state \`by: 'thread ${verb} ${word}'\` — ${already} and ${one.at} — so which one answers a ` +
840
+ `${word} is whichever was written first. One noun, one file.`,
841
+ )
842
+ }
843
+ found.set(word, one.at)
844
+ }
845
+ return found
846
+ }
847
+
848
+ const spends = (root: string, nonce: string): string =>
849
+ join(RECEIPTS, `${createHash('sha256').update(root).digest('hex').slice(0, 16)}-${nonce}.txt`)
850
+
851
+ const stamp = (listing: string): string => createHash('sha256').update(listing).digest('hex').slice(0, 12)
852
+
853
+ function beingMade(): string {
854
+ const words = argv.slice(2).filter((one) => !one.startsWith('-'))
855
+ if (words[0] === 'create' && typeof words[1] === 'string' && words[1] !== '') return words[1]
856
+ const file = (argv[1] ?? '').split('\\').join('/').split('/').at(-1) ?? ''
857
+ return file.replace(/\.[^.]+$/, '')
858
+ }
859
+
860
+ export const SKILLS_AT = 'thread/knowledge'
861
+
862
+ export function skillOf(noun: string): string {
863
+ return `${SKILLS_AT}/${noun}.md`
864
+ }
865
+
866
+ function readTheSkill(): readonly string[] {
867
+ const noun = env.THREAD_NOUN ?? beingMade()
868
+ if (noun === '') return []
869
+ return [
870
+ '',
871
+ `READ ${skillOf(noun)} BEFORE YOU WRITE ANYTHING. It is this command's own output, kept as a`,
872
+ 'file, and it lists everything that already exists. Building a second copy of something the tree',
873
+ 'already has is the most expensive mistake available here and it has already been made: thirteen',
874
+ 'elements were declared in one app that every one of them already shipped.',
875
+ '',
876
+ 'If that file is not there, run `npx thread skills` and it will be.',
877
+ ]
878
+ }
879
+
880
+ export function issue(root: string, listing: string): string {
881
+ const nonce = randomBytes(6).toString('hex')
882
+ mkdirSync(RECEIPTS, { recursive: true })
883
+ writeFileSync(spends(root, nonce), `${stamp(listing)}\n`, 'utf8')
884
+ const said = readTheSkill()
885
+ if (said.length > 0) stdout.write(`${said.join('\n')}\n`)
886
+ return nonce
887
+ }
888
+
889
+ export function spend(root: string, listing: string, offered: string | undefined, sheet: string): void {
890
+ if (offered === undefined || offered.startsWith('-')) {
891
+ refuse(
892
+ `\`${RECEIPT}\` was handed nothing to check. Run \`thread create\` with no arguments — it lists what is ` +
893
+ 'here and issues a receipt to hand back.',
894
+ )
895
+ }
896
+ const path = spends(root, offered)
897
+ if (!existsSync(path)) {
898
+ refuse(
899
+ `\`${offered}\` is not a receipt this tree issued, or it has been spent already — one run per receipt. ` +
900
+ 'Run `thread create` again and use the one it prints.',
901
+ )
902
+ }
903
+ let when = ''
904
+ try {
905
+ when = readFileSync(path, 'utf8').trim()
906
+ } catch {
907
+ when = ''
908
+ }
909
+
910
+ process.on('exit', (code) => {
911
+ if (code === 0) rmSync(path, { force: true })
912
+ })
913
+
914
+ if (when !== stamp(listing)) {
915
+ refuse(
916
+ `${sheet} has changed since that receipt was issued, so what you read is not what is here. ` +
917
+ 'Run `thread create` again.',
918
+ )
919
+ }
920
+ }
921
+
922
+ export function asking(
923
+ held: readonly { readonly at: number; readonly key: string; readonly value: string }[],
924
+ ): {
925
+ readonly piped: boolean
926
+ readonly ask: (
927
+ key: string,
928
+ question: string,
929
+ from?: readonly { readonly at: number; readonly key: string; readonly value: string }[],
930
+ ) => Promise<string>
931
+
932
+ readonly line: () => Promise<string | undefined>
933
+ readonly shut: () => void
934
+ } {
935
+ const piped = answered()
936
+ const rl = piped ? undefined : createInterface({ input: stdin, output: stdout })
937
+ const lines = rl?.[Symbol.asyncIterator]()
938
+ return {
939
+ piped,
940
+ ask: async (key, question, from = held) => {
941
+ const already = answer(from, key)
942
+ if (already !== undefined) return guarded(key, already)
943
+ if (piped) return ''
944
+ stdout.write(question)
945
+ const next = await lines?.next()
946
+ return next === undefined || next.done === true ? '' : guarded(key, next.value.trim())
947
+ },
948
+ line: async () => {
949
+ const next = await lines?.next()
950
+ return next === undefined || next.done === true ? undefined : next.value
951
+ },
952
+ shut: () => rl?.close(),
953
+ }
954
+ }
955
+
956
+ const ANSWERS = '--answers'
957
+
958
+ function fromFile(): string | undefined {
959
+ const at = argv.indexOf(ANSWERS)
960
+ if (at === -1) return undefined
961
+ const path = argv[at + 1]
962
+ if (path === undefined || path.startsWith('-')) {
963
+ refuse(`${ANSWERS} needs the path of a file holding the answers, one \`name: value\` to a line.`)
964
+ }
965
+ try {
966
+ return readFileSync(path, 'utf8')
967
+ } catch {
968
+ refuse(`${ANSWERS} names ${path}, and there is no file there to read the answers from.`)
969
+ }
970
+ }
971
+
972
+ const OWN = '--own'
973
+ const CHECK = '--check'
974
+
975
+ function flagged(): readonly { readonly at: number; readonly key: string; readonly value: string }[] {
976
+ const found: { at: number; key: string; value: string }[] = []
977
+ for (let at = 2; at < argv.length; at += 1) {
978
+ const one = argv[at] ?? ''
979
+ if (
980
+ !one.startsWith('--') ||
981
+ one === RECEIPT ||
982
+ one === ANSWERS ||
983
+ one === OWN ||
984
+ one === CHECK ||
985
+ one === FROM ||
986
+ one === CHAINED
987
+ )
988
+ continue
989
+ const value = argv[at + 1]
990
+ if (value === undefined || value.startsWith('-')) {
991
+ refuse(`\`${one}\` was given no value — an answer given as a flag is \`${one} value\`, with the value right after it.`)
992
+ }
993
+ found.push({ at: 0, key: one.slice(2), value })
994
+ at += 1
995
+ }
996
+ return found
997
+ }
998
+
999
+ export function answered(): boolean {
1000
+ return argv.includes(ANSWERS) || stdin.isTTY !== true
1001
+ }
1002
+
1003
+ export function given(): readonly { readonly at: number; readonly key: string; readonly value: string }[] {
1004
+ const flags = flagged()
1005
+ const fromFlag = new Set(flags.map((one) => one.key))
1006
+
1007
+ const held = fromFile()
1008
+ if (held !== undefined) return read([...flags, ...parsed(held).filter((one) => !fromFlag.has(one.key))])
1009
+
1010
+ const text = handed()
1011
+ if (text === undefined) return read(flags)
1012
+
1013
+ return read([...flags, ...parsed(text).filter((one) => !fromFlag.has(one.key))])
1014
+ }
1015
+
1016
+ const FROM_END = '-from'
1017
+
1018
+ function read(
1019
+ all: readonly { readonly at: number; readonly key: string; readonly value: string }[],
1020
+ ): readonly { readonly at: number; readonly key: string; readonly value: string }[] {
1021
+ return all.map((one) => {
1022
+ if (!one.key.endsWith(FROM_END)) return one
1023
+ const key = one.key.slice(0, -FROM_END.length)
1024
+ if (key === '') return one
1025
+ if (all.some((each) => each.key === key)) {
1026
+ refuse(
1027
+ `\`${asFlag(key)}\` and \`${asFlag(one.key)}\` both answer ${key}, and they do not have to agree. ` +
1028
+ 'Give one of them. Nothing was written.',
1029
+ )
1030
+ }
1031
+ try {
1032
+ return refuse(`${asFlag(key)}${FROM_END} takes what this writes out of a file, and a file is not a receipt: nothing here checked it and no descent reached it. Give the text on the command with ${asFlag(key)}. Nothing was written.`)
1033
+ } catch {
1034
+ refuse(
1035
+ `\`${asFlag(one.key)} ${one.value}\` names a file nothing could read, so there is no ${key} to write and ` +
1036
+ `nothing was written. Write that file first, or give the text itself with ${asFlag(key)}.`,
1037
+ )
1038
+ }
1039
+ })
1040
+ }
1041
+
1042
+ let stood: string | undefined
1043
+ let taken = false
1044
+
1045
+ export function handed(): string | undefined {
1046
+ if (taken) return stood
1047
+ taken = true
1048
+ if (stdin.isTTY === true) return undefined
1049
+ try {
1050
+ stood = readFileSync(0, 'utf8')
1051
+ } catch {
1052
+ stood = undefined
1053
+ }
1054
+ return stood
1055
+ }
1056
+
1057
+ export const TYPED = 'npx thread'
1058
+
1059
+ const folded = (said: string, pad: string, room = 92): readonly string[] => {
1060
+ const out: string[] = []
1061
+ let line = ''
1062
+ for (const word of said.split(/\s+/)) {
1063
+ if (line === '') line = word
1064
+ else if (`${line} ${word}`.length <= room) line = `${line} ${word}`
1065
+ else {
1066
+ out.push(line)
1067
+ line = word
1068
+ }
1069
+ }
1070
+ if (line !== '') out.push(line)
1071
+ return out.map((one) => `${pad}${one}`)
1072
+ }
1073
+
1074
+ const parented = ((): readonly string[] => {
1075
+ const one = CONSTRAINTS.find((each) => each.id === 'twice-parented')
1076
+ if (one === undefined) {
1077
+ return [
1078
+ '',
1079
+ 'THIS PAGE CANNOT STATE THE RULE. It reads what may declare a file out of the `twice-parented`',
1080
+ 'rule, and no rule by that name ships any more. Whatever governs this now, it is not said here —',
1081
+ `\`${TYPED} create rule\` lists every rule that does exist.`,
1082
+ ]
1083
+ }
1084
+ return [
1085
+ '',
1086
+ 'which one you pick is a rule rather than a preference, and a check refuses the tree over it:',
1087
+ '',
1088
+ ` ${one.id}`,
1089
+ ...folded(`bans ${one.bans}.`, ' '),
1090
+ ...folded(one.instead.replace(/^./, (each) => each.toUpperCase()), ' '),
1091
+ ]
1092
+ })()
1093
+
1094
+ const NEXT: Readonly<Record<string, string>> = {
1095
+ declares: 'It writes the file from that, and nothing else runs. A file already on disk is left alone and the row simply names it.',
1096
+ derives: `It writes NO file at \`at:\`, because the command in \`by:\` writes that one — which is why there is no ${asFlag(BODY)} here to give. If this tree has no command answering \`by:\`, that generator is declared first and ${asFlag(WRITES)} is what goes in it.`,
1097
+ seeds: `TWO files come out of this and a different hand writes each, which is the whole reason there are two bodies. If this tree has no command answering \`by:\`, that generator is declared here too, so one run leaves the row, the generator and the file.`,
1098
+ }
1099
+
1100
+ export const VERBS: readonly string[] = [
1101
+ 'nothing is in this tree until a row names it, and which of three lists the row sits in says who',
1102
+ 'wrote the file. Pick the one that is true and the rest of this framework follows from it:',
1103
+ '',
1104
+ ' declares a person wrote it. Exactly one row may ever declare an address, and a second is',
1105
+ ' refused: a file with two owners has two answers for where it came from and the tree',
1106
+ ' can give neither.',
1107
+ ' derives a command wrote it. Delete the file, run the command again, and the same bytes come',
1108
+ ' back. Many rows may derive one address, because a derivation owns nothing — it states',
1109
+ ' `by:`, the command that writes it, and `runs:`, what catches it drifting.',
1110
+ ' seeds a command wrote the shape and a person fills in the part no machine can supply. Many',
1111
+ ' rows may seed one address as well, and it is proved by shape rather than by bytes,',
1112
+ ' because half of what is on disk is your own writing.',
1113
+ '',
1114
+ `a file in none of them is not in this app at all — \`${TYPED} descent\` names every one it reached`,
1115
+ 'and every one it did not, and nothing undeclared is compiled in or shipped.',
1116
+ '',
1117
+ 'and the list decides what this command does next, not just what the row says. What it does next is',
1118
+ 'ASK FOR BODIES, and there are two of them, which are never the same text and never the same file:',
1119
+ '',
1120
+ ` ${asFlag(BODY).padEnd(10)} the body of the file the row names. A person wrote it.`,
1121
+ ` ${asFlag(WRITES).padEnd(10)} the body of the GENERATOR — the code that writes a template into that file.`,
1122
+ '',
1123
+ 'give each as text on the command. Neither may be taken out of a file, because a file is not a',
1124
+ 'receipt: nothing here checked it and no descent reached it. Single-quote a body that holds quotes.',
1125
+ '',
1126
+ 'so each list takes a different set of them, and giving one where the other belongs is the mistake',
1127
+ 'this section exists to stop:',
1128
+ '',
1129
+ ...LISTS.flatMap((list) => {
1130
+ const takes = [
1131
+ ...(WRITTEN.includes(list) ? [asFlag(WRITES)] : []),
1132
+ ...(BODIED.includes(list) ? [asFlag(BODY)] : []),
1133
+ ]
1134
+ const said = folded(
1135
+ `takes ${takes.length === 0 ? 'no body at all' : takes.join(' and ')}. ${NEXT[list] ?? ''}`,
1136
+ ' ',
1137
+ )
1138
+ return said.map((line, at) => (at === 0 ? ` ${list.padEnd(11)}${line.trim()}` : line))
1139
+ }),
1140
+ ...parented,
1141
+ ]
1142
+
1143
+ export const grows = (sheetAt: string, noun: string): readonly string[] => {
1144
+ const shaped = noun.split('-').map((one) => `${one.slice(0, 1).toUpperCase()}${one.slice(1)}`).join('')
1145
+ return [
1146
+ `this is also how the CLI grows. A list of rows in ${sheetAt} becomes a command: declare`,
1147
+ `\`export const THINGS: readonly Thing[] = []\` beside what is already there and \`${TYPED} create thing\``,
1148
+ 'answers from that moment, asking for whatever a `Thing` states and writing the row into that list.',
1149
+ '',
1150
+ `The same is true of what you have now: \`${TYPED} create ${noun}\` exists because ${sheetAt} declares`,
1151
+ `\`${shaped}\`. Nothing was added to a list of commands anywhere — the CLI reads what your tree says.`,
1152
+ ]
1153
+ }
1154
+
1155
+ export const VALUED = 'THREAD_VALUED'
1156
+
1157
+ export function answering(noun: string): readonly string[] {
1158
+ return [
1159
+ ...(env[VALUED] === '1' ? [] : [...VERBS, '']),
1160
+ 'what `new` asks. Give each answer as a flag on one command and close with the receipt:',
1161
+ '',
1162
+ ` ${TYPED} create ${noun} new --<answer> <value> ... ${RECEIPT} <receipt>`,
1163
+ '',
1164
+ 'Flags need no terminal, no pipe and no file, so that form works everywhere. The two others',
1165
+ 'each need something you may not have: one answer to a line on stdin needs a shell that can',
1166
+ 'redirect, and `--answers <path>` needs a way to write the file first. Use them when you have',
1167
+ 'them; the line above is the one that never depends on it.',
1168
+ '',
1169
+ 'Where a question repeats — a list of things, each with answers of its own — there are no',
1170
+ 'separate flag names for the inner ones. Give the group\'s own flag again to begin the next,',
1171
+ 'then its answers after it, reusing the same words. They are read in the order you write them,',
1172
+ 'and everything before the first group flag belongs to the thing itself.',
1173
+ '',
1174
+ 'the answers, in order:',
1175
+ ]
1176
+ }
1177
+
1178
+ export const CARRIED: readonly { readonly verb: string; readonly typed: string; readonly does: string }[] = [
1179
+ { verb: 'read', typed: `${TYPED} read <row>`, does: 'prints every field a row states' },
1180
+ { verb: 'edit', typed: `${TYPED} edit <row> --<field> <value>`, does: 'changes any field it was declared with' },
1181
+ { verb: 'remove', typed: `${TYPED} edit <row> --remove`, does: 'takes the row out of the tree' },
1182
+ ]
1183
+
1184
+ const elsewhere = CARRIED.map((one) => `\`${one.typed}\` ${one.does}`)
1185
+
1186
+ export const ELSEWHERE: readonly string[] = [
1187
+ 'reading a row, changing one of its answers and taking it out are done in one place, whatever',
1188
+ 'declared it \u2014 name the row by its id or by the mark printed beside it above:',
1189
+ '',
1190
+ ...CARRIED.map((one) => ` ${one.typed.padEnd(40)}${one.does}`),
1191
+ ]
1192
+
1193
+ const opening = (said: string): string => said.split(/(?<=\.)\s/)[0] ?? said
1194
+
1195
+ const article = (word: string): string => (/^[aeiou]/.test(word) ? 'an' : 'a')
1196
+
1197
+ export function taught(noun: string, told: ReadonlyMap<string, Declared>, commands: ReadonlySet<string>): readonly string[] {
1198
+ const one = told.get(noun)
1199
+ const opens: readonly (readonly [string, string])[] = [
1200
+ [`${TYPED} read <id>`, 'the row, and the file it names'],
1201
+ [`${TYPED} create ${noun}`, 'this list again, with a fresh receipt'],
1202
+ ...(commands.has('thread inspect')
1203
+ ? [[`${TYPED} inspect <word>`, 'every row whose name or sentence carries the word'] as const]
1204
+ : []),
1205
+ ]
1206
+ const top = one?.top
1207
+ const siblings =
1208
+ top === undefined
1209
+ ? []
1210
+ : [...told]
1211
+ .filter(([, each]) => each.top?.id === top.id && each.describes !== '')
1212
+ .sort(([left], [right]) => (left < right ? -1 : 1))
1213
+ const pad = Math.max(0, ...siblings.map(([word]) => word.length)) + 3
1214
+ return [
1215
+ 'DO NOT CREATE WHAT ALREADY EXISTS. Everything listed below is declared and yours to use. A thing',
1216
+ 'you cannot find is usually one of these under a word you have not met \u2014 read the sentences, not',
1217
+ 'the names.',
1218
+ '',
1219
+ 'IF NOTHING FITS, SAY SO AND STOP. Do not coin a name: a name is a meaning, and a meaning is',
1220
+ 'declared rather than invented. Being told the list is short a thing is a useful answer.',
1221
+ '',
1222
+ 'NOW GO AND OPEN ONE. A sentence is a summary, and every expensive mistake here was made from a',
1223
+ 'summary. These print the real thing, and none of them writes anything:',
1224
+ '',
1225
+ ...opens.map(([said, does]) => ` ${said.padEnd(30)}${does}`),
1226
+ '',
1227
+ ...(one === undefined || one.describes === ''
1228
+ ? []
1229
+ : [
1230
+ `what ${article(noun)} ${noun} is:`,
1231
+ '',
1232
+ ...wrapped(one.describes, ' '),
1233
+ ...one.cites.flatMap((said) => ['', ...wrapped(`said on ${said.on}: "${said.said}"`, ' ')]),
1234
+ '',
1235
+ ]),
1236
+ ...(top === undefined || siblings.length < 2
1237
+ ? []
1238
+ : [
1239
+ `${top.id} \u2014 ${opening(top.describes)}`,
1240
+ 'what it makes, and what each one is. Pick the one that is true before declaring anything:',
1241
+ '',
1242
+ ...siblings.flatMap(([word, each]) => {
1243
+ const lines = wrapped(opening(each.describes), '')
1244
+ return [` ${word.padEnd(pad)}${lines[0] ?? ''}`, ...lines.slice(1).map((line) => `${' '.repeat(pad + 2)}${line}`)]
1245
+ }),
1246
+ '',
1247
+ ]),
1248
+ ]
1249
+ }
1250
+
1251
+ export function reminded(noun: string | undefined): string {
1252
+ const lists =
1253
+ noun === undefined || noun === 'meta'
1254
+ ? `\`${TYPED} create\` lists what this tree makes, and any noun run bare lists what exists, each with its sentence`
1255
+ : `\`${TYPED} create ${noun}\` lists what exists, each with its sentence`
1256
+ return `thread: before the next thing, ${lists}. Do not create what already exists, and if nothing fits, say so and stop.`
1257
+ }
1258
+
1259
+ export function wordsIn(offered: readonly string[]): readonly string[] {
1260
+ const values = new Set([argv[argv.indexOf(RECEIPT) + 1], argv[argv.indexOf(ANSWERS) + 1], ...flagged().map((one) => one.value)])
1261
+ const words = argv.slice(2).filter((one) => !one.startsWith('-') && !values.has(one))
1262
+ const first = words[0]
1263
+ if (offered.length === 0 || first === undefined || offered.includes(first)) return words
1264
+ const carried = CARRIED.find((one) => one.verb === first)
1265
+ if (carried !== undefined) {
1266
+ refuse(
1267
+ `\`${first}\` is not a word this command answers, because every row in this tree has it and it is ` +
1268
+ `answered in one place rather than once per thing. \`${carried.typed}\` ${carried.does}, whatever ` +
1269
+ 'declared it, and it finds the row by its id or by the mark printed beside it. Nothing was written, ' +
1270
+ 'and nothing was read.',
1271
+ )
1272
+ }
1273
+ refuse(
1274
+ `\`${first}\` is not one of the things this command does. It does: ${offered.join(', ')}. ` +
1275
+ `Reading, changing and removing a row are done elsewhere, because they reach every row rather than ` +
1276
+ `only this one's: ${elsewhere.join('; ')}. ` +
1277
+ 'Run it with none of those to see what already exists and exactly what each of them asks for. ' +
1278
+ 'Nothing was written, and nothing was read.',
1279
+ )
1280
+ }
1281
+
1282
+ const ANNOUNCED: readonly { readonly what: string; readonly pattern: RegExp }[] = [
1283
+ { what: 'a Stripe secret key', pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}/ },
1284
+ { what: 'a Stripe restricted key', pattern: /\brk_(?:live|test)_[A-Za-z0-9]{16,}/ },
1285
+ { what: 'a GitHub token', pattern: /\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}/ },
1286
+ { what: 'a GitHub personal access token', pattern: /\bgithub_pat_[A-Za-z0-9_]{30,}/ },
1287
+ { what: 'an AWS access key id', pattern: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/ },
1288
+ { what: 'a Slack token', pattern: /\bxox[abposr]-[A-Za-z0-9-]{10,}/ },
1289
+ { what: 'an OpenAI key', pattern: /\bsk-[A-Za-z0-9_-]{32,}/ },
1290
+ { what: 'an Anthropic key', pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}/ },
1291
+ { what: 'a Google API key', pattern: /\bAIza[A-Za-z0-9_-]{35}\b/ },
1292
+ { what: 'a JSON web token', pattern: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
1293
+ { what: 'a private key', pattern: /-----BEGIN (?:RSA |EC |OPENSSH |PGP )?PRIVATE KEY-----/ },
1294
+ { what: 'a password inside a connection string', pattern: /\b[a-z][a-z0-9+.-]*:\/\/[^\s:/@]+:[^\s:/@]{6,}@/ },
1295
+ ]
1296
+
1297
+ const ASSIGNED = /\b(?:secret|password|passwd|api_?key|apikey|access_?token|auth_?token|private_?key|client_?secret|credential)s?\b\s*[:=]\s*(['"`])([^'"`\n]{16,})\1/gi
1298
+
1299
+ const MIXED = /^(?=.*[a-z])(?=.*[A-Z0-9])[A-Za-z0-9_+/=.-]{16,}$/
1300
+
1301
+ const NOT = [
1302
+ /^(?:sec|pub)_[a-z0-9_]+$/,
1303
+ /^\$\{/,
1304
+ /^(?:x{3,}|X{3,}|\.{3,}|<[^>]+>|change[-_]?me|your[-_]|example|placeholder|redacted|todo|test|dummy|sample|fake|dGhpcyBpcyBub3QgYSBzZWNyZXQ)/i,
1305
+ ]
1306
+
1307
+ function guarded(key: string, value: string): string {
1308
+ const carried = credentialIn(value)
1309
+ if (carried !== undefined) {
1310
+ refuse(
1311
+ `\`${key}\` holds ${carried}. A credential is never typed into a declaration — not in a describes, not ` +
1312
+ 'anywhere but a secret. Declare it with `thread create secret`, name it where the thing that needs it ' +
1313
+ 'states what it requires, and it is resolved through the vault at the moment it is used and never ' +
1314
+ 'written down. Nothing was written.',
1315
+ )
1316
+ }
1317
+ const address = ADDRESS.exec(value)
1318
+ if (address !== null) {
1319
+ refuse(
1320
+ `\`${key}\` holds the address \`${address[0]}\`. An address is a secret here, not a sentence — the same ` +
1321
+ 'thing behind another host is another environment, and one written into a declaration is one nothing ' +
1322
+ 'can point elsewhere. Declare it with `thread create secret` and name it where the thing that needs it ' +
1323
+ 'states what it requires. Nothing was written.',
1324
+ )
1325
+ }
1326
+ return value
1327
+ }
1328
+
1329
+ const ADDRESS = /\b[a-z][a-z0-9+.-]*:\/\/[^\s'"`)]+/i
1330
+
1331
+ export function credentialIn(text: string): string | undefined {
1332
+ for (const one of ANNOUNCED) {
1333
+ if (one.pattern.test(text)) return one.what
1334
+ }
1335
+ for (const found of text.matchAll(ASSIGNED)) {
1336
+ const value = found[2] ?? ''
1337
+ if (NOT.some((each) => each.test(value))) continue
1338
+ if (!MIXED.test(value)) continue
1339
+ return `a ${(found[0].split(/[:=]/)[0] ?? '').trim()} written down as a literal ${String(value.length)} characters long`
1340
+ }
1341
+ return undefined
1342
+ }