@davmash/bygone 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -2
- package/README.md +64 -22
- package/bin/bygone.js +72 -38
- package/cli/commandSpec.js +178 -0
- package/cli/completions.js +321 -0
- package/cli/present.js +166 -0
- package/cli/tour.js +189 -0
- package/cli/tourFile.js +26 -0
- package/completions/_bygone +131 -0
- package/completions/bygone +83 -0
- package/completions/bygone.fish +34 -0
- package/docs/change-explanation-plan.md +358 -0
- package/docs/change-tour-format.md +62 -0
- package/docs/generating-change-tours.md +75 -0
- package/docs/releasing.md +342 -0
- package/docs/walkthrough.md +103 -0
- package/examples/bygone-history.bygone.yaml +80 -0
- package/media/editor.worker.js +26 -14250
- package/media/icon.png +0 -0
- package/media/webview.css +1 -9818
- package/media/webview.js +358 -132939
- package/out/changeTour.js +1901 -0
- package/out/standalone-main.js +14698 -8507
- package/out/standalone-preload.js +5 -4
- package/package.json +10 -3
- package/schemas/change-tour-source.schema.json +287 -0
- package/standalone/index.html +23 -6
- package/web/index.html +266 -0
- package/web/presenter.css +725 -0
- package/web/web-host.js +149 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
const { CLI_SPEC, getCliEntry, tokensFor } = require('./commandSpec.js');
|
|
2
|
+
|
|
3
|
+
const SUPPORTED_SHELLS = Object.freeze(['zsh', 'bash', 'fish']);
|
|
4
|
+
|
|
5
|
+
function generateCompletion(shell) {
|
|
6
|
+
if (shell === 'zsh') {
|
|
7
|
+
return generateZshCompletion();
|
|
8
|
+
}
|
|
9
|
+
if (shell === 'bash') {
|
|
10
|
+
return generateBashCompletion();
|
|
11
|
+
}
|
|
12
|
+
if (shell === 'fish') {
|
|
13
|
+
return generateFishCompletion();
|
|
14
|
+
}
|
|
15
|
+
throw new Error(`Unsupported shell "${shell}". Choose: ${SUPPORTED_SHELLS.join(', ')}.`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function completionFileName(shell) {
|
|
19
|
+
if (shell === 'zsh') {
|
|
20
|
+
return '_bygone';
|
|
21
|
+
}
|
|
22
|
+
if (shell === 'bash') {
|
|
23
|
+
return 'bygone';
|
|
24
|
+
}
|
|
25
|
+
if (shell === 'fish') {
|
|
26
|
+
return 'bygone.fish';
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`Unsupported shell "${shell}".`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function generateZshCompletion() {
|
|
32
|
+
const rootItems = completionEntries()
|
|
33
|
+
.flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
|
|
34
|
+
.join('\n');
|
|
35
|
+
const reviewOptions = entriesByIds(['base', 'help'])
|
|
36
|
+
.flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
|
|
37
|
+
.join('\n');
|
|
38
|
+
const presentOptions = entriesByIds(['base', 'tour', 'help'])
|
|
39
|
+
.flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
|
|
40
|
+
.join('\n');
|
|
41
|
+
const legacyOptions = entriesByIds(['branch', 'base', 'help'])
|
|
42
|
+
.flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
|
|
43
|
+
.join('\n');
|
|
44
|
+
const historyOptions = entriesByIds(['includeStaged', 'help'])
|
|
45
|
+
.flatMap((entry) => entry.tokens.map((token) => ` '${token}:${escapeSingleQuoted(entry.description)}'`))
|
|
46
|
+
.join('\n');
|
|
47
|
+
|
|
48
|
+
return `#compdef bygone
|
|
49
|
+
# Generated from cli/commandSpec.js. Do not edit by hand.
|
|
50
|
+
|
|
51
|
+
_bygone_git_refs() {
|
|
52
|
+
local -a refs
|
|
53
|
+
refs=(\${(f)"$(git for-each-ref --format='%(refname:short)' refs/heads refs/remotes refs/tags 2>/dev/null)"})
|
|
54
|
+
_describe 'git source' refs
|
|
55
|
+
compadd -- HEAD INDEX WORKTREE
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_bygone() {
|
|
59
|
+
local first="\${words[2]}"
|
|
60
|
+
local previous="\${words[CURRENT-1]}"
|
|
61
|
+
local -a root_items review_options present_options legacy_options history_options shells tour_actions
|
|
62
|
+
root_items=(
|
|
63
|
+
${rootItems}
|
|
64
|
+
)
|
|
65
|
+
review_options=(
|
|
66
|
+
${reviewOptions}
|
|
67
|
+
)
|
|
68
|
+
present_options=(
|
|
69
|
+
${presentOptions}
|
|
70
|
+
)
|
|
71
|
+
legacy_options=(
|
|
72
|
+
${legacyOptions}
|
|
73
|
+
)
|
|
74
|
+
history_options=(
|
|
75
|
+
${historyOptions}
|
|
76
|
+
)
|
|
77
|
+
shells=('zsh:Z shell' 'bash:Bash' 'fish:Fish shell')
|
|
78
|
+
tour_actions=('context:Build structured change evidence' 'validate:Validate source and anchors' 'compile:Compile a portable manifest' 'schema:Print the source schema')
|
|
79
|
+
|
|
80
|
+
if (( CURRENT == 2 )); then
|
|
81
|
+
_describe 'bygone command' root_items
|
|
82
|
+
_files
|
|
83
|
+
return
|
|
84
|
+
fi
|
|
85
|
+
|
|
86
|
+
case "$first" in
|
|
87
|
+
completion)
|
|
88
|
+
_describe 'shell' shells
|
|
89
|
+
;;
|
|
90
|
+
tour)
|
|
91
|
+
if (( CURRENT == 3 )); then
|
|
92
|
+
_describe 'tour action' tour_actions
|
|
93
|
+
elif [[ "$words[3]" == "context" ]]; then
|
|
94
|
+
if [[ "$previous" == ${shellAlternation(tokensFor('base'))} ]]; then
|
|
95
|
+
_bygone_git_refs
|
|
96
|
+
elif [[ "$previous" == "--output" || "$previous" == "-o" ]]; then
|
|
97
|
+
_files
|
|
98
|
+
else
|
|
99
|
+
compadd -- --base --output -o --max-patch-bytes --max-total-patch-bytes
|
|
100
|
+
_bygone_git_refs
|
|
101
|
+
fi
|
|
102
|
+
elif [[ "$words[3]" == "validate" || "$words[3]" == "compile" ]]; then
|
|
103
|
+
_files -g '*.bygone.yaml'
|
|
104
|
+
fi
|
|
105
|
+
;;
|
|
106
|
+
review)
|
|
107
|
+
if [[ "$previous" == ${shellAlternation(tokensFor('base'))} ]]; then
|
|
108
|
+
_bygone_git_refs
|
|
109
|
+
else
|
|
110
|
+
_describe 'review option' review_options
|
|
111
|
+
_bygone_git_refs
|
|
112
|
+
fi
|
|
113
|
+
;;
|
|
114
|
+
present)
|
|
115
|
+
if [[ "$previous" == ${shellAlternation(tokensFor('tour'))} ]]; then
|
|
116
|
+
_files -g '*.bygone.yaml'
|
|
117
|
+
elif [[ "$previous" == ${shellAlternation(tokensFor('base'))} ]]; then
|
|
118
|
+
_bygone_git_refs
|
|
119
|
+
else
|
|
120
|
+
_describe 'present option' present_options
|
|
121
|
+
_bygone_git_refs
|
|
122
|
+
fi
|
|
123
|
+
;;
|
|
124
|
+
--branch-diff)
|
|
125
|
+
if [[ "$previous" == ${shellAlternation([...tokensFor('branch'), ...tokensFor('base')])} ]]; then
|
|
126
|
+
_bygone_git_refs
|
|
127
|
+
else
|
|
128
|
+
_describe 'review option' legacy_options
|
|
129
|
+
_bygone_git_refs
|
|
130
|
+
fi
|
|
131
|
+
;;
|
|
132
|
+
--git-diff)
|
|
133
|
+
_bygone_git_refs
|
|
134
|
+
;;
|
|
135
|
+
--history)
|
|
136
|
+
_describe 'history option' history_options
|
|
137
|
+
_files
|
|
138
|
+
;;
|
|
139
|
+
--diff)
|
|
140
|
+
_files
|
|
141
|
+
;;
|
|
142
|
+
*)
|
|
143
|
+
_files
|
|
144
|
+
;;
|
|
145
|
+
esac
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
compdef _bygone bygone
|
|
149
|
+
`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function generateBashCompletion() {
|
|
153
|
+
const rootTokens = completionEntries().flatMap((entry) => entry.tokens).join(' ');
|
|
154
|
+
const reviewTokens = entriesByIds(['base', 'help']).flatMap((entry) => entry.tokens).join(' ');
|
|
155
|
+
const presentTokens = entriesByIds(['base', 'tour', 'help']).flatMap((entry) => entry.tokens).join(' ');
|
|
156
|
+
const legacyTokens = entriesByIds(['branch', 'base', 'help']).flatMap((entry) => entry.tokens).join(' ');
|
|
157
|
+
const historyTokens = entriesByIds(['includeStaged', 'help']).flatMap((entry) => entry.tokens).join(' ');
|
|
158
|
+
const baseTokens = tokensFor('base').join('|');
|
|
159
|
+
const refTokens = [...tokensFor('branch'), ...tokensFor('base')].join('|');
|
|
160
|
+
|
|
161
|
+
return `# Generated from cli/commandSpec.js. Do not edit by hand.
|
|
162
|
+
_bygone_git_refs() {
|
|
163
|
+
git for-each-ref --format='%(refname:short)' refs/heads refs/remotes refs/tags 2>/dev/null
|
|
164
|
+
printf '%s\\n' HEAD INDEX WORKTREE
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
_bygone() {
|
|
168
|
+
local cur prev first refs
|
|
169
|
+
COMPREPLY=()
|
|
170
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
171
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
172
|
+
first="\${COMP_WORDS[1]}"
|
|
173
|
+
|
|
174
|
+
if (( COMP_CWORD == 1 )); then
|
|
175
|
+
COMPREPLY=( $(compgen -W '${rootTokens}' -- "$cur") $(compgen -f -- "$cur") )
|
|
176
|
+
compopt -o filenames 2>/dev/null || true
|
|
177
|
+
return
|
|
178
|
+
fi
|
|
179
|
+
|
|
180
|
+
case "$first" in
|
|
181
|
+
completion)
|
|
182
|
+
COMPREPLY=( $(compgen -W 'zsh bash fish' -- "$cur") )
|
|
183
|
+
;;
|
|
184
|
+
tour)
|
|
185
|
+
if (( COMP_CWORD == 2 )); then
|
|
186
|
+
COMPREPLY=( $(compgen -W 'context validate compile schema' -- "$cur") )
|
|
187
|
+
elif [[ "\${COMP_WORDS[2]}" == "context" ]]; then
|
|
188
|
+
refs="$(_bygone_git_refs)"
|
|
189
|
+
if [[ "$prev" =~ ^(${baseTokens})$ ]]; then
|
|
190
|
+
COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
|
|
191
|
+
elif [[ "$prev" == "--output" || "$prev" == "-o" ]]; then
|
|
192
|
+
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
193
|
+
compopt -o filenames 2>/dev/null || true
|
|
194
|
+
else
|
|
195
|
+
COMPREPLY=( $(compgen -W '--base --output -o --max-patch-bytes --max-total-patch-bytes '"$refs" -- "$cur") )
|
|
196
|
+
fi
|
|
197
|
+
else
|
|
198
|
+
COMPREPLY=( $(compgen -W '--json --output -o' -- "$cur") $(compgen -f -- "$cur") )
|
|
199
|
+
compopt -o filenames 2>/dev/null || true
|
|
200
|
+
fi
|
|
201
|
+
;;
|
|
202
|
+
review)
|
|
203
|
+
refs="$(_bygone_git_refs)"
|
|
204
|
+
if [[ "$prev" =~ ^(${baseTokens})$ ]]; then
|
|
205
|
+
COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
|
|
206
|
+
else
|
|
207
|
+
COMPREPLY=( $(compgen -W '${reviewTokens} '"$refs" -- "$cur") )
|
|
208
|
+
fi
|
|
209
|
+
;;
|
|
210
|
+
present)
|
|
211
|
+
refs="$(_bygone_git_refs)"
|
|
212
|
+
if [[ "$prev" == "--tour" ]]; then
|
|
213
|
+
COMPREPLY=( $(compgen -f -X '!*.bygone.yaml' -- "$cur") )
|
|
214
|
+
compopt -o filenames 2>/dev/null || true
|
|
215
|
+
elif [[ "$prev" =~ ^(${baseTokens})$ ]]; then
|
|
216
|
+
COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
|
|
217
|
+
else
|
|
218
|
+
COMPREPLY=( $(compgen -W '${presentTokens} '"$refs" -- "$cur") )
|
|
219
|
+
fi
|
|
220
|
+
;;
|
|
221
|
+
--branch-diff)
|
|
222
|
+
refs="$(_bygone_git_refs)"
|
|
223
|
+
if [[ "$prev" =~ ^(${refTokens})$ ]]; then
|
|
224
|
+
COMPREPLY=( $(compgen -W "$refs" -- "$cur") )
|
|
225
|
+
else
|
|
226
|
+
COMPREPLY=( $(compgen -W '${legacyTokens} '"$refs" -- "$cur") )
|
|
227
|
+
fi
|
|
228
|
+
;;
|
|
229
|
+
--git-diff)
|
|
230
|
+
COMPREPLY=( $(compgen -W "$(_bygone_git_refs)" -- "$cur") )
|
|
231
|
+
;;
|
|
232
|
+
--history)
|
|
233
|
+
COMPREPLY=( $(compgen -W '${historyTokens}' -- "$cur") $(compgen -f -- "$cur") )
|
|
234
|
+
compopt -o filenames 2>/dev/null || true
|
|
235
|
+
;;
|
|
236
|
+
*)
|
|
237
|
+
COMPREPLY=( $(compgen -f -- "$cur") )
|
|
238
|
+
compopt -o filenames 2>/dev/null || true
|
|
239
|
+
;;
|
|
240
|
+
esac
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
complete -F _bygone bygone
|
|
244
|
+
`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function generateFishCompletion() {
|
|
248
|
+
const lines = [
|
|
249
|
+
'# Generated from cli/commandSpec.js. Do not edit by hand.',
|
|
250
|
+
'function __bygone_git_refs',
|
|
251
|
+
" git for-each-ref --format='%(refname:short)' refs/heads refs/remotes refs/tags 2>/dev/null",
|
|
252
|
+
' printf "%s\\n" HEAD INDEX WORKTREE',
|
|
253
|
+
'end',
|
|
254
|
+
''
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
for (const entry of completionEntries()) {
|
|
258
|
+
const option = fishTokens(entry.tokens);
|
|
259
|
+
const argument = entry.argument === 'path' || entry.argument === 'files' ? ' -r' : '';
|
|
260
|
+
lines.push(`complete -c bygone -n '__fish_use_subcommand' ${option}${argument} -d '${escapeSingleQuoted(entry.description)}'`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
lines.push(
|
|
264
|
+
"complete -c bygone -n '__fish_seen_subcommand_from completion' -a 'zsh bash fish' -d 'Shell'",
|
|
265
|
+
"complete -c bygone -n '__fish_seen_subcommand_from tour' -a 'context validate compile schema' -d 'Tour action'",
|
|
266
|
+
"complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l base -r -a '(__bygone_git_refs)' -d 'Set the change-context base ref'",
|
|
267
|
+
"complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l output -s o -r -d 'Write the change context'",
|
|
268
|
+
"complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l max-patch-bytes -r -d 'Maximum included patch size per file'",
|
|
269
|
+
"complete -c bygone -n 'string match -q \"*tour context*\" -- (commandline -opc)' -l max-total-patch-bytes -r -d 'Maximum included patch size for the dossier'",
|
|
270
|
+
"complete -c bygone -n 'string match -q \"*tour validate*\" -- (commandline -opc)' -l json -d 'Print machine-readable validation output'",
|
|
271
|
+
"complete -c bygone -n 'string match -q \"*tour compile*\" -- (commandline -opc)' -l output -s o -r -d 'Write the compiled manifest'",
|
|
272
|
+
`complete -c bygone -n 'string match -q "*--history*" -- (commandline -opc)' ${fishTokens(tokensFor('includeStaged'))} -d '${escapeSingleQuoted(getCliEntry('includeStaged').description)}'`,
|
|
273
|
+
`complete -c bygone -n '__fish_seen_subcommand_from review' ${fishTokens(tokensFor('base'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('base').description)}'`,
|
|
274
|
+
"complete -c bygone -n '__fish_seen_subcommand_from review' -a '(__bygone_git_refs)' -d 'Git ref'",
|
|
275
|
+
`complete -c bygone -n '__fish_seen_subcommand_from present' ${fishTokens(tokensFor('base'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('base').description)}'`,
|
|
276
|
+
`complete -c bygone -n '__fish_seen_subcommand_from present' ${fishTokens(tokensFor('tour'))} -r -d '${escapeSingleQuoted(getCliEntry('tour').description)}'`,
|
|
277
|
+
"complete -c bygone -n '__fish_seen_subcommand_from present' -a '(__bygone_git_refs)' -d 'Git ref'",
|
|
278
|
+
`complete -c bygone -n 'string match -q "*--branch-diff*" -- (commandline -opc)' ${fishTokens(tokensFor('branch'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('branch').description)}'`,
|
|
279
|
+
`complete -c bygone -n 'string match -q "*--branch-diff*" -- (commandline -opc)' ${fishTokens(tokensFor('base'))} -r -a '(__bygone_git_refs)' -d '${escapeSingleQuoted(getCliEntry('base').description)}'`,
|
|
280
|
+
`complete -c bygone -n 'string match -q "*--git-diff*" -- (commandline -opc)' -a '(__bygone_git_refs)' -d 'Git source'`
|
|
281
|
+
);
|
|
282
|
+
return `${lines.join('\n')}\n`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function completionEntries() {
|
|
286
|
+
return CLI_SPEC.entries.filter((entry) => (
|
|
287
|
+
entry.kind === 'command' || entry.kind === 'mode' || entry.kind === 'global'
|
|
288
|
+
));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function entriesByIds(ids) {
|
|
292
|
+
return ids.map((id) => getCliEntry(id)).filter(Boolean);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function shellAlternation(tokens) {
|
|
296
|
+
return `(${tokens.join('|')})`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function fishTokenOption(token) {
|
|
300
|
+
if (token.startsWith('--')) {
|
|
301
|
+
return `-l ${token.slice(2)}`;
|
|
302
|
+
}
|
|
303
|
+
if (token.startsWith('-')) {
|
|
304
|
+
return `-s ${token.slice(1)}`;
|
|
305
|
+
}
|
|
306
|
+
return `-a ${token}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function fishTokens(tokens) {
|
|
310
|
+
return tokens.map(fishTokenOption).join(' ');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function escapeSingleQuoted(value) {
|
|
314
|
+
return String(value).replace(/'/g, "'\\''");
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
module.exports = {
|
|
318
|
+
SUPPORTED_SHELLS,
|
|
319
|
+
completionFileName,
|
|
320
|
+
generateCompletion
|
|
321
|
+
};
|
package/cli/present.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const { createReadStream, existsSync, mkdirSync, readFileSync, writeFileSync } = require('fs');
|
|
2
|
+
const { createServer } = require('http');
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { spawn } = require('child_process');
|
|
5
|
+
const { buildChangeTourManifest, parseChangeTourStory } = require('../out/changeTour.js');
|
|
6
|
+
const { tokenMatches } = require('./commandSpec.js');
|
|
7
|
+
const { loadTourSource } = require('./tourFile.js');
|
|
8
|
+
|
|
9
|
+
const MIME_TYPES = new Map([
|
|
10
|
+
['.css', 'text/css; charset=utf-8'],
|
|
11
|
+
['.html', 'text/html; charset=utf-8'],
|
|
12
|
+
['.js', 'application/javascript; charset=utf-8'],
|
|
13
|
+
['.json', 'application/json; charset=utf-8'],
|
|
14
|
+
['.png', 'image/png'],
|
|
15
|
+
['.svg', 'image/svg+xml; charset=utf-8'],
|
|
16
|
+
['.ttf', 'font/ttf']
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
async function startPresentation(args, cwd, packageRoot, options = {}) {
|
|
20
|
+
const { headRef, baseRef, tourPath, explicitHeadRef } = parsePresentArgs(args);
|
|
21
|
+
const story = process.env.BYGONE_TOUR_STORY
|
|
22
|
+
? parseChangeTourStory(JSON.parse(readFileSync(path.resolve(cwd, process.env.BYGONE_TOUR_STORY), 'utf8')))
|
|
23
|
+
: undefined;
|
|
24
|
+
const source = tourPath ? loadTourSource(cwd, tourPath).source : undefined;
|
|
25
|
+
const manifest = buildChangeTourManifest(cwd, {
|
|
26
|
+
headRef: explicitHeadRef || source?.range?.head || headRef,
|
|
27
|
+
baseRef: baseRef || source?.range?.base,
|
|
28
|
+
title: process.env.BYGONE_TOUR_TITLE,
|
|
29
|
+
sourceUrl: process.env.BYGONE_TOUR_SOURCE_URL,
|
|
30
|
+
story,
|
|
31
|
+
source
|
|
32
|
+
});
|
|
33
|
+
const serializedManifest = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
34
|
+
const outputPath = process.env.BYGONE_TOUR_OUTPUT;
|
|
35
|
+
if (outputPath) {
|
|
36
|
+
const resolvedOutput = path.resolve(cwd, outputPath);
|
|
37
|
+
mkdirSync(path.dirname(resolvedOutput), { recursive: true });
|
|
38
|
+
writeFileSync(resolvedOutput, serializedManifest, 'utf8');
|
|
39
|
+
process.stdout.write(`Wrote change-tour manifest to ${resolvedOutput}\n`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const server = createServer((request, response) => {
|
|
43
|
+
const requestUrl = new URL(request.url || '/', 'http://127.0.0.1');
|
|
44
|
+
if (requestUrl.pathname === '/tour.json') {
|
|
45
|
+
response.writeHead(200, {
|
|
46
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
47
|
+
'Cache-Control': 'no-store'
|
|
48
|
+
});
|
|
49
|
+
response.end(serializedManifest);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const targetPath = resolveAssetPath(packageRoot, requestUrl.pathname);
|
|
53
|
+
if (!targetPath) {
|
|
54
|
+
respond(response, 404, 'Not found');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
response.writeHead(200, {
|
|
58
|
+
'Content-Type': MIME_TYPES.get(path.extname(targetPath)) || 'application/octet-stream',
|
|
59
|
+
'Cache-Control': 'no-store'
|
|
60
|
+
});
|
|
61
|
+
createReadStream(targetPath).on('error', () => respond(response, 500, 'Read failed')).pipe(response);
|
|
62
|
+
});
|
|
63
|
+
const requestedPort = readPort(process.env.BYGONE_TOUR_PORT);
|
|
64
|
+
await new Promise((resolve, reject) => {
|
|
65
|
+
server.once('error', reject);
|
|
66
|
+
server.listen(requestedPort, '127.0.0.1', resolve);
|
|
67
|
+
});
|
|
68
|
+
const address = server.address();
|
|
69
|
+
if (!address || typeof address === 'string') {
|
|
70
|
+
server.close();
|
|
71
|
+
throw new Error('Could not determine the presentation server address.');
|
|
72
|
+
}
|
|
73
|
+
const url = `http://127.0.0.1:${address.port}/?manifest=/tour.json`;
|
|
74
|
+
if (options.announce !== false) {
|
|
75
|
+
process.stdout.write(`Bygone change tour running at ${url}\n`);
|
|
76
|
+
process.stdout.write('Press Ctrl+C to stop.\n');
|
|
77
|
+
}
|
|
78
|
+
if (options.open !== false && process.env.BYGONE_TOUR_NO_OPEN !== '1') {
|
|
79
|
+
const openUrl = typeof options.openUrl === 'function' ? options.openUrl : openBrowser;
|
|
80
|
+
try {
|
|
81
|
+
await openUrl(url);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
server.close();
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { manifest, server, url };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parsePresentArgs(args) {
|
|
91
|
+
let headRef = 'HEAD';
|
|
92
|
+
let explicitHeadRef;
|
|
93
|
+
let baseRef;
|
|
94
|
+
let tourPath;
|
|
95
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
96
|
+
const arg = args[index];
|
|
97
|
+
if (tokenMatches('base', arg)) {
|
|
98
|
+
if (!args[index + 1]) {
|
|
99
|
+
throw new Error(`${arg} requires a Git ref.`);
|
|
100
|
+
}
|
|
101
|
+
baseRef = args[++index];
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (tokenMatches('tour', arg)) {
|
|
105
|
+
if (!args[index + 1]) throw new Error(`${arg} requires a YAML file.`);
|
|
106
|
+
tourPath = args[++index];
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (arg.startsWith('-')) {
|
|
110
|
+
throw new Error(`Unknown present option: ${arg}`);
|
|
111
|
+
}
|
|
112
|
+
if (explicitHeadRef) {
|
|
113
|
+
throw new Error('present accepts at most one head ref.');
|
|
114
|
+
}
|
|
115
|
+
headRef = arg;
|
|
116
|
+
explicitHeadRef = arg;
|
|
117
|
+
}
|
|
118
|
+
return { headRef, baseRef, tourPath, explicitHeadRef };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function resolveAssetPath(packageRoot, requestPath) {
|
|
122
|
+
if (requestPath === '/' || requestPath === '/index.html') {
|
|
123
|
+
return path.join(packageRoot, 'web', 'index.html');
|
|
124
|
+
}
|
|
125
|
+
if (!requestPath.startsWith('/web/') && !requestPath.startsWith('/media/')) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
const candidate = path.resolve(packageRoot, `.${requestPath}`);
|
|
129
|
+
if (!candidate.startsWith(`${path.resolve(packageRoot)}${path.sep}`) || !existsSync(candidate)) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
return candidate;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function openBrowser(url) {
|
|
136
|
+
const command = process.platform === 'darwin'
|
|
137
|
+
? ['open', [url]]
|
|
138
|
+
: process.platform === 'win32'
|
|
139
|
+
? ['cmd', ['/c', 'start', '', url]]
|
|
140
|
+
: ['xdg-open', [url]];
|
|
141
|
+
const child = spawn(command[0], command[1], { detached: true, stdio: 'ignore' });
|
|
142
|
+
child.unref();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function readPort(value) {
|
|
146
|
+
if (!value) {
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
const port = Number.parseInt(value, 10);
|
|
150
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
151
|
+
throw new Error('BYGONE_TOUR_PORT must be an integer from 0 to 65535.');
|
|
152
|
+
}
|
|
153
|
+
return port;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function respond(response, statusCode, message) {
|
|
157
|
+
if (!response.headersSent) {
|
|
158
|
+
response.writeHead(statusCode, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
159
|
+
}
|
|
160
|
+
response.end(message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
parsePresentArgs,
|
|
165
|
+
startPresentation
|
|
166
|
+
};
|
package/cli/tour.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
const { mkdirSync, readFileSync, writeFileSync } = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const { buildChangeTourContext } = require('../out/changeTour.js');
|
|
4
|
+
const { buildTourCoverageReport } = require('../out/tourCoverage.js');
|
|
5
|
+
const { buildManifestForTourSource, loadTourSource } = require('./tourFile.js');
|
|
6
|
+
|
|
7
|
+
const TOUR_ACTIONS = Object.freeze(['context', 'coverage', 'validate', 'compile', 'schema']);
|
|
8
|
+
|
|
9
|
+
function runTourCommand(args, cwd, packageRoot, output = process.stdout) {
|
|
10
|
+
const options = parseTourArgs(args);
|
|
11
|
+
if (options.action === 'schema') {
|
|
12
|
+
output.write(readFileSync(path.join(packageRoot, 'schemas', 'change-tour-source.schema.json'), 'utf8'));
|
|
13
|
+
return { action: 'schema' };
|
|
14
|
+
}
|
|
15
|
+
if (options.action === 'context') {
|
|
16
|
+
const context = buildChangeTourContext(cwd, {
|
|
17
|
+
headRef: options.headRef,
|
|
18
|
+
baseRef: options.baseRef,
|
|
19
|
+
maxPatchBytes: options.maxPatchBytes,
|
|
20
|
+
maxTotalPatchBytes: options.maxTotalPatchBytes
|
|
21
|
+
});
|
|
22
|
+
return writeJsonResult('change-tour context', context, options.outputPath, cwd, output);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const { resolvedPath, source } = loadTourSource(cwd, options.sourcePath);
|
|
26
|
+
if (options.action === 'coverage') {
|
|
27
|
+
const report = buildTourCoverageReport(cwd, source);
|
|
28
|
+
output.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderCoverageReport(report));
|
|
29
|
+
if (options.minimumCoverage !== undefined && report.totals.coveragePercent < options.minimumCoverage) {
|
|
30
|
+
throw new Error(`Tour coverage ${report.totals.coveragePercent}% is below the required ${options.minimumCoverage}%.`);
|
|
31
|
+
}
|
|
32
|
+
return { action: 'coverage', sourcePath: resolvedPath, report };
|
|
33
|
+
}
|
|
34
|
+
const manifest = buildManifestForTourSource(cwd, source);
|
|
35
|
+
if (options.action === 'validate') {
|
|
36
|
+
const result = buildValidationResult(resolvedPath, manifest);
|
|
37
|
+
output.write(options.json
|
|
38
|
+
? `${JSON.stringify(result, null, 2)}\n`
|
|
39
|
+
: `Valid change tour: ${path.relative(cwd, resolvedPath) || path.basename(resolvedPath)} · ${result.walkthroughSteps} steps · ${result.summary.changedFiles} changed files · ${result.range.mergeBaseOid.slice(0, 7)} → ${result.range.headOid.slice(0, 7)}\n`);
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const serialized = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
44
|
+
if (options.outputPath) {
|
|
45
|
+
const resolvedOutput = path.resolve(cwd, options.outputPath);
|
|
46
|
+
mkdirSync(path.dirname(resolvedOutput), { recursive: true });
|
|
47
|
+
writeFileSync(resolvedOutput, serialized, 'utf8');
|
|
48
|
+
output.write(`Wrote compiled change tour to ${resolvedOutput}\n`);
|
|
49
|
+
return { action: 'compile', outputPath: resolvedOutput, manifest };
|
|
50
|
+
}
|
|
51
|
+
output.write(serialized);
|
|
52
|
+
return { action: 'compile', manifest };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseTourArgs(args) {
|
|
56
|
+
const [action, ...rest] = args;
|
|
57
|
+
if (!TOUR_ACTIONS.includes(action)) {
|
|
58
|
+
throw new Error(`Usage: bygone tour <${TOUR_ACTIONS.join('|')}> [file.bygone.yaml]`);
|
|
59
|
+
}
|
|
60
|
+
if (action === 'schema') {
|
|
61
|
+
if (rest.length > 0) throw new Error('tour schema does not accept additional arguments.');
|
|
62
|
+
return { action };
|
|
63
|
+
}
|
|
64
|
+
if (action === 'context') return parseContextArgs(rest);
|
|
65
|
+
let sourcePath;
|
|
66
|
+
let outputPath;
|
|
67
|
+
let json = false;
|
|
68
|
+
let minimumCoverage;
|
|
69
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
70
|
+
const arg = rest[index];
|
|
71
|
+
if (arg === '--json') {
|
|
72
|
+
json = true;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (arg === '--minimum-coverage') {
|
|
76
|
+
minimumCoverage = Number.parseInt(rest[++index], 10);
|
|
77
|
+
if (!Number.isInteger(minimumCoverage) || minimumCoverage < 0 || minimumCoverage > 100) {
|
|
78
|
+
throw new Error('--minimum-coverage requires an integer from 0 to 100.');
|
|
79
|
+
}
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg === '--output' || arg === '-o') {
|
|
83
|
+
if (!rest[index + 1]) throw new Error(`${arg} requires a file path.`);
|
|
84
|
+
outputPath = rest[++index];
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg.startsWith('-')) throw new Error(`Unknown tour option: ${arg}`);
|
|
88
|
+
if (sourcePath) throw new Error(`tour ${action} accepts exactly one source file.`);
|
|
89
|
+
sourcePath = arg;
|
|
90
|
+
}
|
|
91
|
+
if (!sourcePath) throw new Error(`tour ${action} requires a .bygone.yaml source file.`);
|
|
92
|
+
if (action === 'validate' && outputPath) throw new Error('--output is only valid with tour compile.');
|
|
93
|
+
if (action === 'compile' && json) throw new Error('--json is only valid with tour validate or coverage.');
|
|
94
|
+
if (action !== 'coverage' && minimumCoverage !== undefined) {
|
|
95
|
+
throw new Error('--minimum-coverage is only valid with tour coverage.');
|
|
96
|
+
}
|
|
97
|
+
return { action, sourcePath, outputPath, json, ...(action === 'coverage' ? { minimumCoverage } : {}) };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function renderCoverageReport(report) {
|
|
101
|
+
const lines = [
|
|
102
|
+
`Tour coverage: ${report.totals.coveredUnits}/${report.totals.includedUnits} hunks (${report.totals.coveragePercent}%)`,
|
|
103
|
+
`Depth: ${report.depth.mentioned} mentioned · ${report.depth.explained} explained · ${report.depth.contextualized} contextualized`
|
|
104
|
+
];
|
|
105
|
+
if (report.totals.excludedUnits > 0) lines.push(`Excluded: ${report.totals.excludedUnits}/${report.totals.originalUnits} hunks`);
|
|
106
|
+
for (const file of report.files.filter((entry) => entry.uncoveredHunks.length > 0)) {
|
|
107
|
+
lines.push(`${file.path}: ${file.uncoveredHunks.length} uncovered (${file.uncoveredHunks.join(', ')})`);
|
|
108
|
+
}
|
|
109
|
+
for (const unsupported of report.unsupported) lines.push(`${unsupported.path}: ${unsupported.material} material not scored`);
|
|
110
|
+
return `${lines.join('\n')}\n`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function parseContextArgs(args) {
|
|
114
|
+
let headRef;
|
|
115
|
+
let baseRef;
|
|
116
|
+
let outputPath;
|
|
117
|
+
let maxPatchBytes;
|
|
118
|
+
let maxTotalPatchBytes;
|
|
119
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
120
|
+
const arg = args[index];
|
|
121
|
+
if (arg === '--base' || arg === '-m' || arg === '--main') {
|
|
122
|
+
if (!args[index + 1]) throw new Error(`${arg} requires a Git ref.`);
|
|
123
|
+
baseRef = args[++index];
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (arg === '--output' || arg === '-o') {
|
|
127
|
+
if (!args[index + 1]) throw new Error(`${arg} requires a file path.`);
|
|
128
|
+
outputPath = args[++index];
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (arg === '--max-patch-bytes') {
|
|
132
|
+
if (!args[index + 1]) throw new Error(`${arg} requires a positive integer.`);
|
|
133
|
+
maxPatchBytes = Number.parseInt(args[++index], 10);
|
|
134
|
+
if (!Number.isInteger(maxPatchBytes) || maxPatchBytes < 1) {
|
|
135
|
+
throw new Error(`${arg} requires a positive integer.`);
|
|
136
|
+
}
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (arg === '--max-total-patch-bytes') {
|
|
140
|
+
if (!args[index + 1]) throw new Error(`${arg} requires a positive integer.`);
|
|
141
|
+
maxTotalPatchBytes = Number.parseInt(args[++index], 10);
|
|
142
|
+
if (!Number.isInteger(maxTotalPatchBytes) || maxTotalPatchBytes < 1) {
|
|
143
|
+
throw new Error(`${arg} requires a positive integer.`);
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (arg.startsWith('-')) throw new Error(`Unknown tour context option: ${arg}`);
|
|
148
|
+
if (headRef) throw new Error('tour context accepts at most one head ref.');
|
|
149
|
+
headRef = arg;
|
|
150
|
+
}
|
|
151
|
+
return { action: 'context', headRef, baseRef, outputPath, maxPatchBytes, maxTotalPatchBytes };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function writeJsonResult(label, value, outputPath, cwd, output) {
|
|
155
|
+
const serialized = `${JSON.stringify(value, null, 2)}\n`;
|
|
156
|
+
if (!outputPath) {
|
|
157
|
+
output.write(serialized);
|
|
158
|
+
return { action: 'context', context: value };
|
|
159
|
+
}
|
|
160
|
+
const resolvedOutput = path.resolve(cwd, outputPath);
|
|
161
|
+
mkdirSync(path.dirname(resolvedOutput), { recursive: true });
|
|
162
|
+
writeFileSync(resolvedOutput, serialized, 'utf8');
|
|
163
|
+
output.write(`Wrote ${label} to ${resolvedOutput}\n`);
|
|
164
|
+
return { action: 'context', outputPath: resolvedOutput, context: value };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildValidationResult(sourcePath, manifest) {
|
|
168
|
+
return {
|
|
169
|
+
ok: true,
|
|
170
|
+
sourcePath,
|
|
171
|
+
title: manifest.title,
|
|
172
|
+
range: manifest.range,
|
|
173
|
+
summary: manifest.summary,
|
|
174
|
+
chapters: manifest.chapters.length,
|
|
175
|
+
walkthroughSteps: manifest.scenes.reduce(
|
|
176
|
+
(total, scene) => total + (
|
|
177
|
+
scene.kind === 'walkthrough' || scene.kind === 'stacked-diff' || scene.kind === 'deconstructed-diff'
|
|
178
|
+
? scene.steps.length
|
|
179
|
+
: 0
|
|
180
|
+
),
|
|
181
|
+
0
|
|
182
|
+
)
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
parseTourArgs,
|
|
188
|
+
runTourCommand
|
|
189
|
+
};
|