@davidvornholt/standards 0.19.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/README.md +7 -5
- package/package.json +19 -1
- package/src/cli.ts +2 -2
- package/src/creds-add-cloudflare-commit.ts +2 -5
- package/src/creds-add-github.ts +82 -30
- package/src/creds-add-policy.ts +106 -26
- package/src/creds-add-preflight.ts +68 -0
- package/src/creds-add.ts +29 -20
- package/src/creds-args.ts +14 -0
- package/src/creds-cloudflare-account.ts +2 -2
- package/src/creds-cloudflare-id.ts +10 -0
- package/src/creds-cloudflare-zone.ts +40 -0
- package/src/creds-commands.ts +34 -11
- package/src/creds-github-app-api.ts +132 -0
- package/src/creds-github-apps.ts +107 -0
- package/src/creds-login-github-manifest.ts +38 -15
- package/src/creds-login-github.ts +78 -24
- package/src/creds-naming.ts +13 -0
- package/src/creds-plan-renew.ts +2 -5
- package/src/creds-plan-report.ts +87 -0
- package/src/creds-plan-run.ts +2 -11
- 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-sops-value.ts +89 -0
- package/src/creds-sops.ts +0 -53
- package/src/creds-store-github.ts +121 -0
- package/src/creds-store-lock-holder.ts +105 -0
- package/src/creds-store-lock-inspection.ts +125 -0
- package/src/creds-store-lock-lease.ts +32 -0
- package/src/creds-store-lock.ts +28 -51
- package/src/creds-store.ts +14 -43
- package/src/dev-env-compose.ts +120 -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 +19 -6
- package/src/dev-env-dotenv.ts +12 -3
- 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 +115 -63
- package/src/github-command-shared.ts +8 -1
- package/src/github-live-drift.ts +3 -3
- package/src/poller-closing-reference.ts +179 -0
- package/src/poller-fix-outcome.ts +9 -0
- package/src/poller-fix-publication.ts +5 -12
- package/src/poller-fix-run.ts +2 -3
- package/src/poller-fix-validation.ts +7 -8
- package/src/poller-outcome.ts +4 -1
- package/src/poller-protected-paths.ts +1 -25
- package/src/poller-protocol.ts +0 -42
- package/src/poller-review-publication.ts +2 -8
- package/src/poller-review-validation.ts +5 -12
package/src/creds-store.ts
CHANGED
|
@@ -6,23 +6,24 @@ import { basename, dirname, join } from 'node:path';
|
|
|
6
6
|
import process from 'node:process';
|
|
7
7
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
8
8
|
import { isCloudflareAccountId } from './creds-cloudflare-account';
|
|
9
|
+
import {
|
|
10
|
+
type GithubBrokerApp,
|
|
11
|
+
githubStoreDocument,
|
|
12
|
+
parseGithubApps,
|
|
13
|
+
validateGithubApps,
|
|
14
|
+
} from './creds-store-github';
|
|
9
15
|
import { withBrokerLock } from './creds-store-lock';
|
|
10
16
|
import { isNonEmptyString, isRecord } from './github-settings-parse';
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
readonly slug: string;
|
|
14
|
-
readonly htmlUrl: string;
|
|
15
|
-
readonly clientId: string;
|
|
16
|
-
readonly privateKey: string;
|
|
17
|
-
};
|
|
17
|
+
|
|
18
|
+
export type { GithubBrokerApp } from './creds-store-github';
|
|
18
19
|
export type CloudflareBrokerAccount = Readonly<
|
|
19
20
|
Record<'accountId' | 'token', string>
|
|
20
21
|
>;
|
|
21
22
|
export type BrokerStore = {
|
|
22
|
-
readonly github: GithubBrokerApp
|
|
23
|
+
readonly github: ReadonlyArray<GithubBrokerApp>;
|
|
23
24
|
readonly cloudflare: ReadonlyArray<CloudflareBrokerAccount>;
|
|
24
25
|
};
|
|
25
|
-
export const EMPTY_BROKER_STORE: BrokerStore = { github:
|
|
26
|
+
export const EMPTY_BROKER_STORE: BrokerStore = { github: [], cloudflare: [] };
|
|
26
27
|
const OWNER_ONLY_FILE_MODE = 0o600;
|
|
27
28
|
const OWNER_ONLY_DIR_MODE = 0o700;
|
|
28
29
|
const FILE_MODE_MODULUS = 0o1000;
|
|
@@ -48,29 +49,6 @@ export const resolveBrokerPath = (): string =>
|
|
|
48
49
|
process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'),
|
|
49
50
|
'standards/broker.yaml',
|
|
50
51
|
);
|
|
51
|
-
const parseGithub = (raw: unknown): GithubBrokerApp | null => {
|
|
52
|
-
if (raw === undefined || raw === null) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
if (
|
|
56
|
-
!isRecord(raw) ||
|
|
57
|
-
typeof raw.app_id !== 'number' ||
|
|
58
|
-
!Number.isInteger(raw.app_id) ||
|
|
59
|
-
!isNonEmptyString(raw.slug) ||
|
|
60
|
-
!isNonEmptyString(raw.html_url) ||
|
|
61
|
-
!isNonEmptyString(raw.client_id) ||
|
|
62
|
-
!isNonEmptyString(raw.private_key)
|
|
63
|
-
) {
|
|
64
|
-
throw new Error('invalid github: run `standards creds login github`');
|
|
65
|
-
}
|
|
66
|
-
return {
|
|
67
|
-
appId: raw.app_id,
|
|
68
|
-
slug: raw.slug,
|
|
69
|
-
htmlUrl: raw.html_url,
|
|
70
|
-
clientId: raw.client_id,
|
|
71
|
-
privateKey: raw.private_key,
|
|
72
|
-
};
|
|
73
|
-
};
|
|
74
52
|
const validateCloudflareAccounts = (
|
|
75
53
|
accounts: ReadonlyArray<CloudflareBrokerAccount>,
|
|
76
54
|
path: string,
|
|
@@ -131,7 +109,7 @@ export const readBrokerStore = async (path: string): Promise<BrokerStore> => {
|
|
|
131
109
|
throw new Error(`${path} must contain a YAML mapping`);
|
|
132
110
|
}
|
|
133
111
|
return {
|
|
134
|
-
github:
|
|
112
|
+
github: parseGithubApps(raw.github, path),
|
|
135
113
|
cloudflare: parseCloudflare(raw.cloudflare, path),
|
|
136
114
|
};
|
|
137
115
|
};
|
|
@@ -142,21 +120,14 @@ export const updateBrokerStore = async (
|
|
|
142
120
|
): Promise<void> =>
|
|
143
121
|
withBrokerLock(path, async () => {
|
|
144
122
|
const store = await update(await readBrokerStore(path));
|
|
123
|
+
validateGithubApps(store.github, path);
|
|
145
124
|
validateCloudflareAccounts(store.cloudflare, path);
|
|
146
125
|
await writeBrokerStoreUnlocked(path, store, sync);
|
|
147
126
|
});
|
|
148
127
|
const storeDocument = (store: BrokerStore): unknown => ({
|
|
149
|
-
...(store.github ===
|
|
128
|
+
...(store.github.length === 0
|
|
150
129
|
? {}
|
|
151
|
-
: {
|
|
152
|
-
github: {
|
|
153
|
-
app_id: store.github.appId,
|
|
154
|
-
slug: store.github.slug,
|
|
155
|
-
html_url: store.github.htmlUrl,
|
|
156
|
-
client_id: store.github.clientId,
|
|
157
|
-
private_key: store.github.privateKey,
|
|
158
|
-
},
|
|
159
|
-
}),
|
|
130
|
+
: { github: githubStoreDocument(store.github) }),
|
|
160
131
|
...(store.cloudflare.length === 0
|
|
161
132
|
? {}
|
|
162
133
|
: {
|
|
@@ -0,0 +1,120 @@
|
|
|
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 { DevEnvDocument, EnvValues } from './dev-env-document';
|
|
8
|
+
|
|
9
|
+
export type DevEnvLayer = {
|
|
10
|
+
readonly source: string;
|
|
11
|
+
readonly document: DevEnvDocument;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type ComposedDevEnvTarget = {
|
|
15
|
+
readonly group: string;
|
|
16
|
+
readonly workspace: string;
|
|
17
|
+
readonly env: EnvValues;
|
|
18
|
+
readonly sources: ReadonlyArray<string>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type ComposedDevEnv = {
|
|
22
|
+
readonly targets: ReadonlyArray<ComposedDevEnvTarget>;
|
|
23
|
+
readonly problems: ReadonlyArray<string>;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type MutableTarget = {
|
|
27
|
+
readonly group: string;
|
|
28
|
+
readonly workspace: string;
|
|
29
|
+
readonly env: Record<string, string>;
|
|
30
|
+
readonly sources: Array<string>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const copyEnv = (source: EnvValues): Record<string, string> => {
|
|
34
|
+
const copy = Object.create(null) as Record<string, string>;
|
|
35
|
+
for (const [key, value] of Object.entries(source)) {
|
|
36
|
+
copy[key] = value;
|
|
37
|
+
}
|
|
38
|
+
return copy;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const applyLayer = (
|
|
42
|
+
merged: Map<string, MutableTarget>,
|
|
43
|
+
layer: DevEnvLayer,
|
|
44
|
+
): void => {
|
|
45
|
+
for (const target of layer.document.targets) {
|
|
46
|
+
const key = `${target.group}.${target.workspace}`;
|
|
47
|
+
const existing = merged.get(key);
|
|
48
|
+
if (existing === undefined) {
|
|
49
|
+
merged.set(key, {
|
|
50
|
+
group: target.group,
|
|
51
|
+
workspace: target.workspace,
|
|
52
|
+
env: copyEnv(target.env),
|
|
53
|
+
sources: [layer.source],
|
|
54
|
+
});
|
|
55
|
+
} else {
|
|
56
|
+
for (const [envKey, value] of Object.entries(target.env)) {
|
|
57
|
+
existing.env[envKey] = value;
|
|
58
|
+
}
|
|
59
|
+
existing.sources.push(layer.source);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const sharedOverlapProblems = (
|
|
65
|
+
config: DevEnvLayer,
|
|
66
|
+
secrets: DevEnvLayer,
|
|
67
|
+
): ReadonlyArray<string> => {
|
|
68
|
+
const secretTargets = new Map(
|
|
69
|
+
secrets.document.targets.map((target) => [
|
|
70
|
+
`${target.group}.${target.workspace}`,
|
|
71
|
+
target,
|
|
72
|
+
]),
|
|
73
|
+
);
|
|
74
|
+
return config.document.targets.flatMap((target) => {
|
|
75
|
+
const counterpart = secretTargets.get(
|
|
76
|
+
`${target.group}.${target.workspace}`,
|
|
77
|
+
);
|
|
78
|
+
if (counterpart === undefined) {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
return [...target.declaredKeys]
|
|
82
|
+
.filter((key) => counterpart.declaredKeys.has(key))
|
|
83
|
+
.map(
|
|
84
|
+
(key) =>
|
|
85
|
+
`${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`,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const composeDevEnv = (
|
|
91
|
+
config: DevEnvLayer | null,
|
|
92
|
+
secrets: DevEnvLayer,
|
|
93
|
+
local: DevEnvLayer | null,
|
|
94
|
+
): ComposedDevEnv => {
|
|
95
|
+
const problems: ReadonlyArray<string> = [
|
|
96
|
+
...(config === null ? [] : config.document.problems),
|
|
97
|
+
...secrets.document.problems,
|
|
98
|
+
...(local === null ? [] : local.document.problems),
|
|
99
|
+
...(config === null ? [] : sharedOverlapProblems(config, secrets)),
|
|
100
|
+
];
|
|
101
|
+
const merged = new Map<string, MutableTarget>();
|
|
102
|
+
for (const layer of [config, secrets, local]) {
|
|
103
|
+
if (layer !== null) {
|
|
104
|
+
applyLayer(merged, layer);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const targets = [...merged.values()]
|
|
108
|
+
.sort((a, b) =>
|
|
109
|
+
a.group === b.group
|
|
110
|
+
? a.workspace.localeCompare(b.workspace)
|
|
111
|
+
: a.group.localeCompare(b.group),
|
|
112
|
+
)
|
|
113
|
+
.map((target) => ({
|
|
114
|
+
group: target.group,
|
|
115
|
+
workspace: target.workspace,
|
|
116
|
+
env: target.env,
|
|
117
|
+
sources: target.sources,
|
|
118
|
+
}));
|
|
119
|
+
return { targets, problems };
|
|
120
|
+
};
|
|
@@ -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
|
@@ -12,6 +12,7 @@ export type DevEnvTarget = {
|
|
|
12
12
|
readonly group: string;
|
|
13
13
|
readonly workspace: string;
|
|
14
14
|
readonly env: EnvValues;
|
|
15
|
+
readonly declaredKeys: ReadonlySet<string>;
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
export type DevEnvDocument = {
|
|
@@ -31,12 +32,19 @@ type ParsedWorkspaces = {
|
|
|
31
32
|
const parseWorkspaceEnv = (
|
|
32
33
|
label: string,
|
|
33
34
|
raw: Record<string, unknown>,
|
|
34
|
-
): {
|
|
35
|
+
): {
|
|
36
|
+
readonly env: EnvValues;
|
|
37
|
+
readonly declaredKeys: ReadonlySet<string>;
|
|
38
|
+
readonly problems: ReadonlyArray<string>;
|
|
39
|
+
} => {
|
|
35
40
|
const problems: Array<string> = [];
|
|
36
|
-
const
|
|
41
|
+
const env = Object.create(null) as Record<string, string>;
|
|
42
|
+
const declaredKeys = new Set<string>();
|
|
37
43
|
for (const [key, value] of Object.entries(raw)) {
|
|
38
44
|
const portableName = PORTABLE_ENV_NAME.test(key);
|
|
39
|
-
if (
|
|
45
|
+
if (portableName) {
|
|
46
|
+
declaredKeys.add(key);
|
|
47
|
+
} else {
|
|
40
48
|
problems.push(
|
|
41
49
|
`${label} env key ${JSON.stringify(key)} must be a portable environment variable name`,
|
|
42
50
|
);
|
|
@@ -48,10 +56,10 @@ const parseWorkspaceEnv = (
|
|
|
48
56
|
`${label}.${key} cannot be represented losslessly in Bun dotenv syntax`,
|
|
49
57
|
);
|
|
50
58
|
} else if (portableName) {
|
|
51
|
-
|
|
59
|
+
env[key] = value;
|
|
52
60
|
}
|
|
53
61
|
}
|
|
54
|
-
return { env
|
|
62
|
+
return { env, declaredKeys, problems };
|
|
55
63
|
};
|
|
56
64
|
|
|
57
65
|
const parseWorkspaces = (
|
|
@@ -75,7 +83,12 @@ const parseWorkspaces = (
|
|
|
75
83
|
);
|
|
76
84
|
problems.push(...parsed.problems);
|
|
77
85
|
if (validWorkspace) {
|
|
78
|
-
targets.push({
|
|
86
|
+
targets.push({
|
|
87
|
+
group,
|
|
88
|
+
workspace,
|
|
89
|
+
env: parsed.env,
|
|
90
|
+
declaredKeys: parsed.declaredKeys,
|
|
91
|
+
});
|
|
79
92
|
}
|
|
80
93
|
} else {
|
|
81
94
|
problems.push(
|
package/src/dev-env-dotenv.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { EnvValues } from './dev-env-document';
|
|
2
2
|
import { encodeBunDotenvValue } from './dev-env-dotenv-value';
|
|
3
3
|
|
|
4
|
+
export const DEV_ENV_GENERATED_HEADER =
|
|
5
|
+
'# Generated by `bun standards dev-env` (just dev-env-generate). Do not edit.';
|
|
6
|
+
|
|
7
|
+
export const hasDevEnvGeneratedHeader = (content: string): boolean => {
|
|
8
|
+
const newline = content.indexOf('\n');
|
|
9
|
+
const firstLine = newline === -1 ? content : content.slice(0, newline);
|
|
10
|
+
return firstLine === DEV_ENV_GENERATED_HEADER;
|
|
11
|
+
};
|
|
12
|
+
|
|
4
13
|
const renderValue = (key: string, value: string): string => {
|
|
5
14
|
const encoded = encodeBunDotenvValue(value);
|
|
6
15
|
if (encoded === null) {
|
|
@@ -11,12 +20,12 @@ const renderValue = (key: string, value: string): string => {
|
|
|
11
20
|
|
|
12
21
|
export const renderDotenv = (
|
|
13
22
|
sourcePath: string,
|
|
14
|
-
|
|
23
|
+
sources: ReadonlyArray<string>,
|
|
15
24
|
env: EnvValues,
|
|
16
25
|
): string =>
|
|
17
26
|
[
|
|
18
|
-
|
|
19
|
-
`#
|
|
27
|
+
DEV_ENV_GENERATED_HEADER,
|
|
28
|
+
`# Sources: ${sources.join(' + ')} (${sourcePath})`,
|
|
20
29
|
...Object.entries(env).map(([key, value]) => renderValue(key, value)),
|
|
21
30
|
'',
|
|
22
31
|
].join('\n');
|