Skip to content

Commit 127fc0a

Browse files
committed
feat: pass config block to config fn, allow custom sentinels
1 parent ebbf59b commit 127fc0a

File tree

5 files changed

+348
-69
lines changed

5 files changed

+348
-69
lines changed

README.md

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,17 @@ Most projects keep their file definitions in a dedicated module and call the bun
6969
}
7070
```
7171

72-
The CLI walks up from the current working directory, finds the nearest `package.json`, and reads the `cpconfig`
73-
definition (preferring `config.cpconfig`). When the value is a string, `cpconfig` resolves and imports that module
74-
using standard Node.js resolution. The module can export either:
72+
The CLI walks up from the current working directory, finds the nearest `package.json`, and reads the
73+
`config.cpconfig` entry. The value must be a string pointing at a module—top-level `cpconfig` keys or inline
74+
objects are rejected. `cpconfig` resolves the module using standard Node.js rules and expects it to export one of:
7575

7676
- a default or named `config` object with `{ files, options }`;
7777
- a plain object map of files;
7878
- a factory function (sync or async) returning either of the above.
7979

80+
Factory functions receive two arguments: the entire `package.json` `config` object (so you can read sibling settings)
81+
and the raw CLI argument array (e.g. `['--json']`). Use them to branch on runtime options or shared configurations.
82+
8083
For example, `cpconfig.config.mjs` might look like:
8184

8285
```js
@@ -88,9 +91,6 @@ export default {
8891
};
8992
```
9093

91-
If you prefer, you can still embed the object directly in `package.json` under either `cpconfig` or `config.cpconfig`.
92-
Module exports simply provide a cleaner home for larger definitions or shared packages.
93-
9494
- Run `npx cpconfig` manually whenever you need to refresh files.
9595
- Add `cpconfig` to `postinstall` to keep developer machines in sync automatically.
9696

@@ -104,7 +104,10 @@ Module exports simply provide a cleaner home for larger definitions or shared pa
104104
| `gitignorePath` | `string` | `<rootDir>/.gitignore` | Custom location for the managed gitignore file. |
105105

106106
Each config file can optionally set `gitignore: false` to opt out of the managed block, or provide a `mode`
107-
(integer) that is applied when the file is first created.
107+
(integer) that is applied when the file is first created. Add a `sentinel` string to embed your own marker
108+
inside the file (for example `// @cpconfig` or `<!-- cpconfig -->`). `cpconfig` only rewrites files that include
109+
their sentinel, so you never clobber hand-crafted files. Make sure the string appears in the requested contents.
110+
When an existing file is missing its sentinel, `cpconfig` leaves it untouched and prints a warning explaining why.
108111

109112
## Result object
110113

@@ -113,7 +116,7 @@ Each config file can optionally set `gitignore: false` to opt out of the managed
113116
```ts
114117
const result = await syncConfigs(files);
115118

116-
result.files; // [{ path: 'config/.env.local', action: 'created', gitignored: true }, ...]
119+
result.files; // [{ path: 'config/.env.local', managed: true, action: 'created', gitignored: true }, ...]
117120
result.gitignore; // { updated: true, added: ['config/.env.local'], removed: [] }
118121
```
119122

src/cli.spec.ts

Lines changed: 132 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,20 @@ const packageTemplate = {
1212
describe('cli', () => {
1313
test('applies configuration sourced from package.json', async () => {
1414
await withTempDir(async (cwd) => {
15+
const modulePath = path.join(cwd, 'cpconfig.config.mjs');
16+
17+
await writeFile(
18+
modulePath,
19+
`export default {\n files: {\n 'secrets/.env': { contents: 'SECRET=1' },\n 'config/app.json': { contents: '{"flag":true}' }\n }\n};\n`,
20+
);
21+
1522
await writeFile(
1623
path.join(cwd, 'package.json'),
1724
JSON.stringify(
1825
{
1926
...packageTemplate,
20-
cpconfig: {
21-
files: {
22-
'secrets/.env': { contents: 'SECRET=1' },
23-
'config/app.json': { contents: '{"flag":true}' },
24-
},
27+
config: {
28+
cpconfig: './cpconfig.config.mjs',
2529
},
2630
},
2731
null,
@@ -97,15 +101,19 @@ describe('cli', () => {
97101

98102
await writeFile(
99103
modulePath,
100-
`export default async function buildConfig() {\n await Promise.resolve();\n return {\n files: {\n 'factory.txt': { contents: 'from factory' }\n },\n options: {\n gitignorePath: 'generated.ignore'\n }\n };\n}\n`,
104+
`export default async function buildConfig(config, cliArgs) {\n await Promise.resolve();\n return {\n files: {\n 'factory.txt': { contents: JSON.stringify({ config, cliArgs }) }\n },\n options: {\n gitignorePath: config?.output ?? 'generated.ignore'\n }\n };\n}\n`,
101105
);
102106

103107
await writeFile(
104108
path.join(cwd, 'package.json'),
105109
JSON.stringify(
106110
{
107111
...packageTemplate,
108-
cpconfig: './cpconfig.factory.mjs',
112+
config: {
113+
cpconfig: './cpconfig.factory.mjs',
114+
output: 'generated.ignore',
115+
feature: 'enabled',
116+
},
109117
},
110118
null,
111119
2,
@@ -115,29 +123,49 @@ describe('cli', () => {
115123
const stdout = createBuffer();
116124
const stderr = createBuffer();
117125

118-
const exitCode = await runCli([], { cwd, stdout, stderr });
126+
const exitCode = await runCli(['--json'], { cwd, stdout, stderr });
119127

120128
expect(exitCode).toBe(0);
121129
expect(stderr.toString()).toBe('');
122130

123-
await expect(readFile(path.join(cwd, 'factory.txt'), 'utf8')).resolves.toBe('from factory');
131+
const fileContents = await readFile(path.join(cwd, 'factory.txt'), 'utf8');
132+
const deserialised = JSON.parse(fileContents) as {
133+
config: Record<string, unknown>;
134+
cliArgs: string[];
135+
};
136+
137+
expect(deserialised.config).toMatchObject({
138+
cpconfig: './cpconfig.factory.mjs',
139+
feature: 'enabled',
140+
output: 'generated.ignore',
141+
});
142+
expect(deserialised.cliArgs).toEqual(['--json']);
143+
124144
const gitignore = await readFile(path.join(cwd, 'generated.ignore'), 'utf8');
125145
expect(gitignore).toContain('factory.txt');
126-
expect(stdout.toString()).toContain('cpconfig.factory.mjs');
146+
const stdoutJson = JSON.parse(stdout.toString()) as {
147+
gitignore: { path: string };
148+
};
149+
expect(stdoutJson.gitignore.path).toContain('generated.ignore');
127150
});
128151
});
129152

130153
test('supports dry runs without writing files', async () => {
131154
await withTempDir(async (cwd) => {
155+
const modulePath = path.join(cwd, 'cpconfig.dry.mjs');
156+
157+
await writeFile(
158+
modulePath,
159+
`export default {\n files: {\n 'generated.txt': { contents: 'dry' }\n }\n};\n`,
160+
);
161+
132162
await writeFile(
133163
path.join(cwd, 'package.json'),
134164
JSON.stringify(
135165
{
136166
...packageTemplate,
137167
config: {
138-
cpconfig: {
139-
'generated.txt': { contents: 'dry' },
140-
},
168+
cpconfig: './cpconfig.dry.mjs',
141169
},
142170
},
143171
null,
@@ -167,7 +195,97 @@ describe('cli', () => {
167195
const exitCode = await runCli([], { cwd, stderr, stdout: createBuffer() });
168196

169197
expect(exitCode).toBe(1);
170-
expect(stderr.toString()).toMatch(/No cpconfig definition/);
198+
expect(stderr.toString()).toMatch(
199+
/Expected config\.cpconfig to reference a module using a string/,
200+
);
201+
});
202+
});
203+
204+
test('warns when an existing file is missing the configured sentinel', async () => {
205+
await withTempDir(async (cwd) => {
206+
const modulePath = path.join(cwd, 'cpconfig.sentinel.mjs');
207+
208+
await writeFile(
209+
modulePath,
210+
`export default {\n files: {\n 'managed.txt': { contents: '// sentinel\\nmanaged=true\\n', sentinel: '// sentinel' }\n }\n};\n`,
211+
);
212+
213+
await writeFile(
214+
path.join(cwd, 'package.json'),
215+
JSON.stringify(
216+
{
217+
...packageTemplate,
218+
config: {
219+
cpconfig: './cpconfig.sentinel.mjs',
220+
},
221+
},
222+
null,
223+
2,
224+
),
225+
);
226+
227+
const existingPath = path.join(cwd, 'managed.txt');
228+
await writeFile(existingPath, 'managed=false\n', 'utf8');
229+
230+
const stdout = createBuffer();
231+
const stderr = createBuffer();
232+
233+
const exitCode = await runCli([], { cwd, stdout, stderr });
234+
235+
expect(exitCode).toBe(0);
236+
expect(stderr.toString()).toMatch(/Not overwriting "managed.txt"/);
237+
238+
await expect(readFile(existingPath, 'utf8')).resolves.toBe('managed=false\n');
239+
await expect(readFile(path.join(cwd, '.gitignore'), 'utf8')).rejects.toThrow();
240+
expect(stdout.toString()).toContain('managed.txt (unmanaged)');
241+
});
242+
});
243+
244+
test('emits helpful errors when cpconfig is defined at the package root', async () => {
245+
await withTempDir(async (cwd) => {
246+
await writeFile(
247+
path.join(cwd, 'package.json'),
248+
JSON.stringify(
249+
{
250+
...packageTemplate,
251+
cpconfig: './cpconfig.config.mjs',
252+
},
253+
null,
254+
2,
255+
),
256+
);
257+
258+
const stderr = createBuffer();
259+
const exitCode = await runCli([], { cwd, stderr, stdout: createBuffer() });
260+
261+
expect(exitCode).toBe(1);
262+
expect(stderr.toString()).toMatch(/Move the value to config\.cpconfig/);
263+
});
264+
});
265+
266+
test('emits helpful errors when config.cpconfig is not a string', async () => {
267+
await withTempDir(async (cwd) => {
268+
await writeFile(
269+
path.join(cwd, 'package.json'),
270+
JSON.stringify(
271+
{
272+
...packageTemplate,
273+
config: {
274+
cpconfig: { invalid: true },
275+
},
276+
},
277+
null,
278+
2,
279+
),
280+
);
281+
282+
const stderr = createBuffer();
283+
const exitCode = await runCli([], { cwd, stderr, stdout: createBuffer() });
284+
285+
expect(exitCode).toBe(1);
286+
expect(stderr.toString()).toMatch(
287+
/Expected config\.cpconfig to be a non-empty string module specifier/,
288+
);
171289
});
172290
});
173291
});

0 commit comments

Comments
 (0)