@abarcenas/ai-workflow-template 1.29.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/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,371 @@
|
|
|
1
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
2
|
+
// utils.js — Shared utility functions for the setup command pipeline
|
|
3
|
+
// ═════════════════════════════════════════════════════════════════════════════
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'crypto'
|
|
6
|
+
import {
|
|
7
|
+
appendFileSync,
|
|
8
|
+
chmodSync,
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
statSync,
|
|
13
|
+
writeFileSync,
|
|
14
|
+
} from 'fs'
|
|
15
|
+
import { dirname, join, resolve } from 'path'
|
|
16
|
+
import { fileURLToPath } from 'url'
|
|
17
|
+
|
|
18
|
+
import { CI_ENV_VARS, SUPPORTED_FLAGS } from './constants.js'
|
|
19
|
+
|
|
20
|
+
// ── Path resolution ──────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolves the consumer project root directory.
|
|
24
|
+
*
|
|
25
|
+
* Priority:
|
|
26
|
+
* 1. `INIT_CWD` environment variable (set by npm/npx) — resolved to absolute
|
|
27
|
+
* 2. `process.cwd()` — fallback with a warning
|
|
28
|
+
*
|
|
29
|
+
* @returns {string} Absolute path to the consumer's project root.
|
|
30
|
+
*/
|
|
31
|
+
export function getConsumerRoot() {
|
|
32
|
+
const initCwd = process.env.INIT_CWD
|
|
33
|
+
if (initCwd) {
|
|
34
|
+
return resolve(initCwd)
|
|
35
|
+
}
|
|
36
|
+
logWarn(
|
|
37
|
+
'[setup] INIT_CWD is not set; falling back to process.cwd(). ' +
|
|
38
|
+
'Consumer root detection may be inaccurate.',
|
|
39
|
+
)
|
|
40
|
+
return process.cwd()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the package root (where this package is installed) by walking up
|
|
45
|
+
* from the current module's location.
|
|
46
|
+
*
|
|
47
|
+
* Uses `import.meta.url` to determine the filesystem path of this file,
|
|
48
|
+
* assumes it lives at `<packageRoot>/scripts/setup/utils.js`, and walks up
|
|
49
|
+
* two directories.
|
|
50
|
+
*
|
|
51
|
+
* @returns {string} Absolute path to the package root.
|
|
52
|
+
*/
|
|
53
|
+
export function resolvePackageRoot() {
|
|
54
|
+
const thisFile = fileURLToPath(import.meta.url)
|
|
55
|
+
const thisDir = dirname(thisFile) // .../scripts/setup
|
|
56
|
+
return resolve(thisDir, '..', '..') // .../
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Joins the consumer project root with the provided path segments to produce
|
|
61
|
+
* an absolute path.
|
|
62
|
+
*
|
|
63
|
+
* @param {...string} segments - Path segments to join beneath the consumer root.
|
|
64
|
+
* @returns {string} Absolute path.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveConsumerPath(...segments) {
|
|
67
|
+
return resolve(getConsumerRoot(), ...segments)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── File system helpers ──────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Creates a directory and its parents if they do not already exist
|
|
74
|
+
* (equivalent to `mkdir -p`).
|
|
75
|
+
*
|
|
76
|
+
* @param {string} dirPath - Absolute or relative directory path.
|
|
77
|
+
* @returns {boolean} `true` if the directory exists or was created.
|
|
78
|
+
* @throws {Error} If a non-EEXIST filesystem error occurs.
|
|
79
|
+
*/
|
|
80
|
+
export function ensureDir(dirPath) {
|
|
81
|
+
try {
|
|
82
|
+
mkdirSync(dirPath, { recursive: true })
|
|
83
|
+
return true
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// EEXIST is expected when the directory already exists
|
|
86
|
+
if (err.code === 'EEXIST') return true
|
|
87
|
+
throw err
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Reads a file from disk and returns its contents as a UTF-8 string.
|
|
93
|
+
*
|
|
94
|
+
* Returns `null` when the file does not exist (ENOENT). All other errors
|
|
95
|
+
* are re-thrown so callers can handle unexpected failures explicitly.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} filePath - Absolute path to the file.
|
|
98
|
+
* @returns {string|null} File contents, or `null` if the file does not exist.
|
|
99
|
+
*/
|
|
100
|
+
export function safeReadFile(filePath) {
|
|
101
|
+
try {
|
|
102
|
+
return readFileSync(filePath, 'utf-8')
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (err.code === 'ENOENT') return null
|
|
105
|
+
throw err
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Writes content to a file, creating parent directories if necessary.
|
|
111
|
+
*
|
|
112
|
+
* Handles permission-denied and read-only filesystem errors gracefully by
|
|
113
|
+
* returning `false` instead of throwing.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} filePath - Absolute path to the file.
|
|
116
|
+
* @param {string|Buffer} content - Content to write.
|
|
117
|
+
* @param {object} [options] - Write options (encoding, mode, etc.).
|
|
118
|
+
* @returns {boolean} `true` on successful write, `false` on EACCES/EROFS.
|
|
119
|
+
* @throws {Error} If an unexpected filesystem error occurs.
|
|
120
|
+
*/
|
|
121
|
+
export function safeWriteFile(filePath, content, options = {}) {
|
|
122
|
+
try {
|
|
123
|
+
ensureDir(dirname(filePath))
|
|
124
|
+
writeFileSync(filePath, content, options)
|
|
125
|
+
return true
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err.code === 'EACCES' || err.code === 'EROFS') return false
|
|
128
|
+
throw err
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Appends content to a file, creating parent directories and the file itself
|
|
134
|
+
* if they do not yet exist.
|
|
135
|
+
*
|
|
136
|
+
* @param {string} filePath - Absolute path to the file.
|
|
137
|
+
* @param {string} content - Content to append.
|
|
138
|
+
* @throws {Error} If a filesystem error occurs.
|
|
139
|
+
*/
|
|
140
|
+
export function safeAppendFile(filePath, content) {
|
|
141
|
+
ensureDir(dirname(filePath))
|
|
142
|
+
appendFileSync(filePath, content, 'utf-8')
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Checks whether a path exists on the filesystem.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} filePath - Path to check.
|
|
149
|
+
* @returns {boolean} `true` if the path exists (file, directory, symlink, etc.).
|
|
150
|
+
*/
|
|
151
|
+
export function fileExists(filePath) {
|
|
152
|
+
return existsSync(filePath)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Checks whether a path exists AND is a directory.
|
|
157
|
+
*
|
|
158
|
+
* @param {string} dirPath - Path to check.
|
|
159
|
+
* @returns {boolean} `true` if the path exists and is a directory.
|
|
160
|
+
*/
|
|
161
|
+
export function dirExists(dirPath) {
|
|
162
|
+
try {
|
|
163
|
+
return statSync(dirPath).isDirectory()
|
|
164
|
+
} catch {
|
|
165
|
+
return false
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── File inspection ──────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Checks whether a file is a shell script by reading its first line and testing
|
|
173
|
+
* for a `#!` (shebang) prefix.
|
|
174
|
+
*
|
|
175
|
+
* @param {string} filePath - Absolute path to the file.
|
|
176
|
+
* @returns {boolean} `true` if the first line starts with `#!`.
|
|
177
|
+
*/
|
|
178
|
+
export function isShellScript(filePath) {
|
|
179
|
+
try {
|
|
180
|
+
const firstLine = readFileSync(filePath, 'utf-8').split('\n')[0]
|
|
181
|
+
return firstLine != null && firstLine.startsWith('#!')
|
|
182
|
+
} catch {
|
|
183
|
+
return false
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Sets the executable permission bits (0755 / rwxr-xr-x) on a file.
|
|
189
|
+
*
|
|
190
|
+
* @param {string} filePath - Absolute path to the file.
|
|
191
|
+
* @throws {Error} If the file does not exist or permissions cannot be changed.
|
|
192
|
+
*/
|
|
193
|
+
export function chmodX(filePath) {
|
|
194
|
+
chmodSync(filePath, 0o755)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── JSON helpers ─────────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Reads and parses a JSON file.
|
|
201
|
+
*
|
|
202
|
+
* Returns `undefined` when the file cannot be read or parsed (ENOENT,
|
|
203
|
+
* JSON syntax errors, etc.) so callers can safely attempt optional reads.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} filePath - Absolute path to the JSON file.
|
|
206
|
+
* @returns {object|undefined} Parsed object, or `undefined` on failure.
|
|
207
|
+
*/
|
|
208
|
+
export function safeReadJson(filePath) {
|
|
209
|
+
try {
|
|
210
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
211
|
+
return JSON.parse(content)
|
|
212
|
+
} catch {
|
|
213
|
+
return undefined
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Writes an object to a JSON file with 2-space indentation and a trailing
|
|
219
|
+
* newline. Creates parent directories if needed.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} filePath - Absolute path to the JSON file.
|
|
222
|
+
* @param {object} obj - Object to serialize.
|
|
223
|
+
* @throws {Error} If the file cannot be written.
|
|
224
|
+
*/
|
|
225
|
+
export function safeWriteJson(filePath, obj) {
|
|
226
|
+
const content = JSON.stringify(obj, null, 2) + '\n'
|
|
227
|
+
safeWriteFile(filePath, content)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ── Hashing ──────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Computes the SHA-256 hex digest of a string.
|
|
234
|
+
*
|
|
235
|
+
* Useful for comparing file contents without holding them in memory.
|
|
236
|
+
*
|
|
237
|
+
* @param {string} content - The string content to hash.
|
|
238
|
+
* @returns {string} Lowercase hex-encoded SHA-256 digest.
|
|
239
|
+
*/
|
|
240
|
+
export function hashContent(content) {
|
|
241
|
+
return createHash('sha256').update(content).digest('hex')
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Environment detection ────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Detects whether the command is running inside a CI/CD pipeline.
|
|
248
|
+
*
|
|
249
|
+
* Checks every environment variable name from `CI_ENV_VARS`:
|
|
250
|
+
* - `NODE_ENV` is tested for strict equality to `'production'`
|
|
251
|
+
* - All others are tested for truthiness
|
|
252
|
+
*
|
|
253
|
+
* @returns {boolean} `true` if any CI indicator is found.
|
|
254
|
+
*/
|
|
255
|
+
export function isCI() {
|
|
256
|
+
return CI_ENV_VARS.some((name) => {
|
|
257
|
+
const value = process.env[name]
|
|
258
|
+
if (name === 'NODE_ENV') return value === 'production'
|
|
259
|
+
return !!value
|
|
260
|
+
})
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Returns the consumer's major Node.js version as an integer.
|
|
265
|
+
*
|
|
266
|
+
* Parses `process.version` (e.g. `"v18.17.1"` → `18`).
|
|
267
|
+
*
|
|
268
|
+
* @returns {number} Major Node.js version.
|
|
269
|
+
*/
|
|
270
|
+
export function getNodeVersion() {
|
|
271
|
+
return parseInt(process.version.slice(1).split('.')[0], 10)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── CLI argument parsing ────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Builds a default flags object with every supported flag key set to `false`.
|
|
278
|
+
*
|
|
279
|
+
* @returns {object} Flags object.
|
|
280
|
+
*/
|
|
281
|
+
function defaultFlags() {
|
|
282
|
+
const keys = [...new Set(Object.values(SUPPORTED_FLAGS))]
|
|
283
|
+
return Object.fromEntries(keys.map((k) => [k, false]))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Parses CLI arguments (`argv` array) into a structured flags object.
|
|
288
|
+
*
|
|
289
|
+
* Handles these argument formats:
|
|
290
|
+
* `--flag` → boolean `true`
|
|
291
|
+
* `--flag=value` → string value (for non-boolean flags)
|
|
292
|
+
* `--no-flag` → boolean `false`
|
|
293
|
+
* `-f` (short form) → boolean `true` (mapped via `SUPPORTED_FLAGS`)
|
|
294
|
+
*
|
|
295
|
+
* Unrecognised arguments are silently ignored.
|
|
296
|
+
*
|
|
297
|
+
* @param {string[]} argv - Array of CLI arguments (typically `process.argv.slice(2)`).
|
|
298
|
+
* @returns {object} Flags object with all supported keys set.
|
|
299
|
+
*
|
|
300
|
+
* @example
|
|
301
|
+
* parseCliArgs(['--force', '--dry-run', '--skip-hooks'])
|
|
302
|
+
* // → { force: true, dryRun: true, yes: false, skipHooks: true, skipPrepare: false,
|
|
303
|
+
* // skipSync: false, help: false, version: false, quiet: false, verbose: false }
|
|
304
|
+
*/
|
|
305
|
+
export function parseCliArgs(argv) {
|
|
306
|
+
const flags = defaultFlags()
|
|
307
|
+
|
|
308
|
+
for (const arg of argv) {
|
|
309
|
+
// --no-flag-name → flagName: false
|
|
310
|
+
if (arg.startsWith('--no-')) {
|
|
311
|
+
const dashed = arg.slice(5) // remove '--no-'
|
|
312
|
+
const camel = dashed.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
|
|
313
|
+
if (camel in flags) {
|
|
314
|
+
flags[camel] = false
|
|
315
|
+
}
|
|
316
|
+
continue
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// --flag=value → property = value (as string)
|
|
320
|
+
if (arg.includes('=')) {
|
|
321
|
+
const eqIdx = arg.indexOf('=')
|
|
322
|
+
const raw = arg.slice(0, eqIdx)
|
|
323
|
+
const value = arg.slice(eqIdx + 1)
|
|
324
|
+
const mapped = SUPPORTED_FLAGS[raw]
|
|
325
|
+
if (mapped) {
|
|
326
|
+
flags[mapped] = value
|
|
327
|
+
}
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// --flag or -f → property: true
|
|
332
|
+
const mapped = SUPPORTED_FLAGS[arg]
|
|
333
|
+
if (mapped) {
|
|
334
|
+
flags[mapped] = true
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return flags
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ── Logging wrappers ─────────────────────────────────────────────────────────
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Logs an error message to stderr.
|
|
345
|
+
* Will be enhanced with colour formatting by ui.js in the future.
|
|
346
|
+
*
|
|
347
|
+
* @param {string} message - Error message.
|
|
348
|
+
*/
|
|
349
|
+
export function logError(message) {
|
|
350
|
+
console.error(message)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Logs a warning message to stderr.
|
|
355
|
+
* Will be enhanced with colour formatting by ui.js in the future.
|
|
356
|
+
*
|
|
357
|
+
* @param {string} message - Warning message.
|
|
358
|
+
*/
|
|
359
|
+
export function logWarn(message) {
|
|
360
|
+
console.warn(message)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Logs an informational message to stdout.
|
|
365
|
+
* Will be enhanced with colour formatting by ui.js in the future.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} message - Info message.
|
|
368
|
+
*/
|
|
369
|
+
export function logInfo(message) {
|
|
370
|
+
console.log(message)
|
|
371
|
+
}
|