@abarcenas/ai-workflow-template 1.28.0 → 1.32.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/.agents/instructions/learned-knowledge.instructions.md +23 -0
- package/.github/workflows/npm-publish.yml +3 -0
- package/.github/workflows/pr-test.yml +23 -0
- package/.husky/post-merge +1 -0
- package/.husky/pre-commit +2 -0
- package/.opencode/agents/architect.agent.md +2 -2
- package/.opencode/agents/designer.agent.md +13 -13
- package/.opencode/agents/e2e-tester.agent.md +33 -32
- package/.opencode/agents/orchestrator/feature-pipeline.agent.md +46 -56
- package/.opencode/agents/orchestrator/orchestrator.agent.md +55 -67
- package/.opencode/agents/orchestrator/tdd-orchestrator.agent.md +47 -59
- package/.opencode/agents/researcher.agent.md +15 -14
- package/.opencode/agents/unit-tester.agent.md +2 -1
- package/README.md +48 -1
- package/bin/setup.js +23 -0
- package/package.json +7 -2
- package/scripts/setup/constants.js +226 -0
- package/scripts/setup/discover.js +236 -0
- package/scripts/setup/discover.test.js +388 -0
- package/scripts/setup/hooks.js +261 -0
- package/scripts/setup/hooks.test.js +527 -0
- package/scripts/setup/husky-init.js +102 -0
- package/scripts/setup/index.js +354 -0
- package/scripts/setup/index.test.js +639 -0
- package/scripts/setup/prepare.js +127 -0
- package/scripts/setup/prepare.test.js +718 -0
- package/scripts/setup/sync-phase.js +172 -0
- package/scripts/setup/ui.js +503 -0
- package/scripts/setup/utils.js +371 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// sync-phase.js — Phase 5: spawn sync.js and normalize-memory.js as child
|
|
3
|
+
// processes (avoids process.exit() killing the parent setup)
|
|
4
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
5
|
+
|
|
6
|
+
import { spawn } from 'child_process'
|
|
7
|
+
import { join } from 'path'
|
|
8
|
+
|
|
9
|
+
import { resolvePackageRoot, logInfo, logWarn, logError } from './utils.js'
|
|
10
|
+
|
|
11
|
+
// ── Main export ─────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs the sync phase of the setup pipeline.
|
|
15
|
+
*
|
|
16
|
+
* Spawns `scripts/sync.js` and `scripts/normalize-memory.js` as child processes
|
|
17
|
+
* rather than importing them directly. This is required because `sync.js` calls
|
|
18
|
+
* `process.exit()` internally (line 83), which would terminate the entire setup
|
|
19
|
+
* process if it were imported as a module.
|
|
20
|
+
*
|
|
21
|
+
* **Early exits:**
|
|
22
|
+
* - `skipSync` is `true` → returns `{ action: 'skipped' }` without spawning
|
|
23
|
+
* - `dryRun` is `true` → returns `{ action: 'dry-run' }` after logging intent
|
|
24
|
+
*
|
|
25
|
+
* **Normal execution:**
|
|
26
|
+
* Both scripts are spawned sequentially (sync.js first, normalize-memory.js
|
|
27
|
+
* second) with `INIT_CWD` set to `consumerRoot` so they resolve consumer
|
|
28
|
+
* paths correctly. If `flags.force` is set, the `--force` argument is
|
|
29
|
+
* forwarded to both child scripts.
|
|
30
|
+
*
|
|
31
|
+
* Non-zero exit codes from either script are treated as warnings, not
|
|
32
|
+
* failures — the overall action becomes `'partial'` but the pipeline
|
|
33
|
+
* continues (graceful degradation per REQ-04).
|
|
34
|
+
*
|
|
35
|
+
* @param {object} context - Setup context object.
|
|
36
|
+
* @param {string} context.consumerRoot - Absolute path to the consumer project root.
|
|
37
|
+
* @param {boolean} [context.dryRun=false] - If `true`, only simulate without spawning.
|
|
38
|
+
* @param {boolean} [context.skipSync=false] - If `true`, skip the entire sync phase.
|
|
39
|
+
* @param {object} [context.flags={}] - Parsed CLI flags (may include `force`).
|
|
40
|
+
* @returns {Promise<{
|
|
41
|
+
* action: 'completed'|'partial'|'skipped'|'dry-run',
|
|
42
|
+
* message: string,
|
|
43
|
+
* results: Array<{
|
|
44
|
+
* script: string,
|
|
45
|
+
* success: boolean,
|
|
46
|
+
* exitCode: number,
|
|
47
|
+
* output: string
|
|
48
|
+
* }>
|
|
49
|
+
* }>}
|
|
50
|
+
*/
|
|
51
|
+
export async function runSyncPhase(context) {
|
|
52
|
+
const { consumerRoot, dryRun = false, skipSync = false, flags = {} } = context
|
|
53
|
+
|
|
54
|
+
// ── Skip path ──────────────────────────────────────────────────────────
|
|
55
|
+
if (skipSync) {
|
|
56
|
+
const message = 'Sync phase skipped (--skip-sync)'
|
|
57
|
+
logInfo(`[setup] ${message}`)
|
|
58
|
+
return { action: 'skipped', message, results: [] }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Dry-run path ───────────────────────────────────────────────────────
|
|
62
|
+
if (dryRun) {
|
|
63
|
+
const message =
|
|
64
|
+
`Would run sync.js and normalize-memory.js ` +
|
|
65
|
+
`with INIT_CWD="${consumerRoot}"`
|
|
66
|
+
logInfo(`[setup] dry-run: ${message}`)
|
|
67
|
+
return { action: 'dry-run', message, results: [] }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const packageRoot = resolvePackageRoot()
|
|
71
|
+
const extraArgs = flags.force ? ['--force'] : []
|
|
72
|
+
|
|
73
|
+
// ── Spawn sync.js ──────────────────────────────────────────────────────
|
|
74
|
+
const syncPath = join(packageRoot, 'scripts', 'sync.js')
|
|
75
|
+
const syncResult = await spawnScript(
|
|
76
|
+
'sync.js',
|
|
77
|
+
syncPath,
|
|
78
|
+
consumerRoot,
|
|
79
|
+
extraArgs,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
// ── Spawn normalize-memory.js ──────────────────────────────────────────
|
|
83
|
+
const normPath = join(packageRoot, 'scripts', 'normalize-memory.js')
|
|
84
|
+
const normResult = await spawnScript(
|
|
85
|
+
'normalize-memory.js',
|
|
86
|
+
normPath,
|
|
87
|
+
consumerRoot,
|
|
88
|
+
extraArgs,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const results = [syncResult, normResult]
|
|
92
|
+
const allSucceeded = results.every((r) => r.success)
|
|
93
|
+
const action = allSucceeded ? 'completed' : 'partial'
|
|
94
|
+
const failureCount = results.filter((r) => !r.success).length
|
|
95
|
+
|
|
96
|
+
const message =
|
|
97
|
+
action === 'completed'
|
|
98
|
+
? 'Sync phase completed'
|
|
99
|
+
: `Sync phase completed with ${failureCount} script failure(s)`
|
|
100
|
+
|
|
101
|
+
if (!allSucceeded) {
|
|
102
|
+
logWarn(`[setup] ${message}`)
|
|
103
|
+
for (const r of results) {
|
|
104
|
+
if (!r.success) {
|
|
105
|
+
logWarn(`[setup] ${r.script} exited with code ${r.exitCode}`)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { action, message, results }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ── Internal helpers ───────────────────────────────────────────────────────
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Spawns a Node.js script as a child process and waits for it to complete.
|
|
117
|
+
*
|
|
118
|
+
* Both stdout and stderr are captured into a single `output` string
|
|
119
|
+
* (interleaved in the order data events arrive). The returned promise
|
|
120
|
+
* resolves when the child process exits or fails to spawn.
|
|
121
|
+
*
|
|
122
|
+
* Uses `process.execPath` so the child always runs the same Node.js
|
|
123
|
+
* binary as the parent setup process.
|
|
124
|
+
*
|
|
125
|
+
* @param {string} name - Display name for the script (e.g. `'sync.js'`).
|
|
126
|
+
* @param {string} scriptPath - Absolute path to the script to execute.
|
|
127
|
+
* @param {string} cwd - Working directory for the child process.
|
|
128
|
+
* @param {string[]} extraArgs - Extra CLI arguments forwarded to the script.
|
|
129
|
+
* @returns {Promise<{
|
|
130
|
+
* script: string,
|
|
131
|
+
* success: boolean,
|
|
132
|
+
* exitCode: number,
|
|
133
|
+
* output: string
|
|
134
|
+
* }>}
|
|
135
|
+
*/
|
|
136
|
+
function spawnScript(name, scriptPath, cwd, extraArgs) {
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
const child = spawn(process.execPath, [scriptPath, ...extraArgs], {
|
|
139
|
+
cwd,
|
|
140
|
+
env: { ...process.env, INIT_CWD: cwd },
|
|
141
|
+
stdio: 'pipe',
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
let output = ''
|
|
145
|
+
|
|
146
|
+
child.stdout.on('data', (data) => {
|
|
147
|
+
output += data.toString()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
child.stderr.on('data', (data) => {
|
|
151
|
+
output += data.toString()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
child.on('close', (exitCode) => {
|
|
155
|
+
resolve({
|
|
156
|
+
script: name,
|
|
157
|
+
success: exitCode === 0,
|
|
158
|
+
exitCode: exitCode ?? -1,
|
|
159
|
+
output: output.trim(),
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
child.on('error', (err) => {
|
|
164
|
+
resolve({
|
|
165
|
+
script: name,
|
|
166
|
+
success: false,
|
|
167
|
+
exitCode: -1,
|
|
168
|
+
output: `Failed to spawn child process: ${err.message}`,
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
}
|
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// ui.js — User interface / output formatting for the setup command pipeline
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'fs'
|
|
6
|
+
import { join } from 'path'
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
UNICODE_CHARS,
|
|
10
|
+
PACKAGE_NAME,
|
|
11
|
+
BIN_NAME,
|
|
12
|
+
SUPPORTED_FLAGS,
|
|
13
|
+
} from './constants.js'
|
|
14
|
+
import { resolvePackageRoot } from './utils.js'
|
|
15
|
+
|
|
16
|
+
// ── Colour support ──────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** `true` when stdout is a TTY — ANSI escape codes are emitted. */
|
|
19
|
+
const useColor = process.stdout.isTTY
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Builds an ANSI SGR escape sequence.
|
|
23
|
+
*
|
|
24
|
+
* Returns an empty string when colours are disabled (piped / non-TTY output)
|
|
25
|
+
* so that log files stay clean.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} code - SGR parameter(s), e.g. `'1'`, `'32m'`, `'1;31m'`.
|
|
28
|
+
* @returns {string} Escape sequence, or `''` when colours are off.
|
|
29
|
+
*/
|
|
30
|
+
function ansi(code) {
|
|
31
|
+
return useColor ? `\x1b[${code}` : ''
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** ANSI reset. */
|
|
35
|
+
const RST = ansi('0m')
|
|
36
|
+
/** ANSI bold / increased intensity. */
|
|
37
|
+
const BLD = ansi('1m')
|
|
38
|
+
/** ANSI dim / decreased intensity. */
|
|
39
|
+
const DIM = ansi('2m')
|
|
40
|
+
/** ANSI green foreground. */
|
|
41
|
+
const GRN = ansi('32m')
|
|
42
|
+
/** ANSI yellow foreground. */
|
|
43
|
+
const YLW = ansi('33m')
|
|
44
|
+
/** ANSI red foreground. */
|
|
45
|
+
const RED = ansi('31m')
|
|
46
|
+
/** ANSI cyan foreground. */
|
|
47
|
+
const CYN = ansi('36m')
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read-only colour palette for use by downstream consumers.
|
|
51
|
+
*
|
|
52
|
+
* @type {{ reset: string, bold: string, dim: string, green: string,
|
|
53
|
+
* yellow: string, red: string, cyan: string }}
|
|
54
|
+
*/
|
|
55
|
+
export const COLORS = Object.freeze({
|
|
56
|
+
reset: RST,
|
|
57
|
+
bold: BLD,
|
|
58
|
+
dim: DIM,
|
|
59
|
+
green: GRN,
|
|
60
|
+
yellow: YLW,
|
|
61
|
+
red: RED,
|
|
62
|
+
cyan: CYN,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Strips ANSI escape sequences from a string.
|
|
69
|
+
*
|
|
70
|
+
* @param {string} str - Input that may contain ANSI codes.
|
|
71
|
+
* @returns {string} Plain text without escape sequences.
|
|
72
|
+
*/
|
|
73
|
+
function stripAnsi(str) {
|
|
74
|
+
return str.replace(/\x1b\[[0-9;]*m/g, '')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Returns the visible display width of a string, treating ANSI codes as
|
|
79
|
+
* zero-width.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} str - Input that may contain ANSI codes.
|
|
82
|
+
* @returns {number} Number of visible characters.
|
|
83
|
+
*/
|
|
84
|
+
function visibleWidth(str) {
|
|
85
|
+
return stripAnsi(str).length
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Returns a horizontal rule of the given character, bounded by the terminal
|
|
90
|
+
* width.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} [char='─'] - Character to repeat.
|
|
93
|
+
* @param {number} [maxWidth] - Maximum available width (defaults to terminal
|
|
94
|
+
* columns, capped at 72).
|
|
95
|
+
* @returns {string} Rule string.
|
|
96
|
+
*/
|
|
97
|
+
function rule(char = '\u2500', maxWidth) {
|
|
98
|
+
const width = maxWidth ?? Math.min(process.stdout.columns || 72, 72)
|
|
99
|
+
return char.repeat(width)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Version caching ─────────────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
/** @type {string|null} */
|
|
105
|
+
let _version = null
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Reads the package version from the project's `package.json`.
|
|
109
|
+
*
|
|
110
|
+
* The result is cached after the first successful read so repeated calls
|
|
111
|
+
* (e.g. `header()` then `help()`) don't re-read the filesystem.
|
|
112
|
+
*
|
|
113
|
+
* @returns {string} Semver string (e.g. `'1.29.0'`). Falls back to `'0.0.0'`.
|
|
114
|
+
*/
|
|
115
|
+
function getVersion() {
|
|
116
|
+
if (_version === null) {
|
|
117
|
+
try {
|
|
118
|
+
const pkgRoot = resolvePackageRoot()
|
|
119
|
+
const pkgPath = join(pkgRoot, 'package.json')
|
|
120
|
+
const raw = readFileSync(pkgPath, 'utf-8')
|
|
121
|
+
_version = JSON.parse(raw).version || '0.0.0'
|
|
122
|
+
} catch {
|
|
123
|
+
_version = '0.0.0'
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return _version
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Write helpers ───────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Writes one or more lines to stdout.
|
|
133
|
+
*
|
|
134
|
+
* @param {...string} lines - Lines to print.
|
|
135
|
+
*/
|
|
136
|
+
function write(...lines) {
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
console.log(line)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Public output API ───────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Prints the setup banner showing the package name and version.
|
|
146
|
+
*
|
|
147
|
+
* Example:
|
|
148
|
+
*
|
|
149
|
+
* ────────────────────────────────────────────────────────────
|
|
150
|
+
* @abarcenas/ai-workflow-template v1.29.0 — Setup
|
|
151
|
+
* ────────────────────────────────────────────────────────────
|
|
152
|
+
*/
|
|
153
|
+
export function header() {
|
|
154
|
+
const version = getVersion()
|
|
155
|
+
const width = Math.min(process.stdout.columns || 72, 72)
|
|
156
|
+
const sep = DIM + rule() + RST
|
|
157
|
+
const title =
|
|
158
|
+
` ${BLD}${PACKAGE_NAME}${RST}` +
|
|
159
|
+
` ${GRN}v${version}${RST}` +
|
|
160
|
+
` ${DIM}\u2014${RST}` +
|
|
161
|
+
` ${BLD}Setup${RST}`
|
|
162
|
+
|
|
163
|
+
write()
|
|
164
|
+
write(sep)
|
|
165
|
+
write(title)
|
|
166
|
+
write(sep)
|
|
167
|
+
write()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Prints a progress step indicator with a rightwards arrow.
|
|
172
|
+
*
|
|
173
|
+
* @param {string} message - Description of the step being performed.
|
|
174
|
+
* @example
|
|
175
|
+
* step('Checking environment…')
|
|
176
|
+
* // → Checking environment…
|
|
177
|
+
*/
|
|
178
|
+
export function step(message) {
|
|
179
|
+
write(` ${UNICODE_CHARS.ARROW} ${message}`)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Prints a step that completed successfully (checkmark).
|
|
184
|
+
*
|
|
185
|
+
* @param {string} message - Description of the completed step.
|
|
186
|
+
* @example
|
|
187
|
+
* stepSuccess('Environment ready')
|
|
188
|
+
* // ✓ Environment ready
|
|
189
|
+
*/
|
|
190
|
+
export function stepSuccess(message) {
|
|
191
|
+
write(` ${GRN}${UNICODE_CHARS.CHECK}${RST} ${message}`)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Prints a step that completed with a warning.
|
|
196
|
+
*
|
|
197
|
+
* @param {string} message - Warning description.
|
|
198
|
+
* @example
|
|
199
|
+
* stepWarn('Husky version is newer than expected')
|
|
200
|
+
* // ⚠ Husky version is newer than expected
|
|
201
|
+
*/
|
|
202
|
+
export function stepWarn(message) {
|
|
203
|
+
write(` ${YLW}${UNICODE_CHARS.WARN}${RST} ${message}`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Prints a step that failed.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} message - Error description.
|
|
210
|
+
* @example
|
|
211
|
+
* stepError('Git repository not found')
|
|
212
|
+
* // ✗ Git repository not found
|
|
213
|
+
*/
|
|
214
|
+
export function stepError(message) {
|
|
215
|
+
write(` ${RED}${UNICODE_CHARS.CROSS}${RST} ${message}`)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Prints an informational message (default colour, stdout).
|
|
220
|
+
*
|
|
221
|
+
* @param {string} message - Message to display.
|
|
222
|
+
*/
|
|
223
|
+
export function info(message) {
|
|
224
|
+
write(message)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Prints a warning message (yellow, bold) to stderr.
|
|
229
|
+
*
|
|
230
|
+
* @param {string} message - Warning message.
|
|
231
|
+
*/
|
|
232
|
+
export function warn(message) {
|
|
233
|
+
console.error(`${YLW}${BLD}warning${RST} ${message}`)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Prints an error message (red, bold) to stderr.
|
|
238
|
+
*
|
|
239
|
+
* @param {string} message - Error message.
|
|
240
|
+
*/
|
|
241
|
+
export function error(message) {
|
|
242
|
+
console.error(`${RED}${BLD}error${RST} ${message}`)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Prints a success message (green) to stdout.
|
|
247
|
+
*
|
|
248
|
+
* @param {string} message - Success message.
|
|
249
|
+
*/
|
|
250
|
+
export function success(message) {
|
|
251
|
+
write(`${GRN}${UNICODE_CHARS.CHECK}${RST} ${message}`)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Prints a bold section header.
|
|
256
|
+
*
|
|
257
|
+
* @param {string} title - Section title.
|
|
258
|
+
* @example
|
|
259
|
+
* section('Environment Discovery')
|
|
260
|
+
* // Environment Discovery
|
|
261
|
+
*/
|
|
262
|
+
export function section(title) {
|
|
263
|
+
write(`${BLD}${title}${RST}`)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Prints a formatted summary table from phase results.
|
|
268
|
+
*
|
|
269
|
+
* Each result entry is rendered as a single row in a text table with three
|
|
270
|
+
* columns: **Phase**, **Status**, and **Message**. The table is followed by
|
|
271
|
+
* an overall result line (success / warnings / errors).
|
|
272
|
+
*
|
|
273
|
+
* @param {Array<{ phase: string, status: string, message: string }>} results
|
|
274
|
+
* Phase results to display.
|
|
275
|
+
* - `phase` — Short phase name (e.g. `'discover'`, `'hooks'`)
|
|
276
|
+
* - `status` — One of `'success'`, `'warn'`, `'error'`, or a custom string
|
|
277
|
+
* - `message` — Human-readable result description
|
|
278
|
+
*
|
|
279
|
+
* @example
|
|
280
|
+
* summary([
|
|
281
|
+
* { phase: 'discover', status: 'success', message: 'Git repo found' },
|
|
282
|
+
* { phase: 'hooks', status: 'error', message: 'Permission denied' },
|
|
283
|
+
* ])
|
|
284
|
+
*/
|
|
285
|
+
export function summary(results) {
|
|
286
|
+
if (!results || results.length === 0) return
|
|
287
|
+
|
|
288
|
+
const headerPhase = 'Phase'
|
|
289
|
+
const headerStatus = 'Status'
|
|
290
|
+
const headerMessage = 'Message'
|
|
291
|
+
|
|
292
|
+
// ── Column widths ───────────────────────────────────────────────────
|
|
293
|
+
const phases = results.map((r) => r.phase)
|
|
294
|
+
const statuses = results.map((r) => r.status)
|
|
295
|
+
const colPhase = Math.max(headerPhase.length, ...phases.map((s) => s.length))
|
|
296
|
+
const colStatus = Math.max(headerStatus.length, ...statuses.map((s) => s.length))
|
|
297
|
+
const colMsg = Math.max(
|
|
298
|
+
headerMessage.length,
|
|
299
|
+
...results.map((r) => visibleWidth(r.message || '')),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// ── Status rendering ────────────────────────────────────────────────
|
|
303
|
+
/**
|
|
304
|
+
* Formats a status string with a colour-coded symbol.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} s - Status key.
|
|
307
|
+
* @returns {string} Formatted status (may contain ANSI codes).
|
|
308
|
+
*/
|
|
309
|
+
const fmtStatus = (s) => {
|
|
310
|
+
switch (s) {
|
|
311
|
+
case 'success':
|
|
312
|
+
return `${GRN}${UNICODE_CHARS.CHECK}${RST} success`
|
|
313
|
+
case 'warn':
|
|
314
|
+
return `${YLW}${UNICODE_CHARS.WARN}${RST} warn`
|
|
315
|
+
case 'error':
|
|
316
|
+
return `${RED}${UNICODE_CHARS.CROSS}${RST} error`
|
|
317
|
+
default:
|
|
318
|
+
return s
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Number of extra invisible chars added by the colour wrapper in fmtStatus
|
|
323
|
+
const statusAnsiExtra =
|
|
324
|
+
useColor && ['success', 'warn', 'error'].includes(statuses[0])
|
|
325
|
+
? // Each coloured status wraps the symbol in open + close = 2 escapes
|
|
326
|
+
// e.g. `\x1b[32m✓\x1b[0m success` = 5+5+8 = 18 visible, but 28 chars
|
|
327
|
+
// The extra is the ANSI codes length per status
|
|
328
|
+
10
|
|
329
|
+
: 0
|
|
330
|
+
|
|
331
|
+
// ── Print ───────────────────────────────────────────────────────────
|
|
332
|
+
write()
|
|
333
|
+
section('Setup Summary')
|
|
334
|
+
divider()
|
|
335
|
+
|
|
336
|
+
// Header
|
|
337
|
+
write(
|
|
338
|
+
` ${headerPhase.padEnd(colPhase)} ` +
|
|
339
|
+
`${headerStatus.padEnd(colStatus + statusAnsiExtra)} ` +
|
|
340
|
+
`${headerMessage}`,
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
// Separator
|
|
344
|
+
write(
|
|
345
|
+
` ${DIM}${'\u2500'.repeat(colPhase)}` +
|
|
346
|
+
` ${'\u2500'.repeat(Math.max(colStatus, 6))}` +
|
|
347
|
+
` ${'\u2500'.repeat(colMsg)}${RST}`,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
// Rows
|
|
351
|
+
for (const r of results) {
|
|
352
|
+
const p = r.phase.padEnd(colPhase)
|
|
353
|
+
const s = fmtStatus(r.status).padEnd(colStatus + statusAnsiExtra)
|
|
354
|
+
write(` ${p} ${s} ${r.message || ''}`)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
divider()
|
|
358
|
+
|
|
359
|
+
// Overall result
|
|
360
|
+
const hasErrors = results.some((r) => r.status === 'error')
|
|
361
|
+
const hasWarnings = results.some((r) => r.status === 'warn')
|
|
362
|
+
if (hasErrors) {
|
|
363
|
+
error('Setup completed with errors.')
|
|
364
|
+
} else if (hasWarnings) {
|
|
365
|
+
warn('Setup completed with warnings.')
|
|
366
|
+
} else {
|
|
367
|
+
success('Setup completed successfully.')
|
|
368
|
+
}
|
|
369
|
+
write()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Prints usage / help information showing all available CLI flags and their
|
|
374
|
+
* descriptions.
|
|
375
|
+
*/
|
|
376
|
+
export function help() {
|
|
377
|
+
const version = getVersion()
|
|
378
|
+
|
|
379
|
+
// ── Build flag display groups ───────────────────────────────────────
|
|
380
|
+
// Invert SUPPORTED_FLAGS to group short + long forms per canonical key.
|
|
381
|
+
/** @type {Record<string, string[]>} */
|
|
382
|
+
const groups = {}
|
|
383
|
+
for (const [flag, canonical] of Object.entries(SUPPORTED_FLAGS)) {
|
|
384
|
+
;(groups[canonical] || (groups[canonical] = [])).push(flag)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Sort each group so shorter flags come first, then build display text.
|
|
388
|
+
/** @type {Record<string, string>} */
|
|
389
|
+
const display = {}
|
|
390
|
+
let maxLen = 0
|
|
391
|
+
for (const [canonical, flags] of Object.entries(groups)) {
|
|
392
|
+
flags.sort((a, b) => a.length - b.length)
|
|
393
|
+
const text = flags.join(', ')
|
|
394
|
+
display[canonical] = text
|
|
395
|
+
maxLen = Math.max(maxLen, text.length)
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Ordered flag descriptions.
|
|
399
|
+
/** @type {Record<string, string>} */
|
|
400
|
+
const descriptions = {
|
|
401
|
+
force: 'Overwrite existing hooks (a .bak backup is created)',
|
|
402
|
+
dryRun: 'Show what would be done without making any changes',
|
|
403
|
+
yes: 'Skip confirmation prompts (non-interactive mode)',
|
|
404
|
+
skipHooks: 'Skip git hook installation phase',
|
|
405
|
+
skipPrepare: 'Skip prepare script modification phase',
|
|
406
|
+
skipSync: 'Skip file sync phase',
|
|
407
|
+
help: 'Show this help message and exit',
|
|
408
|
+
version: 'Show the version number and exit',
|
|
409
|
+
quiet: 'Suppress non-error output (quiet mode)',
|
|
410
|
+
verbose: 'Show detailed debug information',
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const order = [
|
|
414
|
+
'force',
|
|
415
|
+
'dryRun',
|
|
416
|
+
'yes',
|
|
417
|
+
'skipHooks',
|
|
418
|
+
'skipPrepare',
|
|
419
|
+
'skipSync',
|
|
420
|
+
'help',
|
|
421
|
+
'version',
|
|
422
|
+
'quiet',
|
|
423
|
+
'verbose',
|
|
424
|
+
]
|
|
425
|
+
|
|
426
|
+
// ── Print help text ─────────────────────────────────────────────────
|
|
427
|
+
write()
|
|
428
|
+
write(`${BLD}${PACKAGE_NAME}${RST} ${GRN}v${version}${RST}`)
|
|
429
|
+
write()
|
|
430
|
+
write(` ${BLD}Usage:${RST}`)
|
|
431
|
+
write(` npx ${PACKAGE_NAME} setup [options]`)
|
|
432
|
+
write(` ${BIN_NAME} [options]`)
|
|
433
|
+
write()
|
|
434
|
+
write(` ${BLD}Description:${RST}`)
|
|
435
|
+
write(
|
|
436
|
+
` Sets up a consumer project with git hooks, memory-bank scaffolding,` +
|
|
437
|
+
` and AI workflow`,
|
|
438
|
+
)
|
|
439
|
+
write(` integration for the ${PACKAGE_NAME} framework.`)
|
|
440
|
+
write()
|
|
441
|
+
write(` ${BLD}Options:${RST}`)
|
|
442
|
+
|
|
443
|
+
for (const key of order) {
|
|
444
|
+
const d = display[key]
|
|
445
|
+
const desc = descriptions[key] || ''
|
|
446
|
+
if (d) {
|
|
447
|
+
write(` ${d.padEnd(maxLen)} ${desc}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
write()
|
|
452
|
+
write(` ${BLD}Examples:${RST}`)
|
|
453
|
+
write(` npx ${PACKAGE_NAME} setup`)
|
|
454
|
+
write(` npx ${PACKAGE_NAME} setup --dry-run --verbose`)
|
|
455
|
+
write(` npx ${PACKAGE_NAME} setup --force --skip-hooks`)
|
|
456
|
+
write(` ${BIN_NAME} --help`)
|
|
457
|
+
write()
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* Prints a horizontal rule for visual separation.
|
|
462
|
+
*/
|
|
463
|
+
export function divider() {
|
|
464
|
+
write(` ${DIM}${rule()}${RST}`)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Prints a prominent banner indicating that `--dry-run` mode is active and no
|
|
469
|
+
* files will be modified.
|
|
470
|
+
*/
|
|
471
|
+
export function dryRunBanner() {
|
|
472
|
+
const width = Math.min(process.stdout.columns || 72, 72)
|
|
473
|
+
const sep = YLW + rule() + RST
|
|
474
|
+
write()
|
|
475
|
+
write(sep)
|
|
476
|
+
write(
|
|
477
|
+
` ${YLW}${UNICODE_CHARS.WARN}${RST}` +
|
|
478
|
+
` ${BLD}DRY RUN MODE${RST}` +
|
|
479
|
+
` ${DIM}\u2014${RST} No files will be modified.` +
|
|
480
|
+
` ${YLW}${UNICODE_CHARS.WARN}${RST}`,
|
|
481
|
+
)
|
|
482
|
+
write(sep)
|
|
483
|
+
write()
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Prints a banner indicating CI mode is detected and git hooks will be
|
|
488
|
+
* skipped.
|
|
489
|
+
*/
|
|
490
|
+
export function ciModeBanner() {
|
|
491
|
+
const width = Math.min(process.stdout.columns || 72, 72)
|
|
492
|
+
const sep = CYN + rule() + RST
|
|
493
|
+
write()
|
|
494
|
+
write(sep)
|
|
495
|
+
write(
|
|
496
|
+
` ${CYN}${UNICODE_CHARS.BULLET}${RST}` +
|
|
497
|
+
` ${BLD}CI MODE DETECTED${RST}` +
|
|
498
|
+
` ${DIM}\u2014${RST} Git hooks will be skipped.` +
|
|
499
|
+
` Use ${CYN}--skip-hooks${RST} to suppress this message.`,
|
|
500
|
+
)
|
|
501
|
+
write(sep)
|
|
502
|
+
write()
|
|
503
|
+
}
|