@davidvornholt/standards 0.20.0 → 0.21.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/src/dev-env.ts CHANGED
@@ -1,89 +1,107 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
3
2
  import { join } from 'node:path';
3
+ import { composeDevEnv } from './dev-env-compose';
4
4
  import {
5
+ type DevEnvRemoval,
5
6
  type DevEnvWrite,
6
7
  devEnvDestinationProblems,
7
8
  } from './dev-env-destination';
9
+ import { devEnvGitIgnoreProblem } from './dev-env-destination-gitignore';
8
10
  import { parseDevEnvDocument } from './dev-env-document';
9
11
  import { renderDotenv } from './dev-env-dotenv';
10
- import { writeDevEnvFiles } from './dev-env-transaction';
11
- import { runSops } from './sops-exec';
12
+ import { planDevEnvRemovals } from './dev-env-reconciliation';
13
+ import { DEV_SECRETS_FILE, readDevSecrets } from './dev-env-secrets';
14
+ import { applyDevEnvChanges } from './dev-env-transaction';
15
+ import { parseYaml } from './yaml-parse';
12
16
 
13
- export const DEV_SECRETS_FILE = 'secrets/dev.yaml';
17
+ export const DEV_CONFIG_FILE = 'config/dev.yaml';
18
+ export const DEV_LOCAL_FILE = 'config/dev.local.yaml';
14
19
 
15
- const SOPS_ARGS = ['--decrypt', '--output-type', 'json', DEV_SECRETS_FILE];
20
+ export type DevEnvPlainInput = { readonly raw: unknown } | null;
16
21
 
17
- type DecryptResult =
18
- | { readonly ok: true; readonly value: unknown }
19
- | { readonly ok: false; readonly problem: string };
22
+ export type DevEnvInputs = {
23
+ readonly config: DevEnvPlainInput;
24
+ readonly secrets: unknown;
25
+ readonly local: DevEnvPlainInput;
26
+ };
27
+
28
+ const isMissingPathError = (error: unknown): boolean =>
29
+ (error as { readonly code?: unknown } | null)?.code === 'ENOENT';
20
30
 
21
- // sops emits JSON here so the CLI never parses the encrypted YAML itself.
22
- const decryptDevSecrets = (consumer: string): DecryptResult => {
23
- const result = runSops(SOPS_ARGS, consumer);
24
- if (result.status !== 0) {
25
- const detail = result.errorMessage ?? result.stderr.trim();
31
+ const readPlainLayer = (consumer: string, rel: string) => {
32
+ const path = join(consumer, rel);
33
+ try {
34
+ lstatSync(path);
35
+ } catch (error) {
36
+ if (isMissingPathError(error)) {
37
+ return { input: null, present: false, problems: [] };
38
+ }
39
+ const detail = error instanceof Error ? error.message : String(error);
26
40
  return {
27
- ok: false,
28
- problem: detail
29
- ? `could not decrypt ${DEV_SECRETS_FILE}: ${detail}`
30
- : `could not decrypt ${DEV_SECRETS_FILE}`,
41
+ input: null,
42
+ present: true,
43
+ problems: [`could not inspect ${rel}: ${detail}`],
31
44
  };
32
45
  }
46
+ let raw: string;
33
47
  try {
34
- return { ok: true, value: JSON.parse(result.stdout) as unknown };
48
+ raw = readFileSync(path, 'utf8');
35
49
  } catch (error) {
50
+ const detail = error instanceof Error ? error.message : String(error);
36
51
  return {
37
- ok: false,
38
- problem: `could not parse decrypted ${DEV_SECRETS_FILE} as JSON: ${
39
- error instanceof Error ? error.message : String(error)
40
- }`,
52
+ input: null,
53
+ present: true,
54
+ problems: [`could not read ${rel}: ${detail}`],
41
55
  };
42
56
  }
43
- };
44
-
45
- // Writing decrypted values into the tree is only safe when git will never
46
- // track them. `git check-ignore` is authoritative; anything but a clear
47
- // "ignored" answer fails closed, including running outside a git checkout.
48
- const gitIgnoreProblem = (consumer: string, rel: string): string | null => {
49
- const result = spawnSync('git', ['check-ignore', '-q', '--', rel], {
50
- cwd: consumer,
51
- });
52
- if (result.error !== undefined || result.status === null) {
53
- return `cannot run git to verify ${rel} is gitignored`;
54
- }
55
- if (result.status === 0) {
56
- return null;
57
- }
58
- if (result.status === 1) {
59
- return `${rel} is not gitignored; ignore it before generating dev env files`;
57
+ const parsed = parseYaml(raw, rel);
58
+ if (parsed.problem !== null) {
59
+ return { input: null, present: true, problems: [parsed.problem] };
60
60
  }
61
- return `cannot verify ${rel} is gitignored (git check-ignore exited ${result.status})`;
61
+ return { input: { raw: parsed.value ?? {} }, present: true, problems: [] };
62
62
  };
63
63
 
64
+ const documentProblems = (
65
+ source: string,
66
+ input: DevEnvPlainInput,
67
+ ): ReadonlyArray<string> =>
68
+ input === null ? [] : parseDevEnvDocument(input.raw, source).problems;
69
+
64
70
  export type DevEnvPlan = {
65
71
  readonly writes: ReadonlyArray<DevEnvWrite>;
72
+ readonly removals: ReadonlyArray<DevEnvRemoval>;
66
73
  readonly problems: ReadonlyArray<string>;
67
74
  };
68
75
 
69
- export const planDevEnvWrites = (
76
+ export const planDevEnvChanges = (
70
77
  consumer: string,
71
- raw: unknown,
78
+ inputs: DevEnvInputs,
72
79
  ): DevEnvPlan => {
73
- const document = parseDevEnvDocument(raw, DEV_SECRETS_FILE);
74
- const problems: Array<string> = [...document.problems];
80
+ const layer = (source: string, input: DevEnvPlainInput) =>
81
+ input === null
82
+ ? null
83
+ : { source, document: parseDevEnvDocument(input.raw, source) };
84
+ const composed = composeDevEnv(
85
+ layer(DEV_CONFIG_FILE, inputs.config),
86
+ {
87
+ source: DEV_SECRETS_FILE,
88
+ document: parseDevEnvDocument(inputs.secrets, DEV_SECRETS_FILE),
89
+ },
90
+ layer(DEV_LOCAL_FILE, inputs.local),
91
+ );
92
+ const problems: Array<string> = [...composed.problems];
75
93
  const writes: Array<DevEnvWrite> = [];
76
- for (const target of document.targets) {
94
+ for (const target of composed.targets) {
77
95
  const workspaceDir = `${target.group}/${target.workspace}`;
78
96
  const rel = `${workspaceDir}/.env.local`;
79
97
  if (existsSync(join(consumer, workspaceDir, 'package.json'))) {
80
- const ignoreProblem = gitIgnoreProblem(consumer, rel);
98
+ const ignoreProblem = devEnvGitIgnoreProblem(consumer, rel);
81
99
  if (ignoreProblem === null) {
82
100
  writes.push({
83
101
  rel,
84
102
  content: renderDotenv(
85
103
  `${target.group}.${target.workspace}`,
86
- DEV_SECRETS_FILE,
104
+ target.sources,
87
105
  target.env,
88
106
  ),
89
107
  });
@@ -92,36 +110,62 @@ export const planDevEnvWrites = (
92
110
  }
93
111
  } else {
94
112
  problems.push(
95
- `${DEV_SECRETS_FILE} defines ${target.group}.${target.workspace}, but ${workspaceDir}/package.json does not exist`,
113
+ `${target.sources.join(' + ')} defines ${target.group}.${target.workspace}, but ${workspaceDir}/package.json does not exist`,
96
114
  );
97
115
  }
98
116
  }
99
- return { writes, problems };
117
+ const removalPlan = planDevEnvRemovals(consumer, writes);
118
+ return {
119
+ writes,
120
+ removals: removalPlan.removals,
121
+ problems: [...problems, ...removalPlan.problems],
122
+ };
100
123
  };
101
124
 
102
125
  export const runDevEnv = async (consumer: string): Promise<boolean> => {
103
- if (!existsSync(join(consumer, DEV_SECRETS_FILE))) {
104
- console.error(
105
- `standards dev-env: ${DEV_SECRETS_FILE} not found; create it with \`just secrets edit dev\``,
106
- );
107
- return false;
108
- }
109
- const decrypted = decryptDevSecrets(consumer);
110
- if (!decrypted.ok) {
111
- console.error(`standards dev-env: ${decrypted.problem}`);
126
+ const config = readPlainLayer(consumer, DEV_CONFIG_FILE);
127
+ const secrets = readDevSecrets(consumer);
128
+ const local = readPlainLayer(consumer, DEV_LOCAL_FILE);
129
+ const localIgnoreProblem = local.present
130
+ ? devEnvGitIgnoreProblem(consumer, DEV_LOCAL_FILE)
131
+ : null;
132
+ const inputProblems = [
133
+ ...config.problems,
134
+ ...(secrets.ok ? [] : [secrets.problem]),
135
+ ...local.problems,
136
+ ...(localIgnoreProblem === null ? [] : [localIgnoreProblem]),
137
+ ...documentProblems(DEV_CONFIG_FILE, config.input),
138
+ ...(secrets.ok
139
+ ? parseDevEnvDocument(secrets.value, DEV_SECRETS_FILE).problems
140
+ : []),
141
+ ...documentProblems(DEV_LOCAL_FILE, local.input),
142
+ ];
143
+ if (inputProblems.length > 0 || !secrets.ok) {
144
+ console.error(`standards dev-env: ${inputProblems.length} problem(s):`);
145
+ console.error(inputProblems.map((problem) => ` - ${problem}`).join('\n'));
112
146
  return false;
113
147
  }
114
- const plan = planDevEnvWrites(consumer, decrypted.value);
148
+ const plan = planDevEnvChanges(consumer, {
149
+ config: config.input,
150
+ secrets: secrets.value,
151
+ local: local.input,
152
+ });
115
153
  const problems = [
116
154
  ...plan.problems,
117
- ...(await devEnvDestinationProblems(consumer, plan.writes)),
155
+ ...(await devEnvDestinationProblems(consumer, [
156
+ ...plan.writes,
157
+ ...plan.removals,
158
+ ])),
118
159
  ];
119
160
  if (problems.length > 0) {
120
161
  console.error(`standards dev-env: ${problems.length} problem(s):`);
121
162
  console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
122
163
  return false;
123
164
  }
124
- const generated = await writeDevEnvFiles(consumer, plan.writes);
165
+ const generated = await applyDevEnvChanges(consumer, [
166
+ ...plan.writes,
167
+ ...plan.removals,
168
+ ]);
125
169
  if (!generated.ok) {
126
170
  console.error('standards dev-env: generation failed:');
127
171
  console.error(
@@ -140,8 +184,16 @@ export const runDevEnv = async (consumer: string): Promise<boolean> => {
140
184
  for (const write of plan.writes) {
141
185
  console.log(` wrote ${write.rel}`);
142
186
  }
187
+ for (const removal of plan.removals) {
188
+ console.log(` removed ${removal.rel}`);
189
+ }
190
+ const inputFiles = [
191
+ ...(config.input === null ? [] : [DEV_CONFIG_FILE]),
192
+ DEV_SECRETS_FILE,
193
+ ...(local.input === null ? [] : [DEV_LOCAL_FILE]),
194
+ ];
143
195
  console.log(
144
- `standards dev-env: generated ${plan.writes.length} env file(s) from ${DEV_SECRETS_FILE}`,
196
+ `standards dev-env: generated ${plan.writes.length} and removed ${plan.removals.length} env file(s) from ${inputFiles.join(' + ')}`,
145
197
  );
146
198
  return true;
147
199
  };