runlist 0.0.0 → 0.94.1

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 (166) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1114 -6
  3. package/assets/app/app.css +283 -0
  4. package/assets/app/app.mjs +381 -0
  5. package/assets/app/block-editor.mjs +182 -0
  6. package/assets/app/block-model.mjs +52 -0
  7. package/assets/app/document-create.mjs +86 -0
  8. package/assets/app/document-lifecycle.mjs +36 -0
  9. package/assets/app/document-stages.mjs +22 -0
  10. package/assets/app/document-views.mjs +33 -0
  11. package/assets/app/document-yardstick.mjs +258 -0
  12. package/assets/app/editor-navigation.mjs +51 -0
  13. package/assets/app/filing-navigation.mjs +138 -0
  14. package/assets/app/git-changes.mjs +80 -0
  15. package/assets/app/git-commit.mjs +46 -0
  16. package/assets/app/index.html +70 -0
  17. package/assets/app/library-navigation.mjs +93 -0
  18. package/assets/app/outline.mjs +18 -0
  19. package/assets/app/quick-navigation.mjs +52 -0
  20. package/assets/app/record-navigation.mjs +78 -0
  21. package/assets/app/recovery-center.mjs +53 -0
  22. package/assets/app/semantic-search.mjs +14 -0
  23. package/assets/app/settings.mjs +15 -0
  24. package/assets/app/shared.mjs +104 -0
  25. package/assets/app/stage-source.mjs +75 -0
  26. package/assets/app/template-editor.mjs +47 -0
  27. package/assets/app/theme.mjs +25 -0
  28. package/assets/app/transport.mjs +43 -0
  29. package/assets/contracts/source-editing-v1.md +232 -0
  30. package/assets/opencode/plugin.js +162 -0
  31. package/assets/schemas/runlist-record-v1.schema.json +912 -0
  32. package/bin/dotmd.mjs +5 -0
  33. package/bin/runlist.mjs +2714 -0
  34. package/package.json +53 -6
  35. package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
  36. package/plugins/runlist-codex/bin/runlist-hook +86 -0
  37. package/plugins/runlist-codex/hooks/hooks.json +16 -0
  38. package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
  39. package/runlist.config.example.mjs +370 -0
  40. package/scripts/postinstall.mjs +56 -0
  41. package/src/agent-context.mjs +201 -0
  42. package/src/ai.mjs +30 -0
  43. package/src/app-create.mjs +169 -0
  44. package/src/app-filing.mjs +139 -0
  45. package/src/app-git-commits.mjs +48 -0
  46. package/src/app-git.mjs +180 -0
  47. package/src/app-library.mjs +152 -0
  48. package/src/app-lifecycle-worker.mjs +12 -0
  49. package/src/app-lifecycle.mjs +79 -0
  50. package/src/app-records.mjs +134 -0
  51. package/src/app-search.mjs +80 -0
  52. package/src/app-semantic-search.mjs +105 -0
  53. package/src/app-service.mjs +208 -0
  54. package/src/app-yardstick.mjs +269 -0
  55. package/src/app.mjs +102 -0
  56. package/src/atomic-mutation.mjs +1736 -0
  57. package/src/baton.mjs +435 -0
  58. package/src/body-link.mjs +48 -0
  59. package/src/bulk-tag.mjs +174 -0
  60. package/src/check-collapse.mjs +75 -0
  61. package/src/claude-commands.mjs +75 -0
  62. package/src/cli-editor.mjs +8 -0
  63. package/src/code-refs.mjs +545 -0
  64. package/src/codex-integration.mjs +81 -0
  65. package/src/color.mjs +14 -0
  66. package/src/commands.mjs +374 -0
  67. package/src/completions.mjs +93 -0
  68. package/src/config-edit.mjs +606 -0
  69. package/src/config.mjs +705 -0
  70. package/src/decision-action.mjs +58 -0
  71. package/src/decision.mjs +220 -0
  72. package/src/decisions.mjs +787 -0
  73. package/src/deps.mjs +317 -0
  74. package/src/desktop-install.mjs +158 -0
  75. package/src/desktop-runtime.mjs +4 -0
  76. package/src/desktop.mjs +25 -0
  77. package/src/diff.mjs +101 -0
  78. package/src/doctor.mjs +591 -0
  79. package/src/durable-rename.mjs +55 -0
  80. package/src/errors-read.mjs +44 -0
  81. package/src/export.mjs +496 -0
  82. package/src/extractors.mjs +101 -0
  83. package/src/filing.mjs +255 -0
  84. package/src/fix-membership.mjs +134 -0
  85. package/src/fix-refs.mjs +174 -0
  86. package/src/flags.mjs +318 -0
  87. package/src/frontmatter-fix.mjs +195 -0
  88. package/src/frontmatter.mjs +252 -0
  89. package/src/git-commit-filters.mjs +32 -0
  90. package/src/git-commit-protocol.mjs +198 -0
  91. package/src/git-commit-signing.mjs +75 -0
  92. package/src/git-commit-store.mjs +26 -0
  93. package/src/git-commit-support.mjs +16 -0
  94. package/src/git-commit-worker.mjs +32 -0
  95. package/src/git.mjs +909 -0
  96. package/src/glossary-check.mjs +35 -0
  97. package/src/glossary.mjs +243 -0
  98. package/src/graph.mjs +298 -0
  99. package/src/guard.mjs +488 -0
  100. package/src/health.mjs +212 -0
  101. package/src/hints.mjs +129 -0
  102. package/src/host-integration.mjs +339 -0
  103. package/src/hub-membership.mjs +284 -0
  104. package/src/hub.mjs +292 -0
  105. package/src/hud.mjs +436 -0
  106. package/src/index-file.mjs +163 -0
  107. package/src/index.mjs +478 -0
  108. package/src/init.mjs +512 -0
  109. package/src/install.mjs +222 -0
  110. package/src/journal-read.mjs +116 -0
  111. package/src/journal.mjs +472 -0
  112. package/src/lifecycle.mjs +1444 -0
  113. package/src/lint.mjs +386 -0
  114. package/src/managed-path.mjs +192 -0
  115. package/src/markdown-code-spans.mjs +58 -0
  116. package/src/migrate-prompts.mjs +171 -0
  117. package/src/migrate-template.mjs +170 -0
  118. package/src/migrate.mjs +90 -0
  119. package/src/misuse-read.mjs +61 -0
  120. package/src/model-request.mjs +33 -0
  121. package/src/model.mjs +523 -0
  122. package/src/modules.mjs +355 -0
  123. package/src/naming.mjs +55 -0
  124. package/src/native-action.mjs +59 -0
  125. package/src/native-record.mjs +244 -0
  126. package/src/new.mjs +1321 -0
  127. package/src/output-identity.mjs +111 -0
  128. package/src/parent-plan.mjs +58 -0
  129. package/src/parse-cache.mjs +99 -0
  130. package/src/path-containment.mjs +15 -0
  131. package/src/pickup-card.mjs +293 -0
  132. package/src/pickup.mjs +671 -0
  133. package/src/prompt.mjs +25 -0
  134. package/src/prompts.mjs +585 -0
  135. package/src/query.mjs +977 -0
  136. package/src/record-action.mjs +11 -0
  137. package/src/record-create.mjs +82 -0
  138. package/src/reference-planner.mjs +388 -0
  139. package/src/reference-values.mjs +11 -0
  140. package/src/rename.mjs +107 -0
  141. package/src/render.mjs +678 -0
  142. package/src/roadmap.mjs +184 -0
  143. package/src/runlist.mjs +944 -0
  144. package/src/section.mjs +225 -0
  145. package/src/semantic-search-client.mjs +58 -0
  146. package/src/ship.mjs +166 -0
  147. package/src/show.mjs +101 -0
  148. package/src/skill-drift.mjs +98 -0
  149. package/src/source-editor.mjs +491 -0
  150. package/src/stages.mjs +73 -0
  151. package/src/state-migration.mjs +123 -0
  152. package/src/stats.mjs +200 -0
  153. package/src/status-metadata.mjs +87 -0
  154. package/src/statuses.mjs +721 -0
  155. package/src/summary.mjs +74 -0
  156. package/src/surfaces.mjs +28 -0
  157. package/src/sync-status.mjs +316 -0
  158. package/src/template-store.mjs +62 -0
  159. package/src/update.mjs +338 -0
  160. package/src/use.mjs +58 -0
  161. package/src/util.mjs +337 -0
  162. package/src/validate.mjs +794 -0
  163. package/src/watch.mjs +56 -0
  164. package/src/xref.mjs +406 -0
  165. package/src/yardstick-command.mjs +178 -0
  166. package/src/yardstick.mjs +112 -0
package/src/deps.mjs ADDED
@@ -0,0 +1,317 @@
1
+ import path from 'node:path';
2
+ import { buildGraph } from './graph.mjs';
3
+ import { buildIndex, resolveDocArg } from './index.mjs';
4
+ import { toRepoPath, die } from './util.mjs';
5
+ import { bold, dim, green } from './color.mjs';
6
+
7
+ export function runDeps(argv, config) {
8
+ const positional = [];
9
+ let json = false;
10
+ let maxDepth = 5;
11
+
12
+ for (let i = 0; i < argv.length; i++) {
13
+ if (argv[i] === '--json') { json = true; continue; }
14
+ if (argv[i] === '--depth' && argv[i + 1]) { maxDepth = Number.parseInt(argv[++i], 10) || 5; continue; }
15
+ if (argv[i] === '--config') { i++; continue; }
16
+ if (argv[i].startsWith('-')) continue;
17
+ positional.push(argv[i]);
18
+ }
19
+
20
+ const index = buildIndex(config);
21
+ const graph = buildGraph(index, config);
22
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
23
+
24
+ // Build adjacency maps
25
+ const forwardMap = new Map(); // source → [{target, field, broken}]
26
+ const reverseMap = new Map(); // target → [{source, field}]
27
+
28
+ for (const edge of graph.edges) {
29
+ if (!forwardMap.has(edge.source)) forwardMap.set(edge.source, []);
30
+ forwardMap.get(edge.source).push({ target: edge.target, field: edge.field, broken: edge.broken });
31
+ if (!edge.broken) {
32
+ if (!reverseMap.has(edge.target)) reverseMap.set(edge.target, []);
33
+ reverseMap.get(edge.target).push({ source: edge.source, field: edge.field });
34
+ }
35
+ }
36
+
37
+ const input = positional[0];
38
+
39
+ if (input) {
40
+ // Tree view for a specific doc
41
+ const filePath = resolveDocArg(input, config);
42
+ const repoPath = toRepoPath(filePath, config.repoRoot);
43
+ const doc = docByPath.get(repoPath);
44
+ if (!doc) die(`Doc not in index: ${repoPath}`);
45
+
46
+ if (json) {
47
+ renderTreeJson(doc, forwardMap, reverseMap, docByPath, maxDepth);
48
+ } else {
49
+ renderTree(doc, forwardMap, reverseMap, docByPath, maxDepth);
50
+ }
51
+ } else {
52
+ // Flat overview
53
+ if (json) {
54
+ renderFlatJson(graph, forwardMap, reverseMap, docByPath);
55
+ } else {
56
+ renderFlat(graph, forwardMap, reverseMap, docByPath);
57
+ }
58
+ }
59
+ }
60
+
61
+ // ── Tree view ──────────────────────────────────────────────────────────
62
+
63
+ function renderTree(doc, forwardMap, reverseMap, docByPath, maxDepth) {
64
+ const slug = path.basename(doc.path, '.md');
65
+ process.stdout.write(`${bold(slug)} ${dim(`(${doc.status})`)}\n`);
66
+
67
+ const forward = forwardMap.get(doc.path) || [];
68
+ const reverse = reverseMap.get(doc.path) || [];
69
+
70
+ if (forward.length > 0) {
71
+ process.stdout.write(`\n${bold('Depends on:')}\n`);
72
+ for (const edge of forward) {
73
+ printTreeNode(edge.target, edge.field, edge.broken, forwardMap, docByPath, maxDepth, 1, new Set([doc.path]));
74
+ }
75
+ }
76
+
77
+ if (reverse.length > 0) {
78
+ process.stdout.write(`\n${bold('Depended on by:')}\n`);
79
+ for (const edge of reverse) {
80
+ const targetSlug = path.basename(edge.source, '.md');
81
+ const targetDoc = docByPath.get(edge.source);
82
+ const status = targetDoc?.status ?? 'unknown';
83
+ process.stdout.write(` ${targetSlug} ${dim(`(${status})`)} ${dim(`via ${edge.field}`)}\n`);
84
+ }
85
+ }
86
+
87
+ if (doc.blockers?.length > 0) {
88
+ process.stdout.write(`\n${bold('Blockers:')}\n`);
89
+ for (const b of doc.blockers) {
90
+ process.stdout.write(` - ${b}\n`);
91
+ }
92
+ }
93
+
94
+ if (forward.length === 0 && reverse.length === 0 && !doc.blockers?.length) {
95
+ process.stdout.write(`\n${dim('No dependencies found.')}\n`);
96
+ }
97
+
98
+ process.stdout.write('\n');
99
+ }
100
+
101
+ function printTreeNode(targetPath, field, broken, forwardMap, docByPath, maxDepth, depth, visited) {
102
+ const indent = ' '.repeat(depth);
103
+ const targetSlug = path.basename(targetPath, '.md');
104
+ const targetDoc = docByPath.get(targetPath);
105
+ const status = targetDoc?.status ?? 'unknown';
106
+
107
+ let suffix = dim(` via ${field}`);
108
+ if (broken) suffix += ' ' + dim('[broken]');
109
+ if (visited.has(targetPath)) {
110
+ process.stdout.write(`${indent}${targetSlug} ${dim(`(${status})`)}${suffix} ${dim('[cycle]')}\n`);
111
+ return;
112
+ }
113
+
114
+ process.stdout.write(`${indent}${targetSlug} ${dim(`(${status})`)}${suffix}\n`);
115
+
116
+ if (depth >= maxDepth) return;
117
+
118
+ const children = forwardMap.get(targetPath) || [];
119
+ const nextVisited = new Set(visited);
120
+ nextVisited.add(targetPath);
121
+ for (const child of children) {
122
+ printTreeNode(child.target, child.field, child.broken, forwardMap, docByPath, maxDepth, depth + 1, nextVisited);
123
+ }
124
+ }
125
+
126
+ function renderTreeJson(doc, forwardMap, reverseMap, docByPath, maxDepth) {
127
+ const slug = path.basename(doc.path, '.md');
128
+
129
+ function walkForward(docPath, depth, visited) {
130
+ const edges = forwardMap.get(docPath) || [];
131
+ return edges.map(e => {
132
+ const d = docByPath.get(e.target);
133
+ const node = {
134
+ path: e.target,
135
+ slug: path.basename(e.target, '.md'),
136
+ status: d?.status ?? 'unknown',
137
+ field: e.field,
138
+ broken: e.broken || false,
139
+ cycle: visited.has(e.target),
140
+ };
141
+ if (!node.cycle && !node.broken && depth < maxDepth) {
142
+ const next = new Set(visited);
143
+ next.add(e.target);
144
+ node.dependsOn = walkForward(e.target, depth + 1, next);
145
+ }
146
+ return node;
147
+ });
148
+ }
149
+
150
+ const result = {
151
+ path: doc.path,
152
+ slug,
153
+ status: doc.status,
154
+ dependsOn: walkForward(doc.path, 1, new Set([doc.path])),
155
+ dependedOnBy: (reverseMap.get(doc.path) || []).map(e => {
156
+ const d = docByPath.get(e.source);
157
+ return { path: e.source, slug: path.basename(e.source, '.md'), status: d?.status ?? 'unknown', field: e.field };
158
+ }),
159
+ blockers: doc.blockers || [],
160
+ };
161
+
162
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
163
+ }
164
+
165
+ // ── Flat overview ──────────────────────────────────────────────────────
166
+
167
+ function renderFlat(graph, forwardMap, reverseMap, docByPath) {
168
+ process.stdout.write(bold('Deps') + dim(` — ${graph.stats.nodeCount} docs, ${graph.stats.edgeCount} edges`) + '\n\n');
169
+
170
+ if (graph.stats.edgeCount === 0) {
171
+ process.stdout.write(dim('No dependencies found. Add referenceFields to your config.') + '\n');
172
+ return;
173
+ }
174
+
175
+ // Most blocking: nodes with the most reverse edges
176
+ const blockingCounts = [...reverseMap.entries()]
177
+ .map(([p, edges]) => ({ path: p, count: edges.length }))
178
+ .sort((a, b) => b.count - a.count)
179
+ .slice(0, 10);
180
+
181
+ if (blockingCounts.length > 0) {
182
+ process.stdout.write(bold('Most blocking') + dim(' (depended on by the most docs):') + '\n');
183
+ for (const { path: p, count } of blockingCounts) {
184
+ const doc = docByPath.get(p);
185
+ const slug = path.basename(p, '.md').padEnd(24);
186
+ process.stdout.write(` ${slug} ${dim(`(${doc?.status ?? '?'})`)} blocks ${count}\n`);
187
+ }
188
+ process.stdout.write('\n');
189
+ }
190
+
191
+ // Most blocked: nodes with the most forward edges
192
+ const blockedCounts = [...forwardMap.entries()]
193
+ .map(([p, edges]) => ({ path: p, count: edges.filter(e => !e.broken).length }))
194
+ .filter(e => e.count > 0)
195
+ .sort((a, b) => b.count - a.count)
196
+ .slice(0, 10);
197
+
198
+ if (blockedCounts.length > 0) {
199
+ process.stdout.write(bold('Most blocked') + dim(' (depends on the most docs):') + '\n');
200
+ for (const { path: p, count } of blockedCounts) {
201
+ const doc = docByPath.get(p);
202
+ const slug = path.basename(p, '.md').padEnd(24);
203
+ process.stdout.write(` ${slug} ${dim(`(${doc?.status ?? '?'})`)} depends on ${count}\n`);
204
+ }
205
+ process.stdout.write('\n');
206
+ }
207
+
208
+ // Docs with blockers
209
+ const withBlockers = [...docByPath.values()].filter(d => d.blockers?.length > 0);
210
+ if (withBlockers.length > 0) {
211
+ process.stdout.write(bold('With blockers:') + '\n');
212
+ for (const doc of withBlockers) {
213
+ const slug = path.basename(doc.path, '.md');
214
+ process.stdout.write(` ${slug}: ${doc.blockers.join('; ')}\n`);
215
+ }
216
+ process.stdout.write('\n');
217
+ }
218
+
219
+ // Orphans
220
+ if (graph.orphans.length > 0) {
221
+ const orphanSlugs = graph.orphans.map(p => path.basename(p, '.md'));
222
+ process.stdout.write(`${dim('Orphans (no dependencies)')}: ${orphanSlugs.join(', ')}\n`);
223
+ process.stdout.write('\n');
224
+ }
225
+ }
226
+
227
+ function renderFlatJson(graph, forwardMap, reverseMap, docByPath) {
228
+ const blocking = [...reverseMap.entries()]
229
+ .map(([p, edges]) => ({ path: p, slug: path.basename(p, '.md'), blocksCount: edges.length }))
230
+ .sort((a, b) => b.blocksCount - a.blocksCount);
231
+
232
+ const blocked = [...forwardMap.entries()]
233
+ .map(([p, edges]) => ({ path: p, slug: path.basename(p, '.md'), dependsOnCount: edges.filter(e => !e.broken).length }))
234
+ .filter(e => e.dependsOnCount > 0)
235
+ .sort((a, b) => b.dependsOnCount - a.dependsOnCount);
236
+
237
+ const withBlockers = [...docByPath.values()]
238
+ .filter(d => d.blockers?.length > 0)
239
+ .map(d => ({ path: d.path, slug: path.basename(d.path, '.md'), blockers: d.blockers }));
240
+
241
+ process.stdout.write(JSON.stringify({
242
+ stats: graph.stats,
243
+ mostBlocking: blocking,
244
+ mostBlocked: blocked,
245
+ withBlockers,
246
+ orphans: graph.orphans,
247
+ }, null, 2) + '\n');
248
+ }
249
+
250
+ // ── Unblocks ─────────────────────────────────────────────────────────
251
+
252
+ export function runUnblocks(argv, config) {
253
+ const input = argv.find(a => !a.startsWith('-'));
254
+ const json = argv.includes('--json');
255
+ if (!input) die('Usage: runlist unblocks <file>');
256
+
257
+ const filePath = resolveDocArg(input, config);
258
+ const repoPath = toRepoPath(filePath, config.repoRoot);
259
+
260
+ const index = buildIndex(config);
261
+ const graph = buildGraph(index, config);
262
+ const docByPath = new Map(index.docs.map(d => [d.path, d]));
263
+ const doc = docByPath.get(repoPath);
264
+ if (!doc) die(`Doc not in index: ${repoPath}`);
265
+
266
+ // Find docs that reference this one (reverse edges)
267
+ const reverseMap = new Map();
268
+ for (const edge of graph.edges) {
269
+ if (!edge.broken) {
270
+ if (!reverseMap.has(edge.target)) reverseMap.set(edge.target, []);
271
+ reverseMap.get(edge.target).push({ source: edge.source, field: edge.field });
272
+ }
273
+ }
274
+
275
+ // Also find docs with blockers mentioning this file's basename
276
+ const basename = path.basename(repoPath, '.md');
277
+ const blockerRefs = index.docs.filter(d =>
278
+ d.blockers?.some(b => b.includes(basename) || b.includes(path.basename(repoPath)))
279
+ );
280
+
281
+ const directDeps = (reverseMap.get(repoPath) || []).map(e => {
282
+ const d = docByPath.get(e.source);
283
+ return { path: e.source, slug: path.basename(e.source, '.md'), status: d?.status, field: e.field };
284
+ });
285
+
286
+ const blockerDeps = blockerRefs
287
+ .filter(d => d.path !== repoPath)
288
+ .map(d => ({ path: d.path, slug: path.basename(d.path, '.md'), status: d.status, blockers: d.blockers }));
289
+
290
+ if (json) {
291
+ process.stdout.write(JSON.stringify({ doc: repoPath, directDeps, blockerDeps }, null, 2) + '\n');
292
+ return;
293
+ }
294
+
295
+ const slug = path.basename(repoPath, '.md');
296
+ process.stdout.write(`${bold('Unblocks')} — what happens when ${green(slug)} completes:\n\n`);
297
+
298
+ if (directDeps.length > 0) {
299
+ process.stdout.write(bold('Referenced by:') + '\n');
300
+ for (const d of directDeps) {
301
+ process.stdout.write(` ${d.slug.padEnd(24)} ${dim(`(${d.status})`)} via ${dim(d.field)}\n`);
302
+ }
303
+ process.stdout.write('\n');
304
+ }
305
+
306
+ if (blockerDeps.length > 0) {
307
+ process.stdout.write(bold('Listed as blocker in:') + '\n');
308
+ for (const d of blockerDeps) {
309
+ process.stdout.write(` ${d.slug.padEnd(24)} ${dim(`(${d.status})`)}\n`);
310
+ }
311
+ process.stdout.write('\n');
312
+ }
313
+
314
+ if (directDeps.length === 0 && blockerDeps.length === 0) {
315
+ process.stdout.write(dim('No docs depend on or are blocked by this file.') + '\n');
316
+ }
317
+ }
@@ -0,0 +1,158 @@
1
+ import {createHash,randomUUID} from 'node:crypto';
2
+ import {execFileSync} from 'node:child_process';
3
+ import {createWriteStream} from 'node:fs';
4
+ import {lstat,mkdir,mkdtemp,rename,rm} from 'node:fs/promises';
5
+ import {homedir,tmpdir} from 'node:os';
6
+ import path from 'node:path';
7
+ import {Readable,Transform} from 'node:stream';
8
+ import {pipeline} from 'node:stream/promises';
9
+ import {desktopLaunch} from './desktop.mjs';
10
+
11
+ const RELEASES='https://api.github.com/repos/reowens/runlist/releases';
12
+ const DOWNLOADS='https://github.com/reowens/runlist/releases/download/';
13
+ const TEAM='7GSPYYN5X8';
14
+ const MAX_DOWNLOAD=512*1024*1024;
15
+ const nativeExec=(exe,args)=>{
16
+ try {return {stdout:execFileSync(exe,args,{encoding:'utf8',timeout:60000,stdio:['ignore','pipe','pipe']})};}
17
+ catch(error) {throw new Error(`${path.basename(exe)} failed: ${(error.stderr?.toString()||error.message).trim()}`,{cause:error});}
18
+ };
19
+
20
+ export function selectDesktopRelease(releases) {
21
+ if(!Array.isArray(releases))throw new Error('Runlist release information is unavailable.');
22
+ for(const release of releases) {
23
+ if(release.draft)continue;
24
+ for(const asset of release.assets??[]) {
25
+ const match=/^Runlist-(\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?)-macOS-arm64\.dmg$/.exec(asset.name??'');
26
+ if(!match||asset.state!=='uploaded')continue;
27
+ const url=new URL(asset.browser_download_url);
28
+ if(!url.href.startsWith(DOWNLOADS)||url.username||url.password||url.search||url.hash||!url.pathname.endsWith('/'+asset.name))throw new Error('The Runlist installer download address is invalid.');
29
+ if(!Number.isSafeInteger(asset.size)||asset.size<=0||asset.size>MAX_DOWNLOAD)throw new Error('The Runlist installer size is invalid.');
30
+ const checksum=/^sha256:([a-f0-9]{64})$/i.exec(asset.digest??'')?.[1].toLowerCase();
31
+ if(!checksum)throw new Error('The published Runlist installer is missing its SHA-256 digest.');
32
+ return {url:url.href,version:match[1],size:asset.size,sha256:checksum};
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ async function latestInstaller(fetcher) {
39
+ for(let page=1;page<=3;page++) {
40
+ const response=await fetcher(`${RELEASES}?per_page=100&page=${page}`,{
41
+ headers:{Accept:'application/vnd.github+json','X-GitHub-Api-Version':'2022-11-28','User-Agent':'runlist-desktop-install'},
42
+ signal:AbortSignal.timeout(15000),
43
+ });
44
+ if(!response.ok)throw new Error(`Cannot download Runlist release information (HTTP ${response.status}). Try again when online, or use runlist desktop install --from /path/to/installer.dmg.`);
45
+ let bytes=0;const chunks=[];
46
+ for await(const chunk of response.body) {
47
+ bytes+=chunk.length;if(bytes>4*1024*1024)throw new Error('Runlist release information is too large.');chunks.push(chunk);
48
+ }
49
+ const releases=JSON.parse(Buffer.concat(chunks).toString('utf8'));
50
+ const installer=selectDesktopRelease(releases);if(installer)return installer;
51
+ if(releases.length<100)break;
52
+ }
53
+ throw new Error('No Mac GUI installer has been published to Runlist releases yet. Use runlist desktop install --from /path/to/installer.dmg with a signed Runlist installer.');
54
+ }
55
+
56
+ async function download(installer,file,fetcher) {
57
+ const response=await fetcher(installer.url,{signal:AbortSignal.timeout(120000)});
58
+ if(!response.ok||!response.body)throw new Error(`Cannot download the Runlist installer (HTTP ${response.status}).`);
59
+ let bytes=0;const hash=createHash('sha256');
60
+ const guard=new Transform({transform(chunk,encoding,callback){
61
+ bytes+=chunk.length;
62
+ if(bytes>MAX_DOWNLOAD||bytes>installer.size){callback(new Error('Runlist installer download exceeded its published size.'));return;}
63
+ hash.update(chunk);callback(null,chunk);
64
+ }});
65
+ await pipeline(Readable.fromWeb(response.body),guard,createWriteStream(file,{flags:'wx',mode:0o600}));
66
+ if(bytes!==installer.size||hash.digest('hex')!==installer.sha256)throw new Error('Runlist installer download did not match its published SHA-256 digest. Nothing was installed.');
67
+ }
68
+
69
+ async function entry(file) {
70
+ try{return await lstat(file);}catch(error){if(error.code==='ENOENT')return null;throw error;}
71
+ }
72
+
73
+ function verifySigned(file,exec,identifier) {
74
+ exec('/usr/bin/codesign',['--verify',...(identifier?['--deep']:[]),'--strict',file]);
75
+ // spawnSync is used here rather than execFileSync to retain stderr on success.
76
+ const description=exec('/usr/bin/codesign',['-dvv',file]);
77
+ const text=(description.stderr??'')+'\n'+(description.stdout??'');
78
+ if(!text.includes(`TeamIdentifier=${TEAM}\n`)||(identifier&&!text.includes(`Identifier=${identifier}\n`)))throw new Error('This installer is not signed by the Runlist developer. Nothing was installed.');
79
+ exec('/usr/sbin/spctl',identifier
80
+ ?['--assess','--type','execute','--verbose=2',file]
81
+ :['--assess','--type','open','--context','context:primary-signature','--verbose=2',file]);
82
+ }
83
+
84
+ function requireStopped(exec) {
85
+ const processes=exec('/bin/ps',['-axo','comm=']).stdout;
86
+ if(processes.split('\n').some(line=>line.trim()==='Runlist'||line.trim().endsWith('/Runlist.app/Contents/MacOS/Runlist')))throw new Error('Quit Runlist, then run this install command again. Your current app was kept.');
87
+ }
88
+
89
+ export async function runDesktopInstall(argv,{dryRun=false,platform=process.platform,arch=process.arch,home=homedir(),temporary=tmpdir(),fetcher=globalThis.fetch,exec,write=message=>process.stdout.write(message)}={}) {
90
+ if(platform!=='darwin'||arch!=='arm64')throw new Error('CLI GUI installation currently supports Mac Apple Silicon only. Windows, Linux and Intel Mac downloads are not available yet.');
91
+ const fromAt=argv.indexOf('--from');
92
+ const from=fromAt<0?null:argv[fromAt+1];
93
+ if(fromAt>=0&&(!from||from.startsWith('--')))throw new Error('--from requires a local Runlist installer.dmg path.');
94
+ if(dryRun){write(`Would install the optional Runlist GUI from ${from?path.resolve(from):'Runlist GitHub releases'} without opening it. No download or files changed.\n`);return {dryRun:true};}
95
+ const installed=desktopLaunch([],{platform,home}).app;
96
+ if(installed&&!from){write(`Runlist is already installed at ${installed}. Open it with runlist desktop.\n`);return {installed,alreadyInstalled:true};}
97
+ if(!exec) {
98
+ // Imported only for this explicit installer; no subprocess or download at import time.
99
+ const {spawnSync}=await import('node:child_process');
100
+ exec=(exe,args)=>{
101
+ if(exe!=='/usr/bin/codesign'||args[0]!=='-dvv')return nativeExec(exe,args);
102
+ const result=spawnSync(exe,args,{encoding:'utf8',timeout:30000,stdio:['ignore','pipe','pipe']});
103
+ if(result.error||result.status!==0)throw new Error('Cannot inspect the Runlist developer signature.',{cause:result.error});
104
+ return result;
105
+ };
106
+ }
107
+ const target=installed??path.join(home,'Applications/Runlist.app');
108
+ const previous=await entry(target);
109
+ if(previous&&(previous.isSymbolicLink()||!previous.isDirectory()))throw new Error('The Runlist installation path is not a regular application folder. Nothing was changed.');
110
+ if(installed)requireStopped(exec);
111
+ const parent=path.dirname(target);await mkdir(parent,{recursive:true});
112
+ const lock=path.join(parent,'.runlist-desktop-install.lock');
113
+ try{await mkdir(lock);}catch(error){if(error.code==='EEXIST')throw new Error(`Another desktop installation is in progress. If an earlier install was interrupted, remove ${lock} after confirming it has stopped.`);throw error;}
114
+ let work,mount,attached=false,stage,backup;
115
+ try {
116
+ work=await mkdtemp(path.join(temporary,'runlist-desktop-'));
117
+ let image;
118
+ if(from) {
119
+ image=path.resolve(from);const info=await entry(image);
120
+ if(!image.toLowerCase().endsWith('.dmg')||!info?.isFile()||info.isSymbolicLink()||info.size>MAX_DOWNLOAD)throw new Error('--from must name a regular signed Runlist .dmg installer.');
121
+ } else {
122
+ const installer=await latestInstaller(fetcher);write(`Downloading Runlist ${installer.version} for Mac Apple Silicon…\n`);
123
+ image=path.join(work,'Runlist.dmg');await download(installer,image,fetcher);
124
+ }
125
+ verifySigned(image,exec);
126
+ mount=path.join(work,'volume');await mkdir(mount);attached=true;
127
+ exec('/usr/bin/hdiutil',['attach',image,'-readonly','-nobrowse','-noautoopen','-mountpoint',mount]);
128
+ const source=path.join(mount,'Runlist.app');const info=await entry(source);
129
+ if(!info?.isDirectory()||info.isSymbolicLink())throw new Error('The installer does not contain a regular Runlist.app.');
130
+ verifySigned(source,exec,'dev.reowens.runlist');
131
+ stage=path.join(parent,`.Runlist-install-${randomUUID()}.app`);
132
+ exec('/usr/bin/ditto',[source,stage]);
133
+ // Keep normal Mac download identification instead of stripping quarantine.
134
+ const quarantine=`0083;${Math.floor(Date.now()/1000).toString(16)};Runlist;${randomUUID()}`;
135
+ exec('/usr/bin/xattr',['-w','com.apple.quarantine',quarantine,stage]);
136
+ verifySigned(stage,exec,'dev.reowens.runlist');
137
+ const current=await entry(target);
138
+ if(Boolean(previous)!==Boolean(current)||(previous&&(current.dev!==previous.dev||current.ino!==previous.ino)))throw new Error('The installed app changed during installation. Nothing was replaced.');
139
+ if(previous) {
140
+ requireStopped(exec);
141
+ const backups=path.join(parent,'.Runlist-backups');await mkdir(backups,{recursive:true});
142
+ backup=path.join(backups,`Runlist-${randomUUID()}.app`);await rename(target,backup);
143
+ }
144
+ try{await rename(stage,target);stage=null;}
145
+ catch(error){if(backup)await rename(backup,target);throw error;}
146
+ write(`Installed Runlist at ${target}. Open it with runlist desktop.\n`);
147
+ if(backup)write(`Previous app kept at ${backup}.\n`);
148
+ return {installed:target,backup:backup??null};
149
+ } finally {
150
+ if(stage)await rm(stage,{recursive:true,force:true});
151
+ if(attached) {
152
+ try{exec('/usr/bin/hdiutil',['detach',mount]);attached=false;}
153
+ catch{write(`Could not unmount the temporary installer at ${mount}; it was left in place.\n`);}
154
+ }
155
+ if(work&&!attached)await rm(work,{recursive:true,force:true});
156
+ await rm(lock,{recursive:true,force:true});
157
+ }
158
+ }
@@ -0,0 +1,4 @@
1
+ import path from 'node:path';
2
+ export function isBundledRunlistRuntime(executable,paths=path) {
3
+ return /^RunlistHelper(?:\.exe)?$/i.test(paths.basename(executable));
4
+ }
@@ -0,0 +1,25 @@
1
+ import {existsSync} from 'node:fs';
2
+ import {execFileSync,spawn} from 'node:child_process';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ // This is a launch plan only; it never reads checkout configuration.
6
+ export function desktopLaunch(argv,{platform=process.platform,env=process.env,home=os.homedir(),exists=existsSync}={}) {
7
+ const paths=platform==='win32'?path.win32:path.posix;
8
+ const at=argv.indexOf('--app');
9
+ if(at>=0&&(!argv[at+1]||argv[at+1].startsWith('--')))throw new Error('--app requires the installed application path.');
10
+ let candidates;
11
+ if(platform==='darwin')candidates=['/Applications/Runlist.app',paths.join(home,'Applications/Runlist.app')];
12
+ else if(platform==='win32')candidates=[env.LOCALAPPDATA,env.ProgramFiles,env['ProgramFiles(x86)']].filter(Boolean).map(root=>paths.join(root,'Runlist','Runlist.exe'));
13
+ else if(platform==='linux')candidates=['/usr/bin/Runlist','/usr/bin/runlist','/usr/local/bin/Runlist','/usr/local/bin/runlist'];
14
+ else throw new Error('Runlist desktop packaging is available for macOS, Windows and Linux.');
15
+ const app=at>=0?paths.resolve(argv[at+1]):candidates.find(exists);
16
+ return {platform,app,program:platform==='darwin'?'/usr/bin/open':app,args:platform==='darwin'&&app?['-a',app]:[]};
17
+ }
18
+ export function runDesktop(argv,{dryRun=false}={}) {
19
+ const launch=desktopLaunch(argv);
20
+ if(dryRun){process.stdout.write(`Would open ${launch.app??'the installed Runlist application'}${launch.platform==='darwin'?' through macOS LaunchServices':''}. Choose a trusted checkout in the app. No server starts.\n`);return;}
21
+ if(!launch.app||!existsSync(launch.app))throw new Error('Runlist is not installed. Run runlist desktop install, or use runlist desktop --app /path/to/the/application.');
22
+ if(launch.platform==='darwin')execFileSync(launch.program,launch.args,{stdio:'ignore'});
23
+ else {const child=spawn(launch.program,launch.args,{detached:true,stdio:'ignore',windowsHide:true,shell:false});child.on('error',error=>{process.stderr.write(`Cannot launch Runlist: ${error.message}\n`);process.exitCode=1;});child.once('spawn',()=>{process.stdout.write('Opened Runlist. Choose a checkout folder in the application. Local files; no listening server.\n');child.unref();});return;}
24
+ process.stdout.write('Opened Runlist. Choose a checkout folder in the application. Local files; no listening server.\n');
25
+ }
package/src/diff.mjs ADDED
@@ -0,0 +1,101 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
3
+ import { asString, toRepoPath, die, warn } from './util.mjs';
4
+ import { gitDiffSince } from './git.mjs';
5
+ import { buildIndex, resolveDocArg } from './index.mjs';
6
+ import { summarizeDiffText } from './ai.mjs';
7
+ import { bold, dim } from './color.mjs';
8
+
9
+ export function runDiff(argv, config) {
10
+ // Parse flags
11
+ let file = null;
12
+ let stat = false;
13
+ let sinceOverride = null;
14
+ let summarize = false;
15
+ let model;
16
+
17
+ for (let i = 0; i < argv.length; i++) {
18
+ if (argv[i] === '--stat') { stat = true; continue; }
19
+ if (argv[i] === '--summarize') { summarize = true; continue; }
20
+ if (argv[i] === '--since' && argv[i + 1]) { sinceOverride = argv[++i]; continue; }
21
+ if (argv[i] === '--model' && argv[i + 1]) { model = argv[++i]; continue; }
22
+ if (!argv[i].startsWith('-')) { file = argv[i]; }
23
+ }
24
+
25
+ if (file) {
26
+ // Single file mode
27
+ const filePath = resolveDocArg(file, config);
28
+
29
+ const raw = readFileSync(filePath, 'utf8');
30
+ const { frontmatter } = extractFrontmatter(raw);
31
+ const parsed = parseSimpleFrontmatter(frontmatter);
32
+ const since = sinceOverride ?? asString(parsed.updated);
33
+
34
+ if (!since) {
35
+ die(`No updated date found in ${file} and no --since provided.`);
36
+ }
37
+
38
+ const relPath = toRepoPath(filePath, config.repoRoot);
39
+ const diffOutput = gitDiffSince(relPath, since, config.repoRoot, { stat });
40
+
41
+ if (!diffOutput) {
42
+ process.stdout.write(`No changes since ${since} for ${relPath}\n`);
43
+ return;
44
+ }
45
+
46
+ printFileDiff(relPath, since, diffOutput, { summarize, model, config });
47
+ } else {
48
+ // All drifted docs mode
49
+ const index = buildIndex(config);
50
+ const drifted = [];
51
+
52
+ for (const doc of index.docs) {
53
+ if (!doc.updated) continue;
54
+ const relPath = doc.path;
55
+ const since = sinceOverride ?? doc.updated;
56
+ const diffOutput = gitDiffSince(relPath, since, config.repoRoot, { stat });
57
+ if (diffOutput && diffOutput.trim()) {
58
+ drifted.push({ relPath, since, diffOutput });
59
+ }
60
+ }
61
+
62
+ if (drifted.length === 0) {
63
+ process.stdout.write('No drifted docs.\n');
64
+ return;
65
+ }
66
+
67
+ process.stdout.write(bold(`${drifted.length} doc(s) with changes since their updated date:\n\n`));
68
+ for (const { relPath, since, diffOutput } of drifted) {
69
+ printFileDiff(relPath, since, diffOutput, { summarize, model, config });
70
+ }
71
+ }
72
+ }
73
+
74
+ function printFileDiff(relPath, since, diffOutput, opts) {
75
+ process.stdout.write(bold(relPath) + dim(` (updated: ${since})`) + '\n');
76
+
77
+ if (opts.summarize) {
78
+ const previewSkipped = Boolean(opts.config?._execution?.suppressSideEffects);
79
+ let summary;
80
+ try {
81
+ summary = previewSkipped
82
+ ? null
83
+ : opts.config?.hooks?.summarizeDiff
84
+ ? opts.config.hooks.summarizeDiff(diffOutput, relPath)
85
+ : summarizeDiffText(diffOutput, relPath, opts.model);
86
+ } catch (err) {
87
+ warn(`Hook 'summarizeDiff' threw: ${err.message}`);
88
+ summary = null;
89
+ }
90
+ if (previewSkipped) {
91
+ process.stdout.write(dim(' [preview] Summary generation skipped; models and custom summarizeDiff hooks are not invoked.') + '\n');
92
+ } else if (summary) {
93
+ process.stdout.write(dim(` Summary: ${summary}`) + '\n');
94
+ } else {
95
+ warn(' Summary unavailable (model call failed)');
96
+ }
97
+ }
98
+
99
+ process.stdout.write(diffOutput);
100
+ process.stdout.write('\n');
101
+ }