@davidvornholt/standards 0.20.0 → 0.22.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.
Files changed (40) hide show
  1. package/README.md +4 -3
  2. package/package.json +17 -1
  3. package/src/cli.ts +1 -1
  4. package/src/creds-add.ts +19 -1
  5. package/src/creds-args.ts +9 -0
  6. package/src/creds-cloudflare-account.ts +2 -2
  7. package/src/creds-cloudflare-api.ts +6 -10
  8. package/src/creds-cloudflare-id.ts +10 -0
  9. package/src/creds-cloudflare-request.ts +31 -0
  10. package/src/creds-cloudflare-zone.ts +3 -3
  11. package/src/creds-commands.ts +13 -2
  12. package/src/creds-dest.ts +40 -25
  13. package/src/creds-naming.ts +13 -0
  14. package/src/creds-plan-renew.ts +45 -13
  15. package/src/creds-plan-report.ts +87 -0
  16. package/src/creds-plan-run.ts +39 -26
  17. package/src/creds-plan-types.ts +19 -0
  18. package/src/creds-plan-unmanaged.ts +102 -0
  19. package/src/creds-plan.ts +25 -21
  20. package/src/creds-revoke.ts +162 -0
  21. package/src/creds-store-lock-holder.ts +8 -1
  22. package/src/creds-store-lock.ts +2 -1
  23. package/src/creds-target.ts +57 -0
  24. package/src/dev-env-brokered-allowlist.ts +44 -0
  25. package/src/dev-env-brokered-preserve.ts +65 -0
  26. package/src/dev-env-brokered-refresh.ts +129 -0
  27. package/src/dev-env-brokered-resolve.ts +158 -0
  28. package/src/dev-env-brokered.ts +83 -0
  29. package/src/dev-env-compose.ts +129 -0
  30. package/src/dev-env-destination-duplicates.ts +10 -10
  31. package/src/dev-env-destination-gitignore.ts +22 -0
  32. package/src/dev-env-destination.ts +52 -30
  33. package/src/dev-env-document.ts +104 -22
  34. package/src/dev-env-dotenv.ts +50 -5
  35. package/src/dev-env-plain-layer.ts +53 -0
  36. package/src/dev-env-reconciliation.ts +170 -0
  37. package/src/dev-env-secrets.ts +41 -0
  38. package/src/dev-env-transaction-recovery.ts +74 -11
  39. package/src/dev-env-transaction.ts +61 -15
  40. package/src/dev-env.ts +132 -79
package/src/dev-env.ts CHANGED
@@ -1,127 +1,172 @@
1
- import { spawnSync } from 'node:child_process';
2
1
  import { existsSync } from 'node:fs';
3
2
  import { join } from 'node:path';
4
3
  import {
4
+ type DevEnvRunOptions,
5
+ renderDevEnvPreservingUnsafeReferences,
6
+ } from './dev-env-brokered-preserve';
7
+ import { resolveBrokeredReferences } from './dev-env-brokered-resolve';
8
+ import { composeDevEnv } from './dev-env-compose';
9
+ import {
10
+ type DevEnvRemoval,
5
11
  type DevEnvWrite,
6
12
  devEnvDestinationProblems,
7
13
  } from './dev-env-destination';
14
+ import { devEnvGitIgnoreProblem } from './dev-env-destination-gitignore';
8
15
  import { parseDevEnvDocument } from './dev-env-document';
9
- import { renderDotenv } from './dev-env-dotenv';
10
- import { writeDevEnvFiles } from './dev-env-transaction';
11
- import { runSops } from './sops-exec';
12
-
13
- export const DEV_SECRETS_FILE = 'secrets/dev.yaml';
14
-
15
- const SOPS_ARGS = ['--decrypt', '--output-type', 'json', DEV_SECRETS_FILE];
16
+ import type { DevEnvPlainInput } from './dev-env-plain-layer';
17
+ import { readPlainLayer } from './dev-env-plain-layer';
18
+ import { planDevEnvRemovals } from './dev-env-reconciliation';
19
+ import { DEV_SECRETS_FILE, readDevSecrets } from './dev-env-secrets';
20
+ import { applyDevEnvChanges } from './dev-env-transaction';
16
21
 
17
- type DecryptResult =
18
- | { readonly ok: true; readonly value: unknown }
19
- | { readonly ok: false; readonly problem: string };
22
+ export const DEV_CONFIG_FILE = 'config/dev.yaml';
23
+ export const DEV_LOCAL_FILE = 'config/dev.local.yaml';
20
24
 
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();
26
- return {
27
- ok: false,
28
- problem: detail
29
- ? `could not decrypt ${DEV_SECRETS_FILE}: ${detail}`
30
- : `could not decrypt ${DEV_SECRETS_FILE}`,
31
- };
32
- }
33
- try {
34
- return { ok: true, value: JSON.parse(result.stdout) as unknown };
35
- } catch (error) {
36
- return {
37
- ok: false,
38
- problem: `could not parse decrypted ${DEV_SECRETS_FILE} as JSON: ${
39
- error instanceof Error ? error.message : String(error)
40
- }`,
41
- };
42
- }
25
+ export type DevEnvInputs = {
26
+ readonly config: DevEnvPlainInput;
27
+ readonly secrets: unknown;
28
+ readonly local: DevEnvPlainInput;
43
29
  };
44
30
 
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`;
60
- }
61
- return `cannot verify ${rel} is gitignored (git check-ignore exited ${result.status})`;
62
- };
31
+ const documentProblems = (
32
+ source: string,
33
+ input: DevEnvPlainInput,
34
+ ): ReadonlyArray<string> =>
35
+ input === null
36
+ ? []
37
+ : parseDevEnvDocument(input.raw, source, 'configuration').problems;
63
38
 
64
39
  export type DevEnvPlan = {
65
40
  readonly writes: ReadonlyArray<DevEnvWrite>;
41
+ readonly removals: ReadonlyArray<DevEnvRemoval>;
66
42
  readonly problems: ReadonlyArray<string>;
67
43
  };
68
44
 
69
- export const planDevEnvWrites = (
45
+ export const planDevEnvChanges = (
70
46
  consumer: string,
71
- raw: unknown,
47
+ inputs: DevEnvInputs,
48
+ options: DevEnvRunOptions = { preservedBrokeredReferences: new Set() },
72
49
  ): DevEnvPlan => {
73
- const document = parseDevEnvDocument(raw, DEV_SECRETS_FILE);
74
- const problems: Array<string> = [...document.problems];
50
+ const layer = (source: string, input: DevEnvPlainInput) =>
51
+ input === null
52
+ ? null
53
+ : {
54
+ source,
55
+ document: parseDevEnvDocument(input.raw, source, 'configuration'),
56
+ };
57
+ const composed = composeDevEnv(
58
+ layer(DEV_CONFIG_FILE, inputs.config),
59
+ {
60
+ source: DEV_SECRETS_FILE,
61
+ document: parseDevEnvDocument(
62
+ inputs.secrets,
63
+ DEV_SECRETS_FILE,
64
+ 'secrets',
65
+ ),
66
+ },
67
+ layer(DEV_LOCAL_FILE, inputs.local),
68
+ );
69
+ const resolved = resolveBrokeredReferences(
70
+ consumer,
71
+ composed.targets,
72
+ composed.brokeredReferences,
73
+ options.preservedBrokeredReferences,
74
+ );
75
+ const problems: Array<string> = [...composed.problems, ...resolved.problems];
75
76
  const writes: Array<DevEnvWrite> = [];
76
- for (const target of document.targets) {
77
+ for (const target of resolved.targets) {
77
78
  const workspaceDir = `${target.group}/${target.workspace}`;
78
79
  const rel = `${workspaceDir}/.env.local`;
79
80
  if (existsSync(join(consumer, workspaceDir, 'package.json'))) {
80
- const ignoreProblem = gitIgnoreProblem(consumer, rel);
81
+ const ignoreProblem = devEnvGitIgnoreProblem(consumer, rel);
81
82
  if (ignoreProblem === null) {
82
- writes.push({
83
+ const composedTarget = composed.targets.find(
84
+ (candidate) =>
85
+ candidate.group === target.group &&
86
+ candidate.workspace === target.workspace,
87
+ );
88
+ const rendered = renderDevEnvPreservingUnsafeReferences({
89
+ consumer,
83
90
  rel,
84
- content: renderDotenv(
85
- `${target.group}.${target.workspace}`,
86
- DEV_SECRETS_FILE,
87
- target.env,
88
- ),
91
+ sourcePath: `${target.group}.${target.workspace}`,
92
+ sources: target.sources,
93
+ resolvedEnv: target.env,
94
+ composedEnv: composedTarget?.env ?? {},
95
+ preservedReferences: options.preservedBrokeredReferences,
89
96
  });
97
+ if (rendered.ok) {
98
+ writes.push({ rel, content: rendered.content });
99
+ } else {
100
+ problems.push(rendered.problem);
101
+ }
90
102
  } else {
91
103
  problems.push(ignoreProblem);
92
104
  }
93
105
  } else {
94
106
  problems.push(
95
- `${DEV_SECRETS_FILE} defines ${target.group}.${target.workspace}, but ${workspaceDir}/package.json does not exist`,
107
+ `${target.sources.join(' + ')} defines ${target.group}.${target.workspace}, but ${workspaceDir}/package.json does not exist`,
96
108
  );
97
109
  }
98
110
  }
99
- return { writes, problems };
111
+ const removalPlan = planDevEnvRemovals(consumer, writes);
112
+ return {
113
+ writes,
114
+ removals: removalPlan.removals,
115
+ problems: [...problems, ...removalPlan.problems],
116
+ };
100
117
  };
101
118
 
102
- 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}`);
119
+ export const runDevEnv = async (
120
+ consumer: string,
121
+ options: DevEnvRunOptions = { preservedBrokeredReferences: new Set() },
122
+ ): Promise<boolean> => {
123
+ const config = readPlainLayer(consumer, DEV_CONFIG_FILE);
124
+ const secrets = readDevSecrets(consumer);
125
+ const local = readPlainLayer(consumer, DEV_LOCAL_FILE);
126
+ const localIgnoreProblem = local.present
127
+ ? devEnvGitIgnoreProblem(consumer, DEV_LOCAL_FILE)
128
+ : null;
129
+ const inputProblems = [
130
+ ...config.problems,
131
+ ...(secrets.ok ? [] : [secrets.problem]),
132
+ ...local.problems,
133
+ ...(localIgnoreProblem === null ? [] : [localIgnoreProblem]),
134
+ ...documentProblems(DEV_CONFIG_FILE, config.input),
135
+ ...(secrets.ok
136
+ ? parseDevEnvDocument(secrets.value, DEV_SECRETS_FILE, 'secrets').problems
137
+ : []),
138
+ ...documentProblems(DEV_LOCAL_FILE, local.input),
139
+ ];
140
+ if (inputProblems.length > 0 || !secrets.ok) {
141
+ console.error(`standards dev-env: ${inputProblems.length} problem(s):`);
142
+ console.error(inputProblems.map((problem) => ` - ${problem}`).join('\n'));
112
143
  return false;
113
144
  }
114
- const plan = planDevEnvWrites(consumer, decrypted.value);
145
+ const plan = planDevEnvChanges(
146
+ consumer,
147
+ {
148
+ config: config.input,
149
+ secrets: secrets.value,
150
+ local: local.input,
151
+ },
152
+ options,
153
+ );
115
154
  const problems = [
116
155
  ...plan.problems,
117
- ...(await devEnvDestinationProblems(consumer, plan.writes)),
156
+ ...(await devEnvDestinationProblems(consumer, [
157
+ ...plan.writes,
158
+ ...plan.removals,
159
+ ])),
118
160
  ];
119
161
  if (problems.length > 0) {
120
162
  console.error(`standards dev-env: ${problems.length} problem(s):`);
121
163
  console.error(problems.map((problem) => ` - ${problem}`).join('\n'));
122
164
  return false;
123
165
  }
124
- const generated = await writeDevEnvFiles(consumer, plan.writes);
166
+ const generated = await applyDevEnvChanges(consumer, [
167
+ ...plan.writes,
168
+ ...plan.removals,
169
+ ]);
125
170
  if (!generated.ok) {
126
171
  console.error('standards dev-env: generation failed:');
127
172
  console.error(
@@ -140,8 +185,16 @@ export const runDevEnv = async (consumer: string): Promise<boolean> => {
140
185
  for (const write of plan.writes) {
141
186
  console.log(` wrote ${write.rel}`);
142
187
  }
188
+ for (const removal of plan.removals) {
189
+ console.log(` removed ${removal.rel}`);
190
+ }
191
+ const inputFiles = [
192
+ ...(config.input === null ? [] : [DEV_CONFIG_FILE]),
193
+ DEV_SECRETS_FILE,
194
+ ...(local.input === null ? [] : [DEV_LOCAL_FILE]),
195
+ ];
143
196
  console.log(
144
- `standards dev-env: generated ${plan.writes.length} env file(s) from ${DEV_SECRETS_FILE}`,
197
+ `standards dev-env: generated ${plan.writes.length} and removed ${plan.removals.length} env file(s) from ${inputFiles.join(' + ')}`,
145
198
  );
146
199
  return true;
147
200
  };