cache-verdict 0.0.0-stage → 0.1.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/lib/config.mjs ADDED
@@ -0,0 +1,141 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstatSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ export const CONFIG_FILENAME = '.cache-verdict.json';
6
+ export const CONFIG_VERSION = 1;
7
+
8
+ /**
9
+ * Turn a detection result into the config a human reviews before sampling.
10
+ * The override is kept separate so detection stays reusable by `run`.
11
+ *
12
+ * @param {ReturnType<import('./detect.mjs').detectProject>} detection
13
+ * @param {{ command?: string }} [overrides]
14
+ */
15
+ export function buildConfig(detection, overrides = {}) {
16
+ if (detection === null || typeof detection !== 'object') {
17
+ throw new Error('cannot build a config without a detection result');
18
+ }
19
+
20
+ const command = overrides.command === undefined ? detection.command : overrides.command;
21
+ if (typeof command !== 'string' || command.trim() === '') {
22
+ throw new Error('build command must be a non-empty string');
23
+ }
24
+
25
+ const config = {
26
+ schemaVersion: CONFIG_VERSION,
27
+ ecosystem: detection.ecosystem,
28
+ displayName: detection.displayName,
29
+ command: command.trim(),
30
+ inputs: [...detection.inputs],
31
+ cachePath: [...detection.cachePath],
32
+ detectedFrom: [...detection.detectedFrom],
33
+ };
34
+
35
+ const notes = [...(detection.notes ?? [])];
36
+ if (notes.length > 0) config.notes = notes;
37
+
38
+ return config;
39
+ }
40
+
41
+ export function serializeConfig(config) {
42
+ return `${JSON.stringify(config, null, 2)}\n`;
43
+ }
44
+
45
+ function stringArray(value, field, source) {
46
+ if (value === undefined) return [];
47
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
48
+ throw new Error(`${source}: "${field}" must be an array of strings`);
49
+ }
50
+ return [...value];
51
+ }
52
+
53
+ /**
54
+ * Load and minimally validate the reviewed config. `run`, `report`, `check`,
55
+ * and `prune` all read the same file, so validation lives here.
56
+ *
57
+ * @param {string} directory absolute project directory
58
+ */
59
+ export function loadConfig(directory) {
60
+ const target = path.join(directory, CONFIG_FILENAME);
61
+ let text;
62
+ try {
63
+ text = readFileSync(target, 'utf8');
64
+ } catch (error) {
65
+ if (error.code === 'ENOENT') {
66
+ throw new Error(`no ${CONFIG_FILENAME} in ${directory}; run "cache-verdict init" first`);
67
+ }
68
+ throw new Error(`cannot read ${target}: ${error.message}`);
69
+ }
70
+
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(text.replace(/^\uFEFF/, ''));
74
+ } catch (error) {
75
+ throw new Error(`cannot parse ${target}: ${error.message}`);
76
+ }
77
+
78
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
79
+ throw new Error(`${target} must contain a JSON object`);
80
+ }
81
+ if (typeof parsed.command !== 'string' || parsed.command.trim() === '') {
82
+ throw new Error(`${target}: "command" must be a non-empty string`);
83
+ }
84
+
85
+ return {
86
+ ...parsed,
87
+ command: parsed.command.trim(),
88
+ inputs: stringArray(parsed.inputs, 'inputs', target),
89
+ cachePath: stringArray(parsed.cachePath, 'cachePath', target),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Write the config atomically. An existing file is only replaced with
95
+ * `force`, so `init` can never silently discard a reviewed configuration.
96
+ *
97
+ * @param {string} directory
98
+ * @param {object} config
99
+ * @param {{ force?: boolean }} [options]
100
+ * @returns {string} the path that was written
101
+ */
102
+ export function writeConfig(directory, config, options = {}) {
103
+ const force = options.force === true;
104
+ const target = path.join(directory, CONFIG_FILENAME);
105
+
106
+ let existing;
107
+ try {
108
+ existing = lstatSync(target);
109
+ } catch (error) {
110
+ if (error.code !== 'ENOENT') {
111
+ throw new Error(`cannot inspect ${target}: ${error.message}`);
112
+ }
113
+ existing = undefined;
114
+ }
115
+
116
+ if (existing !== undefined) {
117
+ if (existing.isDirectory()) {
118
+ throw new Error(`cannot overwrite ${target}: it is a directory`);
119
+ }
120
+ if (!force) {
121
+ throw new Error(
122
+ `refusing to overwrite existing ${CONFIG_FILENAME} in ${directory}; use --force to replace it`,
123
+ );
124
+ }
125
+ }
126
+
127
+ const temp = path.join(directory, `.${CONFIG_FILENAME}.${process.pid}.${randomUUID()}.tmp`);
128
+ try {
129
+ writeFileSync(temp, serializeConfig(config), { encoding: 'utf8', flag: 'wx', mode: 0o644 });
130
+ renameSync(temp, target);
131
+ } catch (error) {
132
+ try {
133
+ unlinkSync(temp);
134
+ } catch {
135
+ // The temporary file may never have been created; the real error is below.
136
+ }
137
+ throw new Error(`cannot write ${target}: ${error.message}`);
138
+ }
139
+
140
+ return target;
141
+ }