@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.
- package/README.md +4 -3
- package/package.json +17 -1
- package/src/cli.ts +1 -1
- package/src/creds-add.ts +19 -1
- package/src/creds-args.ts +9 -0
- package/src/creds-cloudflare-account.ts +2 -2
- package/src/creds-cloudflare-api.ts +6 -10
- package/src/creds-cloudflare-id.ts +10 -0
- package/src/creds-cloudflare-request.ts +31 -0
- package/src/creds-cloudflare-zone.ts +3 -3
- package/src/creds-commands.ts +13 -2
- package/src/creds-dest.ts +40 -25
- package/src/creds-naming.ts +13 -0
- package/src/creds-plan-renew.ts +45 -13
- package/src/creds-plan-report.ts +87 -0
- package/src/creds-plan-run.ts +39 -26
- package/src/creds-plan-types.ts +19 -0
- package/src/creds-plan-unmanaged.ts +102 -0
- package/src/creds-plan.ts +25 -21
- package/src/creds-revoke.ts +162 -0
- package/src/creds-store-lock-holder.ts +8 -1
- package/src/creds-store-lock.ts +2 -1
- package/src/creds-target.ts +57 -0
- package/src/dev-env-brokered-allowlist.ts +44 -0
- package/src/dev-env-brokered-preserve.ts +65 -0
- package/src/dev-env-brokered-refresh.ts +129 -0
- package/src/dev-env-brokered-resolve.ts +158 -0
- package/src/dev-env-brokered.ts +83 -0
- package/src/dev-env-compose.ts +129 -0
- package/src/dev-env-destination-duplicates.ts +10 -10
- package/src/dev-env-destination-gitignore.ts +22 -0
- package/src/dev-env-destination.ts +52 -30
- package/src/dev-env-document.ts +104 -22
- package/src/dev-env-dotenv.ts +50 -5
- package/src/dev-env-plain-layer.ts +53 -0
- package/src/dev-env-reconciliation.ts +170 -0
- package/src/dev-env-secrets.ts +41 -0
- package/src/dev-env-transaction-recovery.ts +74 -11
- package/src/dev-env-transaction.ts +61 -15
- package/src/dev-env.ts +132 -79
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Brokered S3 pair references let a plain configuration layer point a
|
|
2
|
+
// workspace env key at a credential pair the creds broker owns in another
|
|
3
|
+
// SOPS target. The secret value then has exactly one home — the broker's
|
|
4
|
+
// ledger — so rotation stays the broker's job and no copy can go stale.
|
|
5
|
+
// A reference is configuration, never a secret, so the SOPS-encrypted
|
|
6
|
+
// secrets layer must not declare one.
|
|
7
|
+
|
|
8
|
+
import { parseDestination } from './creds-dest';
|
|
9
|
+
import { parseSopsKeyPath } from './creds-sops-structure';
|
|
10
|
+
import { isRecord } from './github-settings-parse';
|
|
11
|
+
|
|
12
|
+
export const BROKERED_S3_PARTS = [
|
|
13
|
+
'access_key_id',
|
|
14
|
+
'secret_access_key',
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export type BrokeredS3Part = (typeof BROKERED_S3_PARTS)[number];
|
|
18
|
+
|
|
19
|
+
export type BrokeredS3Reference = {
|
|
20
|
+
readonly brokeredS3: string;
|
|
21
|
+
readonly key: string;
|
|
22
|
+
readonly part: BrokeredS3Part;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const REFERENCE_PROPERTIES = ['brokeredS3', 'key', 'part'] as const;
|
|
26
|
+
|
|
27
|
+
export const isBrokeredS3ReferenceShape = (
|
|
28
|
+
value: unknown,
|
|
29
|
+
): value is Record<string, unknown> => isRecord(value) && 'brokeredS3' in value;
|
|
30
|
+
|
|
31
|
+
export type BrokeredS3ParseResult =
|
|
32
|
+
| { readonly ok: true; readonly reference: BrokeredS3Reference }
|
|
33
|
+
| { readonly ok: false; readonly problems: ReadonlyArray<string> };
|
|
34
|
+
|
|
35
|
+
const isBrokeredS3Part = (value: unknown): value is BrokeredS3Part =>
|
|
36
|
+
BROKERED_S3_PARTS.includes(value as BrokeredS3Part);
|
|
37
|
+
|
|
38
|
+
// Target existence and contained-path resolution are owned by
|
|
39
|
+
// `resolveTargetRel` at resolution time; parsing rejects a target name that
|
|
40
|
+
// could never name a broker destination.
|
|
41
|
+
export const parseBrokeredS3Reference = (
|
|
42
|
+
label: string,
|
|
43
|
+
raw: Record<string, unknown>,
|
|
44
|
+
): BrokeredS3ParseResult => {
|
|
45
|
+
const problems: Array<string> = [];
|
|
46
|
+
for (const property of Object.keys(raw)) {
|
|
47
|
+
if (!REFERENCE_PROPERTIES.includes(property as 'brokeredS3')) {
|
|
48
|
+
problems.push(
|
|
49
|
+
`${label} brokered S3 pair reference has unknown property ${JSON.stringify(property)}; allowed properties are brokeredS3, key, and part`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const { brokeredS3: target, key, part } = raw;
|
|
54
|
+
if (
|
|
55
|
+
typeof target !== 'string' ||
|
|
56
|
+
parseDestination(`${target}:reference_validation`) === null
|
|
57
|
+
) {
|
|
58
|
+
problems.push(
|
|
59
|
+
`${label} brokered S3 pair reference needs a valid "brokeredS3" secrets target name`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (typeof key !== 'string' || parseSopsKeyPath(key) === null) {
|
|
63
|
+
problems.push(
|
|
64
|
+
`${label} brokered S3 pair reference needs a "key" naming the pair's dotted SOPS key`,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (!isBrokeredS3Part(part)) {
|
|
68
|
+
problems.push(
|
|
69
|
+
`${label} brokered S3 pair reference needs a "part" of either "access_key_id" or "secret_access_key"`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
if (problems.length > 0) {
|
|
73
|
+
return { ok: false, problems };
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
reference: {
|
|
78
|
+
brokeredS3: target as string,
|
|
79
|
+
key: key as string,
|
|
80
|
+
part: part as BrokeredS3Part,
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Composes the dev env layers — tracked config/dev.yaml, decrypted
|
|
2
|
+
// secrets/dev.yaml, and the gitignored per-machine config/dev.local.yaml —
|
|
3
|
+
// into one workspace-keyed target set. Later layers override earlier ones
|
|
4
|
+
// per env key. A key declared by both tracked shared layers is a placement
|
|
5
|
+
// error: a value is either configuration or a secret, never both.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
DevEnvDocument,
|
|
9
|
+
DevEnvValue,
|
|
10
|
+
EnvValues,
|
|
11
|
+
} from './dev-env-document';
|
|
12
|
+
|
|
13
|
+
export type DevEnvLayer = {
|
|
14
|
+
readonly source: string;
|
|
15
|
+
readonly document: DevEnvDocument;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type ComposedDevEnvTarget = {
|
|
19
|
+
readonly group: string;
|
|
20
|
+
readonly workspace: string;
|
|
21
|
+
readonly env: EnvValues;
|
|
22
|
+
readonly sources: ReadonlyArray<string>;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type ComposedDevEnv = {
|
|
26
|
+
readonly targets: ReadonlyArray<ComposedDevEnvTarget>;
|
|
27
|
+
readonly brokeredReferences: ReadonlySet<string>;
|
|
28
|
+
readonly problems: ReadonlyArray<string>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
type MutableTarget = {
|
|
32
|
+
readonly group: string;
|
|
33
|
+
readonly workspace: string;
|
|
34
|
+
readonly env: Record<string, DevEnvValue>;
|
|
35
|
+
readonly sources: Array<string>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const copyEnv = (source: EnvValues): Record<string, DevEnvValue> => {
|
|
39
|
+
const copy = Object.create(null) as Record<string, DevEnvValue>;
|
|
40
|
+
for (const [key, value] of Object.entries(source)) {
|
|
41
|
+
copy[key] = value;
|
|
42
|
+
}
|
|
43
|
+
return copy;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const applyLayer = (
|
|
47
|
+
merged: Map<string, MutableTarget>,
|
|
48
|
+
layer: DevEnvLayer,
|
|
49
|
+
): void => {
|
|
50
|
+
for (const target of layer.document.targets) {
|
|
51
|
+
const key = `${target.group}.${target.workspace}`;
|
|
52
|
+
const existing = merged.get(key);
|
|
53
|
+
if (existing === undefined) {
|
|
54
|
+
merged.set(key, {
|
|
55
|
+
group: target.group,
|
|
56
|
+
workspace: target.workspace,
|
|
57
|
+
env: copyEnv(target.env),
|
|
58
|
+
sources: [layer.source],
|
|
59
|
+
});
|
|
60
|
+
} else {
|
|
61
|
+
for (const [envKey, value] of Object.entries(target.env)) {
|
|
62
|
+
existing.env[envKey] = value;
|
|
63
|
+
}
|
|
64
|
+
existing.sources.push(layer.source);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const sharedOverlapProblems = (
|
|
70
|
+
config: DevEnvLayer,
|
|
71
|
+
secrets: DevEnvLayer,
|
|
72
|
+
): ReadonlyArray<string> => {
|
|
73
|
+
const secretTargets = new Map(
|
|
74
|
+
secrets.document.targets.map((target) => [
|
|
75
|
+
`${target.group}.${target.workspace}`,
|
|
76
|
+
target,
|
|
77
|
+
]),
|
|
78
|
+
);
|
|
79
|
+
return config.document.targets.flatMap((target) => {
|
|
80
|
+
const counterpart = secretTargets.get(
|
|
81
|
+
`${target.group}.${target.workspace}`,
|
|
82
|
+
);
|
|
83
|
+
if (counterpart === undefined) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
return [...target.declaredKeys]
|
|
87
|
+
.filter((key) => counterpart.declaredKeys.has(key))
|
|
88
|
+
.map(
|
|
89
|
+
(key) =>
|
|
90
|
+
`${target.group}.${target.workspace}.${key} is declared in both ${config.source} and ${secrets.source}; a value is either configuration or a secret, so keep it in exactly one`,
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const composeDevEnv = (
|
|
96
|
+
config: DevEnvLayer | null,
|
|
97
|
+
secrets: DevEnvLayer,
|
|
98
|
+
local: DevEnvLayer | null,
|
|
99
|
+
): ComposedDevEnv => {
|
|
100
|
+
const problems: ReadonlyArray<string> = [
|
|
101
|
+
...(config === null ? [] : config.document.problems),
|
|
102
|
+
...secrets.document.problems,
|
|
103
|
+
...(local === null ? [] : local.document.problems),
|
|
104
|
+
...(config === null ? [] : sharedOverlapProblems(config, secrets)),
|
|
105
|
+
];
|
|
106
|
+
const merged = new Map<string, MutableTarget>();
|
|
107
|
+
for (const layer of [config, secrets, local]) {
|
|
108
|
+
if (layer !== null) {
|
|
109
|
+
applyLayer(merged, layer);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const targets = [...merged.values()]
|
|
113
|
+
.sort((a, b) =>
|
|
114
|
+
a.group === b.group
|
|
115
|
+
? a.workspace.localeCompare(b.workspace)
|
|
116
|
+
: a.group.localeCompare(b.group),
|
|
117
|
+
)
|
|
118
|
+
.map((target) => ({
|
|
119
|
+
group: target.group,
|
|
120
|
+
workspace: target.workspace,
|
|
121
|
+
env: target.env,
|
|
122
|
+
sources: target.sources,
|
|
123
|
+
}));
|
|
124
|
+
return {
|
|
125
|
+
targets,
|
|
126
|
+
brokeredReferences: secrets.document.brokeredReferences,
|
|
127
|
+
problems,
|
|
128
|
+
};
|
|
129
|
+
};
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
import { resolve } from 'node:path';
|
|
2
|
-
import type {
|
|
2
|
+
import type { DevEnvMutation } from './dev-env-destination';
|
|
3
3
|
|
|
4
4
|
export const duplicateDestinationProblems = (
|
|
5
5
|
root: string,
|
|
6
|
-
|
|
6
|
+
mutations: ReadonlyArray<DevEnvMutation>,
|
|
7
7
|
): ReadonlyArray<string> => {
|
|
8
|
-
const rawDuplicates =
|
|
9
|
-
const firstIndex =
|
|
8
|
+
const rawDuplicates = mutations.flatMap((mutation, index) => {
|
|
9
|
+
const firstIndex = mutations.findIndex(({ rel }) => rel === mutation.rel);
|
|
10
10
|
return firstIndex < index
|
|
11
|
-
? [`${
|
|
11
|
+
? [`${mutation.rel} is declared more than once`]
|
|
12
12
|
: [];
|
|
13
13
|
});
|
|
14
|
-
const resolved =
|
|
15
|
-
|
|
16
|
-
dest: resolve(root,
|
|
14
|
+
const resolved = mutations.map((mutation) => ({
|
|
15
|
+
mutation,
|
|
16
|
+
dest: resolve(root, mutation.rel),
|
|
17
17
|
}));
|
|
18
18
|
const normalizedDuplicates = resolved.flatMap((destination, index) => {
|
|
19
19
|
const firstIndex = resolved.findIndex(
|
|
@@ -22,9 +22,9 @@ export const duplicateDestinationProblems = (
|
|
|
22
22
|
const first = resolved[firstIndex];
|
|
23
23
|
return firstIndex < index &&
|
|
24
24
|
first !== undefined &&
|
|
25
|
-
first.
|
|
25
|
+
first.mutation.rel !== destination.mutation.rel
|
|
26
26
|
? [
|
|
27
|
-
`${destination.
|
|
27
|
+
`${destination.mutation.rel} resolves to the same destination as ${first.mutation.rel}`,
|
|
28
28
|
]
|
|
29
29
|
: [];
|
|
30
30
|
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
// Decrypted values are safe only when git proves it will ignore them.
|
|
4
|
+
export const devEnvGitIgnoreProblem = (
|
|
5
|
+
consumer: string,
|
|
6
|
+
rel: string,
|
|
7
|
+
): string | null => {
|
|
8
|
+
const result = spawnSync('git', ['check-ignore', '-q', '--', rel], {
|
|
9
|
+
cwd: consumer,
|
|
10
|
+
encoding: 'utf8',
|
|
11
|
+
});
|
|
12
|
+
if (result.error !== undefined || result.status === null) {
|
|
13
|
+
return `cannot run git to verify ${rel} is gitignored`;
|
|
14
|
+
}
|
|
15
|
+
if (result.status === 0) {
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
if (result.status === 1) {
|
|
19
|
+
return `${rel} is not gitignored; ignore it before generating dev env files`;
|
|
20
|
+
}
|
|
21
|
+
return `cannot verify ${rel} is gitignored (git check-ignore exited ${result.status})`;
|
|
22
|
+
};
|
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import type { Stats } from 'node:fs';
|
|
3
|
-
import {
|
|
3
|
+
import { realpath } from 'node:fs/promises';
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { duplicateDestinationProblems } from './dev-env-destination-duplicates';
|
|
6
|
+
import { devEnvGitIgnoreProblem } from './dev-env-destination-gitignore';
|
|
7
|
+
import {
|
|
8
|
+
devEnvStatOrNull,
|
|
9
|
+
matchesDevEnvRemoval,
|
|
10
|
+
} from './dev-env-reconciliation';
|
|
6
11
|
|
|
7
12
|
export type DevEnvWrite = {
|
|
8
13
|
readonly rel: string;
|
|
9
14
|
readonly content: string;
|
|
10
15
|
};
|
|
11
16
|
|
|
17
|
+
export type DevEnvRemoval = {
|
|
18
|
+
readonly rel: string;
|
|
19
|
+
readonly identity: {
|
|
20
|
+
readonly device: number;
|
|
21
|
+
readonly inode: number;
|
|
22
|
+
};
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type DevEnvMutation = DevEnvWrite | DevEnvRemoval;
|
|
26
|
+
|
|
12
27
|
type DevEnvPathIdentity = {
|
|
13
28
|
readonly path: string;
|
|
14
29
|
readonly device: number;
|
|
@@ -16,7 +31,7 @@ type DevEnvPathIdentity = {
|
|
|
16
31
|
};
|
|
17
32
|
|
|
18
33
|
export type DevEnvDestination = {
|
|
19
|
-
readonly
|
|
34
|
+
readonly mutation: DevEnvMutation;
|
|
20
35
|
readonly dest: string;
|
|
21
36
|
readonly previous: Stats | null;
|
|
22
37
|
readonly temp: string;
|
|
@@ -26,6 +41,7 @@ export type DevEnvDestination = {
|
|
|
26
41
|
readonly realRoot: string;
|
|
27
42
|
backupCreated: boolean;
|
|
28
43
|
committed: boolean;
|
|
44
|
+
rollbackBlocked: boolean;
|
|
29
45
|
};
|
|
30
46
|
|
|
31
47
|
type PreflightResult =
|
|
@@ -40,27 +56,16 @@ const containedBy = (root: string, candidate: string): boolean => {
|
|
|
40
56
|
return rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
|
|
41
57
|
};
|
|
42
58
|
|
|
43
|
-
export const devEnvStatOrNull = async (path: string): Promise<Stats | null> => {
|
|
44
|
-
try {
|
|
45
|
-
return await lstat(path);
|
|
46
|
-
} catch (error) {
|
|
47
|
-
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
throw error;
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
-
|
|
54
59
|
const inspectDestination = async (
|
|
55
60
|
root: string,
|
|
56
61
|
realRoot: string,
|
|
57
|
-
|
|
62
|
+
mutation: DevEnvMutation,
|
|
58
63
|
): Promise<string | DevEnvDestination> => {
|
|
59
|
-
const dest = resolve(root,
|
|
64
|
+
const dest = resolve(root, mutation.rel);
|
|
60
65
|
if (!containedBy(root, dest)) {
|
|
61
|
-
return `${
|
|
66
|
+
return `${mutation.rel} escapes the consumer repository`;
|
|
62
67
|
}
|
|
63
|
-
const paths = dirname(
|
|
68
|
+
const paths = dirname(mutation.rel)
|
|
64
69
|
.split(sep)
|
|
65
70
|
.map((_segment, index, segments) =>
|
|
66
71
|
join(root, ...segments.slice(0, index + 1)),
|
|
@@ -73,19 +78,24 @@ const inspectDestination = async (
|
|
|
73
78
|
entry === null || !entry.isDirectory() || entry.isSymbolicLink(),
|
|
74
79
|
)
|
|
75
80
|
) {
|
|
76
|
-
return `${
|
|
81
|
+
return `${mutation.rel} has an unsafe destination directory`;
|
|
77
82
|
}
|
|
78
83
|
const realParent = await realpath(dirname(dest));
|
|
79
84
|
if (!containedBy(realRoot, realParent)) {
|
|
80
|
-
return `${
|
|
85
|
+
return `${mutation.rel} resolves outside the consumer repository`;
|
|
81
86
|
}
|
|
82
87
|
const previous = await devEnvStatOrNull(dest);
|
|
83
88
|
if (previous !== null && (!previous.isFile() || previous.isSymbolicLink())) {
|
|
84
|
-
return `${
|
|
89
|
+
return `${mutation.rel} must be absent or a regular file, not a symlink or other file type`;
|
|
90
|
+
}
|
|
91
|
+
if (
|
|
92
|
+
!('content' in mutation || (await matchesDevEnvRemoval(dest, mutation)))
|
|
93
|
+
) {
|
|
94
|
+
return `${mutation.rel} changed after planning`;
|
|
85
95
|
}
|
|
86
96
|
const suffix = randomUUID();
|
|
87
97
|
return {
|
|
88
|
-
|
|
98
|
+
mutation,
|
|
89
99
|
dest,
|
|
90
100
|
previous,
|
|
91
101
|
temp: join(dirname(dest), `.env.local.standards-${suffix}.tmp`),
|
|
@@ -99,32 +109,44 @@ const inspectDestination = async (
|
|
|
99
109
|
realRoot,
|
|
100
110
|
backupCreated: false,
|
|
101
111
|
committed: false,
|
|
112
|
+
rollbackBlocked: false,
|
|
102
113
|
};
|
|
103
114
|
};
|
|
104
115
|
|
|
105
116
|
export const preflightDevEnvDestinations = async (
|
|
106
117
|
consumer: string,
|
|
107
|
-
|
|
118
|
+
mutations: ReadonlyArray<DevEnvMutation>,
|
|
108
119
|
): Promise<PreflightResult> => {
|
|
109
120
|
const root = resolve(consumer);
|
|
110
121
|
const realRoot = await realpath(root);
|
|
111
|
-
const duplicates = duplicateDestinationProblems(root,
|
|
122
|
+
const duplicates = duplicateDestinationProblems(root, mutations);
|
|
112
123
|
const checked = await Promise.all(
|
|
113
|
-
|
|
124
|
+
mutations.map(async (mutation) => {
|
|
114
125
|
try {
|
|
115
|
-
return await inspectDestination(root, realRoot,
|
|
126
|
+
return await inspectDestination(root, realRoot, mutation);
|
|
116
127
|
} catch (error) {
|
|
117
128
|
const detail = error instanceof Error ? error.message : String(error);
|
|
118
|
-
return `${
|
|
129
|
+
return `${mutation.rel} could not be preflighted: ${detail}`;
|
|
119
130
|
}
|
|
120
131
|
}),
|
|
121
132
|
);
|
|
122
133
|
const destinations = checked.filter(
|
|
123
134
|
(item): item is DevEnvDestination => typeof item !== 'string',
|
|
124
135
|
);
|
|
136
|
+
const gitignoreProblems = destinations.flatMap((destination) =>
|
|
137
|
+
[
|
|
138
|
+
destination.mutation.rel,
|
|
139
|
+
relative(root, destination.temp),
|
|
140
|
+
relative(root, destination.backup),
|
|
141
|
+
].flatMap((rel) => {
|
|
142
|
+
const problem = devEnvGitIgnoreProblem(consumer, rel);
|
|
143
|
+
return problem === null ? [] : [problem];
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
125
146
|
const problems = [
|
|
126
147
|
...duplicates,
|
|
127
148
|
...checked.filter((item): item is string => typeof item === 'string'),
|
|
149
|
+
...gitignoreProblems,
|
|
128
150
|
];
|
|
129
151
|
return problems.length > 0
|
|
130
152
|
? { ok: false, problems }
|
|
@@ -158,18 +180,18 @@ export const devEnvParentProblem = async (
|
|
|
158
180
|
realParent !== destination.realParent ||
|
|
159
181
|
!containedBy(destination.realRoot, realParent)
|
|
160
182
|
) {
|
|
161
|
-
return `${destination.
|
|
183
|
+
return `${destination.mutation.rel} destination directory changed after preflight`;
|
|
162
184
|
}
|
|
163
185
|
return null;
|
|
164
186
|
} catch {
|
|
165
|
-
return `${destination.
|
|
187
|
+
return `${destination.mutation.rel} destination directory changed after preflight`;
|
|
166
188
|
}
|
|
167
189
|
};
|
|
168
190
|
|
|
169
191
|
export const devEnvDestinationProblems = async (
|
|
170
192
|
consumer: string,
|
|
171
|
-
|
|
193
|
+
mutations: ReadonlyArray<DevEnvMutation>,
|
|
172
194
|
): Promise<ReadonlyArray<string>> => {
|
|
173
|
-
const checked = await preflightDevEnvDestinations(consumer,
|
|
195
|
+
const checked = await preflightDevEnvDestinations(consumer, mutations);
|
|
174
196
|
return checked.ok ? [] : checked.problems;
|
|
175
197
|
};
|
package/src/dev-env-document.ts
CHANGED
|
@@ -1,21 +1,40 @@
|
|
|
1
|
-
// Workspace-group keyed dev
|
|
2
|
-
// `packages.<name>` map env keys to
|
|
3
|
-
// secrets/dev.example.yaml.
|
|
4
|
-
//
|
|
1
|
+
// Workspace-group keyed dev env document: `apps.<name>` and
|
|
2
|
+
// `packages.<name>` map env keys to values — the shape mirrored in
|
|
3
|
+
// secrets/dev.example.yaml. Plain configuration layers may also declare a
|
|
4
|
+
// brokered S3 pair reference instead of a literal; the secrets layer may
|
|
5
|
+
// not, because a reference is configuration. The secrets layer instead owns
|
|
6
|
+
// the encrypted allowlist that authorizes exact target/key pairs. Parsing
|
|
7
|
+
// gathers every problem instead of failing on the first one so a malformed
|
|
8
|
+
// document is repaired in one pass.
|
|
5
9
|
|
|
10
|
+
import {
|
|
11
|
+
type BrokeredS3Reference,
|
|
12
|
+
isBrokeredS3ReferenceShape,
|
|
13
|
+
parseBrokeredS3Reference,
|
|
14
|
+
} from './dev-env-brokered';
|
|
15
|
+
import {
|
|
16
|
+
BROKERED_REFERENCES_KEY,
|
|
17
|
+
parseBrokeredAllowlist,
|
|
18
|
+
} from './dev-env-brokered-allowlist';
|
|
6
19
|
import { encodeBunDotenvValue } from './dev-env-dotenv-value';
|
|
7
20
|
import { isRecord } from './github-settings-parse';
|
|
8
21
|
|
|
9
|
-
export type
|
|
22
|
+
export type DevEnvValue = string | BrokeredS3Reference;
|
|
23
|
+
|
|
24
|
+
export type EnvValues = Readonly<Record<string, DevEnvValue>>;
|
|
25
|
+
|
|
26
|
+
export type DevEnvLayerKind = 'configuration' | 'secrets';
|
|
10
27
|
|
|
11
28
|
export type DevEnvTarget = {
|
|
12
29
|
readonly group: string;
|
|
13
30
|
readonly workspace: string;
|
|
14
31
|
readonly env: EnvValues;
|
|
32
|
+
readonly declaredKeys: ReadonlySet<string>;
|
|
15
33
|
};
|
|
16
34
|
|
|
17
35
|
export type DevEnvDocument = {
|
|
18
36
|
readonly targets: ReadonlyArray<DevEnvTarget>;
|
|
37
|
+
readonly brokeredReferences: ReadonlySet<string>;
|
|
19
38
|
readonly problems: ReadonlyArray<string>;
|
|
20
39
|
};
|
|
21
40
|
|
|
@@ -28,36 +47,84 @@ type ParsedWorkspaces = {
|
|
|
28
47
|
readonly problems: ReadonlyArray<string>;
|
|
29
48
|
};
|
|
30
49
|
|
|
50
|
+
const parseWorkspaceValue = (
|
|
51
|
+
label: string,
|
|
52
|
+
key: string,
|
|
53
|
+
value: unknown,
|
|
54
|
+
layerKind: DevEnvLayerKind,
|
|
55
|
+
): {
|
|
56
|
+
readonly value: DevEnvValue | null;
|
|
57
|
+
readonly problems: ReadonlyArray<string>;
|
|
58
|
+
} => {
|
|
59
|
+
if (typeof value === 'string') {
|
|
60
|
+
return encodeBunDotenvValue(value) === null
|
|
61
|
+
? {
|
|
62
|
+
value: null,
|
|
63
|
+
problems: [
|
|
64
|
+
`${label}.${key} cannot be represented losslessly in Bun dotenv syntax`,
|
|
65
|
+
],
|
|
66
|
+
}
|
|
67
|
+
: { value, problems: [] };
|
|
68
|
+
}
|
|
69
|
+
if (isBrokeredS3ReferenceShape(value)) {
|
|
70
|
+
if (layerKind === 'secrets') {
|
|
71
|
+
return {
|
|
72
|
+
value: null,
|
|
73
|
+
problems: [
|
|
74
|
+
`${label}.${key} is a brokered S3 pair reference; references are configuration and belong in config/dev.yaml or config/dev.local.yaml, not the secrets layer`,
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const parsed = parseBrokeredS3Reference(`${label}.${key}`, value);
|
|
79
|
+
return parsed.ok
|
|
80
|
+
? { value: parsed.reference, problems: [] }
|
|
81
|
+
: { value: null, problems: parsed.problems };
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
value: null,
|
|
85
|
+
problems: [
|
|
86
|
+
layerKind === 'configuration'
|
|
87
|
+
? `${label}.${key} must be a string value or a brokered S3 pair reference`
|
|
88
|
+
: `${label}.${key} must be a string value`,
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
31
93
|
const parseWorkspaceEnv = (
|
|
32
94
|
label: string,
|
|
33
95
|
raw: Record<string, unknown>,
|
|
34
|
-
|
|
96
|
+
layerKind: DevEnvLayerKind,
|
|
97
|
+
): {
|
|
98
|
+
readonly env: EnvValues;
|
|
99
|
+
readonly declaredKeys: ReadonlySet<string>;
|
|
100
|
+
readonly problems: ReadonlyArray<string>;
|
|
101
|
+
} => {
|
|
35
102
|
const problems: Array<string> = [];
|
|
36
|
-
const
|
|
103
|
+
const env = Object.create(null) as Record<string, DevEnvValue>;
|
|
104
|
+
const declaredKeys = new Set<string>();
|
|
37
105
|
for (const [key, value] of Object.entries(raw)) {
|
|
38
106
|
const portableName = PORTABLE_ENV_NAME.test(key);
|
|
39
|
-
if (
|
|
107
|
+
if (portableName) {
|
|
108
|
+
declaredKeys.add(key);
|
|
109
|
+
} else {
|
|
40
110
|
problems.push(
|
|
41
111
|
`${label} env key ${JSON.stringify(key)} must be a portable environment variable name`,
|
|
42
112
|
);
|
|
43
113
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
`${label}.${key} cannot be represented losslessly in Bun dotenv syntax`,
|
|
49
|
-
);
|
|
50
|
-
} else if (portableName) {
|
|
51
|
-
entries.push([key, value]);
|
|
114
|
+
const parsed = parseWorkspaceValue(label, key, value, layerKind);
|
|
115
|
+
problems.push(...parsed.problems);
|
|
116
|
+
if (portableName && parsed.value !== null) {
|
|
117
|
+
env[key] = parsed.value;
|
|
52
118
|
}
|
|
53
119
|
}
|
|
54
|
-
return { env
|
|
120
|
+
return { env, declaredKeys, problems };
|
|
55
121
|
};
|
|
56
122
|
|
|
57
123
|
const parseWorkspaces = (
|
|
58
124
|
source: string,
|
|
59
125
|
group: string,
|
|
60
126
|
workspaces: Record<string, unknown>,
|
|
127
|
+
layerKind: DevEnvLayerKind,
|
|
61
128
|
): ParsedWorkspaces => {
|
|
62
129
|
const problems: Array<string> = [];
|
|
63
130
|
const targets: Array<DevEnvTarget> = [];
|
|
@@ -72,10 +139,16 @@ const parseWorkspaces = (
|
|
|
72
139
|
const parsed = parseWorkspaceEnv(
|
|
73
140
|
`${source} "${group}.${workspace}"`,
|
|
74
141
|
env,
|
|
142
|
+
layerKind,
|
|
75
143
|
);
|
|
76
144
|
problems.push(...parsed.problems);
|
|
77
145
|
if (validWorkspace) {
|
|
78
|
-
targets.push({
|
|
146
|
+
targets.push({
|
|
147
|
+
group,
|
|
148
|
+
workspace,
|
|
149
|
+
env: parsed.env,
|
|
150
|
+
declaredKeys: parsed.declaredKeys,
|
|
151
|
+
});
|
|
79
152
|
}
|
|
80
153
|
} else {
|
|
81
154
|
problems.push(
|
|
@@ -89,22 +162,31 @@ const parseWorkspaces = (
|
|
|
89
162
|
export const parseDevEnvDocument = (
|
|
90
163
|
raw: unknown,
|
|
91
164
|
source: string,
|
|
165
|
+
layerKind: DevEnvLayerKind,
|
|
92
166
|
): DevEnvDocument => {
|
|
93
167
|
if (!isRecord(raw)) {
|
|
94
168
|
return {
|
|
95
169
|
targets: [],
|
|
170
|
+
brokeredReferences: new Set(),
|
|
96
171
|
problems: [`${source} must decrypt to a YAML object`],
|
|
97
172
|
};
|
|
98
173
|
}
|
|
99
174
|
const problems: Array<string> = [];
|
|
100
175
|
const targets: Array<DevEnvTarget> = [];
|
|
176
|
+
const brokeredReferences = new Set<string>();
|
|
101
177
|
for (const [group, workspaces] of Object.entries(raw)) {
|
|
102
|
-
if (
|
|
178
|
+
if (group === BROKERED_REFERENCES_KEY) {
|
|
179
|
+
const parsed = parseBrokeredAllowlist(source, workspaces, layerKind);
|
|
180
|
+
problems.push(...parsed.problems);
|
|
181
|
+
for (const entry of parsed.entries) {
|
|
182
|
+
brokeredReferences.add(entry);
|
|
183
|
+
}
|
|
184
|
+
} else if (!WORKSPACE_GROUPS.includes(group)) {
|
|
103
185
|
problems.push(
|
|
104
|
-
`${source} top-level key "${group}" must be "apps" or "packages"`,
|
|
186
|
+
`${source} top-level key "${group}" must be "apps" or "packages"${layerKind === 'secrets' ? ` or "${BROKERED_REFERENCES_KEY}"` : ''}`,
|
|
105
187
|
);
|
|
106
188
|
} else if (isRecord(workspaces)) {
|
|
107
|
-
const parsed = parseWorkspaces(source, group, workspaces);
|
|
189
|
+
const parsed = parseWorkspaces(source, group, workspaces, layerKind);
|
|
108
190
|
problems.push(...parsed.problems);
|
|
109
191
|
targets.push(...parsed.targets);
|
|
110
192
|
} else {
|
|
@@ -113,5 +195,5 @@ export const parseDevEnvDocument = (
|
|
|
113
195
|
);
|
|
114
196
|
}
|
|
115
197
|
}
|
|
116
|
-
return { targets, problems };
|
|
198
|
+
return { targets, brokeredReferences, problems };
|
|
117
199
|
};
|