@prestack/thread 0.0.1 → 0.0.3

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,145 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import { named } from '../../core/scope.ts'
4
+
5
+ export interface GeneratedFile {
6
+
7
+ readonly path: string
8
+
9
+ readonly contents: string
10
+ }
11
+
12
+ export function sortedBy<T>(items: Iterable<T>, key: (item: T) => string): T[] {
13
+ return [...items].sort((a, b) => compare(key(a), key(b)))
14
+ }
15
+
16
+ export function compare(a: string, b: string): number {
17
+ return a < b ? -1 : a > b ? 1 : 0
18
+ }
19
+
20
+ export function sortedEntries<V>(record: Readonly<Record<string, V>>): [string, V][] {
21
+ return Object.keys(record)
22
+ .sort(compare)
23
+ .map((key) => [key, record[key] as V])
24
+ }
25
+
26
+ const BANNER = `GENERATED by ${named('derivation')} — do not edit by hand.`
27
+ const HASH_LABEL = 'codegen-hash:'
28
+
29
+ interface HeaderSyntax {
30
+ readonly wrap: (text: string) => string
31
+ readonly unwrap: (line: string) => string | null
32
+ }
33
+
34
+ function headerSyntax(path: string): HeaderSyntax {
35
+ if (path.endsWith('.md')) {
36
+ return {
37
+ wrap: (text) => `<!-- ${text} -->`,
38
+ unwrap: (line) =>
39
+ line.startsWith('<!-- ') && line.endsWith(' -->') ? line.slice(5, -4) : null,
40
+ }
41
+ }
42
+ const prefix = path.endsWith('.sql') ? '--' : path.endsWith('.ts') || path.endsWith('.tsx') ? '//' : null
43
+ if (prefix === null) {
44
+ throw new Error(
45
+ `${named('derivation')} emits only .ts, .sql and .md, received "${path}". ` +
46
+ 'A format with no comment syntax cannot carry a header, and an unheadered generated ' +
47
+ 'file is one somebody edits by hand.',
48
+ )
49
+ }
50
+ return {
51
+ wrap: (text) => `${prefix} ${text}`,
52
+ unwrap: (line) => (line.startsWith(`${prefix} `) ? line.slice(prefix.length + 1) : null),
53
+ }
54
+ }
55
+
56
+ export function bodyHash(body: string): string {
57
+ return createHash('sha256').update(body, 'utf8').digest('hex')
58
+ }
59
+
60
+ const HEADER_LINES = 5
61
+
62
+ export function withHeader(path: string, source: string, body: string): GeneratedFile {
63
+ const { wrap } = headerSyntax(path)
64
+ const normalized = body.endsWith('\n') ? body : `${body}\n`
65
+
66
+ const header = [
67
+ wrap(BANNER),
68
+ wrap(`Source: ${source}`),
69
+ wrap('Edit the island and re-run `thread generate`. CI fails when this file is stale.'),
70
+ wrap(`${HASH_LABEL} ${bodyHash(normalized)}`),
71
+ '',
72
+ ]
73
+
74
+ return { path, contents: `${header.join('\n')}\n${normalized}` }
75
+ }
76
+
77
+ export interface ParsedFile {
78
+ readonly source: string
79
+ readonly declaredHash: string
80
+ readonly body: string
81
+ }
82
+
83
+ export function parseGenerated(path: string, contents: string): ParsedFile | null {
84
+ let unwrap: HeaderSyntax['unwrap']
85
+ try {
86
+ unwrap = headerSyntax(path).unwrap
87
+ } catch {
88
+ return null
89
+ }
90
+ const lines = contents.split('\n')
91
+
92
+ if (unwrap(lines[0] ?? '') !== BANNER) return null
93
+
94
+ const sourceLine = unwrap(lines[1] ?? '')
95
+ const hashLine = unwrap(lines[3] ?? '')
96
+ if (sourceLine === null || !sourceLine.startsWith('Source: ')) return null
97
+ if (hashLine === null || !hashLine.startsWith(`${HASH_LABEL} `)) return null
98
+
99
+ return {
100
+ source: sourceLine.slice('Source: '.length),
101
+ declaredHash: hashLine.slice(HASH_LABEL.length + 1),
102
+ body: lines.slice(HEADER_LINES).join('\n'),
103
+ }
104
+ }
105
+
106
+ export function lines(...parts: readonly (string | readonly string[])[]): string {
107
+ return parts.flat().join('\n').replace(/[ \t]+$/gm, '')
108
+ }
109
+
110
+ export function tsString(value: string): string {
111
+ return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`
112
+ }
113
+
114
+ export function stableJson(value: unknown, indent = 0): string {
115
+ const pad = ' '.repeat(indent)
116
+ const padInner = ' '.repeat(indent + 1)
117
+
118
+ if (value === null) return 'null'
119
+ if (Array.isArray(value)) {
120
+ if (value.length === 0) return '[]'
121
+ return `[\n${value.map((item) => `${padInner}${stableJson(item, indent + 1)}`).join(',\n')}\n${pad}]`
122
+ }
123
+ if (typeof value === 'object') {
124
+ const keys = Object.keys(value as Record<string, unknown>).sort()
125
+ if (keys.length === 0) return '{}'
126
+ return `{\n${keys
127
+ .map((key) => `${padInner}${JSON.stringify(key)}: ${stableJson((value as Record<string, unknown>)[key], indent + 1)}`)
128
+ .join(',\n')}\n${pad}}`
129
+ }
130
+ return JSON.stringify(value)
131
+ }
132
+
133
+ export function pascal(id: string): string {
134
+ return id
135
+ .replace(/^(ent_|fld_|rel_|wf_|role_)/, '')
136
+ .split(/[_.]/)
137
+ .filter(Boolean)
138
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
139
+ .join('')
140
+ }
141
+
142
+ export function camel(id: string): string {
143
+ const p = pascal(id)
144
+ return p.charAt(0).toLowerCase() + p.slice(1)
145
+ }
@@ -12,10 +12,11 @@ import { argv, stderr, stdout } from 'node:process';
12
12
  import { pathToFileURL } from 'node:url';
13
13
  import { marked, wordsIn } from "../../core/asking.js";
14
14
  import { exit, invoked } from "../../core/invoked.js";
15
- import { NAMED } from "../../core/scope.js";
15
+ import { ours } from "../../core/scope.js";
16
16
  import { findTree, inTree } from "../../core/tree.js";
17
17
  import { CHECKED_BY, KINDS, LISTENS, SAID, WRITES, WRITTEN_BY, kindOf } from "../release.js";
18
18
  const TREE = findTree();
19
+ const MANIFEST = 'package.json';
19
20
  const mark = (one) => marked([one.id ?? '', one.at ?? '']);
20
21
  async function releases() {
21
22
  const at = inTree(TREE, 'derivation.ts');
@@ -43,7 +44,7 @@ async function named() {
43
44
  }
44
45
  function scripted() {
45
46
  try {
46
- const held = JSON.parse(readFileSync(join(TREE.at, 'package.json'), 'utf8'));
47
+ const held = JSON.parse(readFileSync(join(TREE.at, MANIFEST), 'utf8'));
47
48
  const scripts = held.scripts ?? {};
48
49
  return {
49
50
  build: scripts['build'] === undefined ? 'npm run build' : 'npm run build',
@@ -112,7 +113,14 @@ async function made() {
112
113
  }
113
114
  return found;
114
115
  }
115
- const SHIPPED = NAMED;
116
+ function installed() {
117
+ const at = join(TREE.at, MANIFEST);
118
+ if (!existsSync(at)) {
119
+ throw new Error(`this app has no ${MANIFEST}, so what a host would install cannot be read`);
120
+ }
121
+ const held = JSON.parse(readFileSync(at, 'utf8'));
122
+ return Object.keys(held.dependencies ?? {}).filter((one) => ours(one)).sort();
123
+ }
116
124
  async function run() {
117
125
  const rows = await releases();
118
126
  if (rows.length === 0) {
@@ -145,12 +153,14 @@ async function run() {
145
153
  }
146
154
  const files = await made();
147
155
  const checking = argv.includes('--check');
148
- for (const [at, text] of files) {
149
- if (text.includes(SHIPPED)) {
150
- stderr.write(`release: ${at} names a \`${SHIPPED}\` module, and what a host runs may hold nothing of this ` +
151
- 'framework. It writes the format that host already reads and ships none of itself. Nothing was written.\n');
152
- return 1;
153
- }
156
+ const shipped = installed();
157
+ if (shipped.length > 0) {
158
+ stderr.write(`release: this app installs ${shipped.join(', ')} as a dependency, and what a host runs may hold ` +
159
+ 'nothing of this framework. A package here is scaffolding: it writes the app, and the bundler folds ' +
160
+ 'whatever the app imports into what it builds, so nothing of it is fetched again where this runs. ' +
161
+ `Install ${shipped.length === 1 ? 'it' : 'them'} with \`npm i -D\` instead, or move ${shipped.length === 1 ? 'it' : 'them'} ` +
162
+ 'to `devDependencies` and run this again. Nothing was written.\n');
163
+ return 1;
154
164
  }
155
165
  if (checking) {
156
166
  for (const [at, text] of files) {
@@ -0,0 +1,213 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { argv, stderr, stdout } from 'node:process'
4
+ import { pathToFileURL } from 'node:url'
5
+
6
+ import { marked, wordsIn } from '../../core/asking.ts'
7
+ import { exit, invoked } from '../../core/invoked.ts'
8
+ import { ours } from '../../core/scope.ts'
9
+ import { findTree, inTree } from '../../core/tree.ts'
10
+
11
+ import { CHECKED_BY, KINDS, LISTENS, SAID, WRITES, WRITTEN_BY, kindOf } from '../release.ts'
12
+
13
+ const TREE = findTree()
14
+
15
+ const MANIFEST = 'package.json'
16
+
17
+ interface Row {
18
+ readonly id?: string
19
+ readonly at?: string
20
+ }
21
+
22
+ const mark = (one: Row): string => marked([one.id ?? '', one.at ?? ''])
23
+
24
+ async function releases(): Promise<readonly Row[]> {
25
+ const at = inTree(TREE, 'derivation.ts')
26
+ if (!existsSync(at)) return []
27
+ try {
28
+ const held = (await import(pathToFileURL(at).href)) as { DERIVATION?: { readonly derives?: readonly Row[] } }
29
+ return held.DERIVATION?.derives ?? []
30
+ } catch {
31
+ return []
32
+ }
33
+ }
34
+
35
+ async function named(): Promise<readonly string[]> {
36
+ const at = inTree(TREE, 'secrets.ts')
37
+ if (!existsSync(at)) return []
38
+ try {
39
+ const held = (await import(pathToFileURL(at).href)) as { SECRETS?: readonly { readonly id: string }[] }
40
+ return (held.SECRETS ?? []).map((one) => one.id.toUpperCase()).sort()
41
+ } catch {
42
+ return []
43
+ }
44
+ }
45
+
46
+ function scripted(): { readonly build: string; readonly start: string } {
47
+ try {
48
+ const held = JSON.parse(readFileSync(join(TREE.at, MANIFEST), 'utf8')) as {
49
+ scripts?: Readonly<Record<string, string>>
50
+ }
51
+ const scripts = held.scripts ?? {}
52
+ return {
53
+ build: scripts['build'] === undefined ? 'npm run build' : 'npm run build',
54
+ start: scripts['start'] === undefined ? 'npm start' : 'npm start',
55
+ }
56
+ } catch {
57
+ return { build: 'npm run build', start: 'npm start' }
58
+ }
59
+ }
60
+
61
+ function dockerfile(build: string, start: string): string {
62
+ return [
63
+ '# GENERATED by thread — do not edit. It is derived from what this app declares.',
64
+ '',
65
+ 'FROM node:22-slim AS build',
66
+ 'WORKDIR /app',
67
+ 'COPY package.json package-lock.json* ./',
68
+ 'RUN npm ci --omit=dev --ignore-scripts || npm install --omit=dev --ignore-scripts',
69
+ 'COPY . .',
70
+ 'RUN npm install --ignore-scripts',
71
+ `RUN ${build}`,
72
+ '',
73
+ 'FROM node:22-slim',
74
+ 'WORKDIR /app',
75
+ 'ENV NODE_ENV=production',
76
+ 'COPY --from=build /app ./',
77
+ `EXPOSE ${String(LISTENS)}`,
78
+ `CMD ${JSON.stringify(start.split(' '))}`,
79
+ '',
80
+ ].join('\n')
81
+ }
82
+
83
+ function compose(id: string, secrets: readonly string[]): string {
84
+ return [
85
+ '# GENERATED by thread — do not edit. It is derived from what this app declares.',
86
+ '',
87
+ 'services:',
88
+ ` ${id}:`,
89
+ ' build: .',
90
+ ' ports:',
91
+ ` - "${String(LISTENS)}:${String(LISTENS)}"`,
92
+ ...(secrets.length === 0
93
+ ? []
94
+ : [
95
+ ' environment:',
96
+
97
+ ...secrets.map((one) => ` ${one}: \${${one}}`),
98
+ ]),
99
+ '',
100
+ ].join('\n')
101
+ }
102
+
103
+ async function made(): Promise<ReadonlyMap<string, string>> {
104
+ const found = new Map<string, string>()
105
+ const rows = await releases()
106
+ const secrets = await named()
107
+ const { build, start } = scripted()
108
+
109
+ for (const one of rows) {
110
+ const id = one.id
111
+ if (id === undefined) continue
112
+ const kind = kindOf(id)
113
+ if (kind === undefined) continue
114
+ if (kind === 'docker') {
115
+ found.set('Dockerfile', dockerfile(build, start))
116
+ found.set('compose.yaml', compose(id, secrets))
117
+ }
118
+ }
119
+ return found
120
+ }
121
+
122
+ function installed(): readonly string[] {
123
+ const at = join(TREE.at, MANIFEST)
124
+ if (!existsSync(at)) {
125
+ throw new Error(`this app has no ${MANIFEST}, so what a host would install cannot be read`)
126
+ }
127
+ const held = JSON.parse(readFileSync(at, 'utf8')) as { dependencies?: Readonly<Record<string, string>> }
128
+ return Object.keys(held.dependencies ?? {}).filter((one) => ours(one)).sort()
129
+ }
130
+
131
+ async function run(): Promise<number> {
132
+ const rows = await releases()
133
+ if (rows.length === 0) {
134
+ stdout.write(
135
+ `release: this app declares no release, so there is nowhere for it to go yet. Declare one with ` +
136
+ `\`thread create release\` — it is ${KINDS.join(' or ')}, and the prefix on its id says which.\n`,
137
+ )
138
+ return 0
139
+ }
140
+
141
+ const words = wordsIn(['read'])
142
+ if (words[0] === 'read') {
143
+ const which = words[1]
144
+ const one = rows.find((row) => mark(row) === which)
145
+ if (one === undefined) {
146
+ stderr.write(
147
+ `release: \`${which ?? '(nothing)'}\` is not a mark this tree issued. This app declares ` +
148
+ `${rows.map((row) => `[${mark(row)}] ${String(row.id)}`).join(', ')} — read one of those, or run ` +
149
+ '`thread release` to write it, or `thread create release` to declare another.\n',
150
+ )
151
+ return 1
152
+ }
153
+
154
+ const kind = kindOf(one.id ?? '')
155
+ stdout.write(`${String(one.id)} — ${kind === undefined ? 'a release of a kind this generator does not recognise' : SAID[kind]}\n`)
156
+ for (const file of kind === undefined ? [] : WRITES[kind]) {
157
+ stdout.write(`\n${file}\n`)
158
+ try {
159
+ stdout.write(readFileSync(join(TREE.at, file), 'utf8'))
160
+ } catch {
161
+ stdout.write('(nothing is there)\n')
162
+ }
163
+ }
164
+ return 0
165
+ }
166
+
167
+ const files = await made()
168
+ const checking = argv.includes('--check')
169
+
170
+ const shipped = installed()
171
+ if (shipped.length > 0) {
172
+ stderr.write(
173
+ `release: this app installs ${shipped.join(', ')} as a dependency, and what a host runs may hold ` +
174
+ 'nothing of this framework. A package here is scaffolding: it writes the app, and the bundler folds ' +
175
+ 'whatever the app imports into what it builds, so nothing of it is fetched again where this runs. ' +
176
+ `Install ${shipped.length === 1 ? 'it' : 'them'} with \`npm i -D\` instead, or move ${shipped.length === 1 ? 'it' : 'them'} ` +
177
+ 'to `devDependencies` and run this again. Nothing was written.\n',
178
+ )
179
+ return 1
180
+ }
181
+
182
+ if (checking) {
183
+ for (const [at, text] of files) {
184
+ let current = ''
185
+ try {
186
+ current = readFileSync(join(TREE.at, at), 'utf8')
187
+ } catch {
188
+ current = ''
189
+ }
190
+ if (current === text) continue
191
+ stderr.write(
192
+ current === ''
193
+ ? `release: ${at} is declared and not on disk. Run \`${WRITTEN_BY}\` and commit what it writes.\n`
194
+ : `release: ${at} is not what the declarations produce. A derived file that differs from its ` +
195
+ `generator is a hand edit wearing a generated name. Run \`${WRITTEN_BY}\` and commit what it writes.\n`,
196
+ )
197
+ return 1
198
+ }
199
+ stdout.write(`release: ${[...files.keys()].join(', ')} ${files.size === 1 ? 'is' : 'are'} what the declarations produce\n`)
200
+ return 0
201
+ }
202
+
203
+ for (const [at, text] of files) writeFileSync(join(TREE.at, at), text, 'utf8')
204
+ stdout.write(
205
+ `release: wrote ${[...files.keys()].join(', ')} — built and started by what this app's own manifest ` +
206
+ `declares, listening on ${String(LISTENS)}, naming ${String((await named()).length)} secret(s) and holding none of them.\n`,
207
+ )
208
+ return 0
209
+ }
210
+
211
+ if (invoked(import.meta.url)) exit(await run())
212
+
213
+ export { CHECKED_BY, WRITES, made }
@@ -0,0 +1,135 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { argv, exit, stdout } from 'node:process'
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ import { RECEIPT, answer, answering, asking, given, guidance, issue, named, only, prompted, refuse, slug, spend, wordsIn, type Asked } from '../../core/asking.ts'
6
+ import { listBounds, withRow } from '../../core/rows.ts'
7
+ import { named as packageOf } from '../../core/scope.ts'
8
+ import { findTree, inTree } from '../../core/tree.ts'
9
+
10
+ import { CHECKED_BY, KINDS, PREFIXES, SAID, WRITES, WRITTEN_BY } from '../release.ts'
11
+
12
+ const TREE = findTree()
13
+
14
+ const SHEET = 'derivation.ts'
15
+
16
+ const KEBAB = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
17
+
18
+ const NAME: Asked = { at: 'name', guide: 'lowercase words joined by dashes' }
19
+
20
+ const KIND: Asked = { at: 'kind', guide: `one of ${KINDS.join(', ')} — the prefix on the id says which` }
21
+
22
+ const ASKS: readonly Asked[] = [NAME, KIND]
23
+
24
+ interface Row {
25
+ readonly id?: string
26
+ readonly at?: string
27
+ readonly describes?: string
28
+ }
29
+
30
+ const at = inTree(TREE, SHEET)
31
+
32
+ if (!existsSync(at)) {
33
+ refuse(
34
+ `there is no ${SHEET} here, so a release has nowhere to go. Run \`npx thread attach ${packageOf('derivation')}\` — ` +
35
+ "the sheet is the package's to write, not yours.",
36
+ )
37
+ }
38
+
39
+ const held = (await import(pathToFileURL(at).href)) as { DERIVATION?: { readonly derives?: readonly Row[] } }
40
+ const already = held.DERIVATION?.derives ?? []
41
+
42
+ const listing = [
43
+ already.length === 0
44
+ ? 'no releases yet, so this app declares nowhere to go.'
45
+ : `${String(already.length)} release(s):`,
46
+ ...already.map((one) => ` ${String(one.id)} — writes ${String(one.at)} — ${String(one.describes)}`),
47
+ '',
48
+ 'what a release is:',
49
+ '',
50
+ ...KINDS.map((one) => ` ${one} → ${PREFIXES[one]}<name>, writing ${WRITES[one].join(' and ')}`),
51
+ ...KINDS.flatMap((one) => ['', ` ${SAID[one]}`]),
52
+ ].join('\n')
53
+
54
+ const words = wordsIn(['new'])
55
+
56
+ const piped = given()
57
+ only(piped, named(ASKS), 'a release')
58
+ const { ask, shut } = asking(piped)
59
+
60
+ const nonce = issue(TREE.at, listing)
61
+
62
+ if (words[0] !== 'new') {
63
+ stdout.write(`${listing}\n\n`)
64
+ stdout.write(` npx thread create release new <name> ${RECEIPT} ${nonce}\n\n`)
65
+ stdout.write(`${answering('release').join('\n')}\n\n`)
66
+ stdout.write(`${guidance(ASKS).join('\n')}\n\n`)
67
+ stdout.write(
68
+ ` ${RECEIPT} is spent by one run and refused if ${SHEET} changed after it was issued — what is\n` +
69
+ ' listed above is what you are writing against, and handing one back says you read it.\n',
70
+ )
71
+ exit(1)
72
+ }
73
+
74
+ spend(TREE.at, listing, argv[argv.indexOf(RECEIPT) + 1], SHEET)
75
+
76
+ const name = slug(piped.length > 0 ? (answer(piped, NAME.at) ?? '') : await ask(NAME.at, prompted(NAME)))
77
+ if (name === '' || !KEBAB.test(name)) {
78
+ shut()
79
+ refuse(`"${name}" cannot be the name of a release — it is written in kebab-case, so main or blue-green.`)
80
+ }
81
+
82
+ const said = piped.length > 0 ? (answer(piped, KIND.at) ?? '') : await ask(KIND.at, prompted(KIND))
83
+ const kind = KINDS.find((one) => one === said)
84
+ if (kind === undefined) {
85
+ shut()
86
+ refuse(`"${said}" is not something this can release to. It is one of ${KINDS.join(', ')}.`)
87
+ }
88
+ shut()
89
+
90
+ const id = `${PREFIXES[kind]}${name.replace(/-/g, '_')}`
91
+ if (already.some((one) => one.id === id)) refuse(`\`${id}\` is already declared in ${SHEET}.`)
92
+
93
+ const text = readFileSync(at, 'utf8')
94
+ const bounds = listBounds(text, 'derives')
95
+ if (bounds === undefined) {
96
+ refuse(
97
+ `${SHEET} states no \`derives\` list on its node, so this app was attached before releases existed. ` +
98
+ `Run \`npx thread attach ${packageOf('derivation')}\` again — it adds the list without touching what is in the sheet.`,
99
+ )
100
+ }
101
+
102
+ const [first, ...rest] = WRITES[kind]
103
+
104
+ const row = [
105
+ ' {',
106
+ ` id: '${id}',`,
107
+ ` at: '${String(first)}',`,
108
+ ` describes: '${SAID[kind].replace(/\\/g, '\\\\').replace(/'/g, "\\'")}',`,
109
+ ' version: 1,',
110
+ ` by: '${WRITTEN_BY}',`,
111
+ ` runs: '${CHECKED_BY}',`,
112
+ ...(rest.length === 0
113
+ ? []
114
+ : [
115
+ ' derives: [',
116
+ ...rest.flatMap((one) => [
117
+ ' {',
118
+ ` id: '${id}_${one.split('.')[0] ?? one}',`,
119
+ ` at: '${one}',`,
120
+ ` describes: 'What runs the image this release builds, named beside it because one row states one address.',`,
121
+ ' version: 1,',
122
+ ` by: '${WRITTEN_BY}',`,
123
+ ` runs: '${CHECKED_BY}',`,
124
+ ' },',
125
+ ]),
126
+ ' ],',
127
+ ]),
128
+ ' },',
129
+ ].join('\n')
130
+
131
+ writeFileSync(at, withRow(text, bounds, row), 'utf8')
132
+
133
+ stdout.write(`create: declared ${id} in ${SHEET} — it writes ${WRITES[kind].join(' and ')}.\n`)
134
+ stdout.write(`create: run \`${WRITTEN_BY}\`, and the gate checks it from here on without a line written for it.\n`)
135
+ exit(0)
@@ -0,0 +1,23 @@
1
+
2
+
3
+ export const PREFIXES = { docker: 'dk_' } as const
4
+
5
+ export const KINDS = ['docker'] as const
6
+
7
+ export const WRITES: Readonly<Record<(typeof KINDS)[number], readonly string[]>> = {
8
+ docker: ['Dockerfile', 'compose.yaml'],
9
+ }
10
+
11
+ export const LISTENS = 3000
12
+
13
+ export const SAID: Readonly<Record<(typeof KINDS)[number], string>> = {
14
+ docker:
15
+ 'A container image built from this app and the compose file that runs it. It is the one that needs no account and no network to be proven, so it is what an app can be taken end to end with before anybody has chosen where it really lives. What it holds is derived: the build and start commands this app already declares, the port its own server listens on, and the name of every secret it says it must be given — the names only, because a value is typed by a person and a row has never held one.',
16
+ }
17
+
18
+ export const kindOf = (id: string): (typeof KINDS)[number] | undefined =>
19
+ KINDS.find((one) => id.startsWith(PREFIXES[one]))
20
+
21
+ export const WRITTEN_BY = 'thread release'
22
+
23
+ export const CHECKED_BY = 'thread release --check'
@@ -1,7 +1,7 @@
1
1
  import { named } from "../core/scope.js";
2
2
  const VERSION = 1;
3
3
  const DESCRIBES = 'The system that states every place this app reaches and every credential it reaches with. A target is a destination and the prefix on its id is how it is reached; what it accepts is entities if it keeps them, calls if it is called over https, and the methods or subcommands it answers to if it is an npm package or a program; what it requires is secrets, and where the reach has an address one of them is it. Nothing else in this app names a host, a connection string or an environment variable, so an operation says which place it needs and the rest is worked out from the declaration.';
4
- const TARGETS = 'Where this app states the places it reaches and what each one accepts. Declare one with `thread create target`, which asks how it is reached and puts the matching prefix on its id — `pg_` for a postgres over the network, `pl_` for one in this process, `zu_` for a store in the browser, `ht_` for something called over https, `sdk_` for an npm package this app installs and calls, `cli_` for a program it runs at a command line — and writes what it accepts and the secrets reaching it needs. A place that keeps things accepts entities; a place that is called accepts the calls declared beside it; a package or a program says in `uses` what it is and in `offers` what it responds to, and takes nothing, because a method name belongs to something outside this tree and `takes` is a demand this tree has to be able to meet. This is the one place the split between what reaches a server and what stays local is written down.';
4
+ const TARGETS = 'Where this app states the places it reaches and what each one accepts. Declare one with `thread create target`, which asks how it is reached and puts the matching prefix on its id — `pg_` for a postgres over the network, `pl_` for one in this process, `zu_` for a store in the browser, `ht_` for something called over https, `mod_` for something this app brings in from outside the repository and calls, `cli_` for a program it runs at a command line — and writes what it accepts and the secrets reaching it needs. A place that keeps things accepts entities; a place that is called accepts the calls declared beside it; a package or a program says in `uses` what it is and in `offers` what it responds to, and takes nothing, because a method name belongs to something outside this tree and `takes` is a demand this tree has to be able to meet. This is the one place the split between what reaches a server and what stays local is written down.';
5
5
  const SECRETS = 'Where this app states every value it must be given and may not hold. A row here is a name and a sentence and never a value: `sec_` for one only a server may ever hold, `pub_` for one that may reach a browser, and that prefix is the whole of what decides where the target requiring it can be reached from. A secret with a `sends` goes into a header on every call; the one without is the address of the target that requires it.';
6
6
  const VAULT = 'What opens a secret\'s value at the moment of use, written once and then yours. It reads the names off _threads/secrets.ts and opens the values sealed beside the tree, refusing any other name; add a secret with `thread create secret`, which is the only thing that ever takes a value. To open values from somewhere else in production, replace what opens them here.';
7
7
  const SECRET = 'A value this app must be given and may never hold — a key, a token, a connection string. The row names it and says what it is for, and there is no field on it for the value, because a row is read by everything and a credential is read by one thing at one moment. The prefix says who may ever hold it: `sec_` a server and nothing else, `pub_` anybody including whoever opens the page. That one letter is what refuses a server credential its way into a browser bundle, so it is on the id rather than in a field somebody could forget.';
@@ -169,7 +169,7 @@ export const ATTACH = {
169
169
  list: 'CONCEPTS',
170
170
  id: 'sdk',
171
171
  why: '`sdk` is no longer a noun beside a target — it is one of the ways a target is reached, and its meaning is ' +
172
- `now \`SAID.sdk\` in \`${named('integrations')}/target\`, beside \`https\` and every other reach, none of which is ` +
172
+ `now \`SAID.module\` in \`${named('integrations')}/target\`, beside \`https\` and every other reach, none of which is ` +
173
173
  'coined here either. It also collided with an app declaring a meta called `sdk`, which coins the word: two ' +
174
174
  'meanings for one word is what `twice-meant` refuses, and it was holding the glossary and the docs red.',
175
175
  },