@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.
@@ -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
+ }