@xmoon76/dsh-pi-tui 0.3.2 → 0.3.4

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.
@@ -0,0 +1,116 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Pre-push gate stage derivation — the single source of truth for what the
4
+ * husky pre-push hook runs stays in package.json's `verify:*` scripts; this
5
+ * module turns one of them into the ordered per-command stage list the hook
6
+ * executes with progress reporting.
7
+ *
8
+ * A script like `a && b && c` yields `['a', 'b', 'c']`. Only top-level `&&`
9
+ * separators are split (a `&&` inside quotes, e.g. inside a node -e snippet,
10
+ * is not a separator). A trailing `&&` (or a `&&` followed by nothing but
11
+ * whitespace) is rejected — `sh -e` treats such a line as a syntax error, so
12
+ * a split would silently drop the intended tail.
13
+ *
14
+ * Supported shell grammar (the parsed script must stay within this subset):
15
+ * - commands chained with `&&` (the split points), each otherwise passed
16
+ * verbatim to `sh -c` for execution — the splitter does NOT interpret
17
+ * the commands themselves;
18
+ * - single- and double-quoted strings anywhere in the script (quotes are
19
+ * tracked so `&&` inside them is not a separator);
20
+ * - backslash escapes inside and outside quotes: an escaped `&&` is NOT
21
+ * treated as a separator (it stays inside the current stage, whose
22
+ * shell semantics are then preserved unchanged by `sh -c`);
23
+ * - anything the command segment may contain (redirects, env assignments,
24
+ * pipes, subshells, `node -e "..."`, ...) is preserved verbatim and
25
+ * executed by `sh -c` unchanged.
26
+ * NOT supported (the splitter rejects them loudly): unterminated quotes,
27
+ * dangling `&&`, empty commands (consecutive `&&`), empty scripts.
28
+ *
29
+ * Usage:
30
+ * node scripts/pre-push-stages.mjs <verify-script-name>
31
+ * # e.g. node scripts/pre-push-stages.mjs verify:prepush
32
+ *
33
+ * @module pre-push-stages
34
+ */
35
+
36
+ import { readFileSync } from 'node:fs'
37
+ import { join, dirname } from 'node:path'
38
+ import { fileURLToPath } from 'node:url'
39
+
40
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
41
+
42
+ function fail(message) {
43
+ console.error(`pre-push-stages: ${message}`)
44
+ process.exit(1)
45
+ }
46
+
47
+ const [name] = process.argv.slice(2)
48
+ if (!name || !name.startsWith('verify:')) {
49
+ fail(`usage: node scripts/pre-push-stages.mjs <verify-script-name> (got: ${name ?? 'none'})`)
50
+ }
51
+
52
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
53
+ const script = pkg.scripts?.[name]
54
+ if (typeof script !== 'string' || script === '') {
55
+ fail(`package.json has no non-empty script "${name}" (update .husky/pre-push)`)
56
+ }
57
+
58
+ // Split on top-level `&&` only: walk the string tracking single-quote,
59
+ // double-quote and backslash escapes; a `&&` seen at depth 0 (no open
60
+ // quote) is a command separator. Escapes are honoured OUTSIDE quotes too
61
+ // (`\&&` is an escaped literal `&`, not a separator), and a script with
62
+ // an unterminated quote is rejected — sh would reject it as well.
63
+ const stages = []
64
+ let current = ''
65
+ let quote = null
66
+ for (let i = 0; i < script.length; i++) {
67
+ const ch = script[i]
68
+ if (quote === '"') {
69
+ if (ch === '\\') { current += ch + (script[i + 1] ?? ''); i++; continue }
70
+ if (ch === '"') quote = null
71
+ current += ch
72
+ continue
73
+ }
74
+ if (quote === "'") {
75
+ if (ch === "'") quote = null
76
+ current += ch
77
+ continue
78
+ }
79
+ if (ch === '\\') {
80
+ // Outside quotes: escape the next character (incl. `&`, so `\&&`
81
+ // stays literal). Keep the backslash in the fragment — it is
82
+ // meaningful to the shell that later runs the stage.
83
+ current += ch + (script[i + 1] ?? '')
84
+ i++
85
+ continue
86
+ }
87
+ if (ch === '"' || ch === "'") {
88
+ quote = ch
89
+ current += ch
90
+ continue
91
+ }
92
+ if (ch === '&' && script[i + 1] === '&') {
93
+ stages.push(current)
94
+ current = ''
95
+ i++
96
+ continue
97
+ }
98
+ current += ch
99
+ }
100
+ stages.push(current)
101
+
102
+ if (quote !== null) {
103
+ fail(`script "${name}" has an unterminated ${quote === '"' ? 'double' : 'single'} quote — sh would reject it; fix package.json`)
104
+ }
105
+
106
+ const trimmed = stages.map((s) => s.trim())
107
+ if (trimmed[trimmed.length - 1] === '') {
108
+ fail(`script "${name}" ends with a dangling "&&" — sh -e would reject it; fix package.json`)
109
+ }
110
+ for (const [i, stage] of trimmed.entries()) {
111
+ if (stage === '') {
112
+ fail(`script "${name}" has an empty command at position ${i + 1} (consecutive "&&")`)
113
+ }
114
+ }
115
+
116
+ process.stdout.write(trimmed.join('\n') + '\n')