@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/README.md +4 -3
- package/package.json +9 -1
- package/src/cli.ts +1 -1
- package/src/creds-args.ts +9 -0
- package/src/creds-cloudflare-account.ts +2 -2
- package/src/creds-cloudflare-id.ts +10 -0
- package/src/creds-cloudflare-zone.ts +3 -3
- package/src/creds-commands.ts +13 -2
- package/src/creds-naming.ts +13 -0
- 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-store-lock-holder.ts +8 -1
- package/src/creds-store-lock.ts +2 -1
- 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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// One-shot retirement of a Cloudflare token the broker does not manage, named
|
|
2
|
+
// explicitly by ID. Replacing a hand-made credential means minting a brokered
|
|
3
|
+
// one and deleting the original; without this the second half could only be
|
|
4
|
+
// done in the dashboard. The power granted is deliberately momentary: nothing
|
|
5
|
+
// is recorded, nothing becomes automatic, and a human names the target every
|
|
6
|
+
// time. Tokens reconciliation owns are refused — those are retired by deleting
|
|
7
|
+
// their SOPS key and running `standards creds apply`, which keeps the secret
|
|
8
|
+
// and the provider in step.
|
|
9
|
+
|
|
10
|
+
import { deleteAccountToken } from './creds-cloudflare';
|
|
11
|
+
import { isCloudflareId } from './creds-cloudflare-id';
|
|
12
|
+
import { selectAccount } from './creds-dest';
|
|
13
|
+
import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
|
|
14
|
+
import {
|
|
15
|
+
BROKER_IDENTITY_NAME,
|
|
16
|
+
type BrokeredTokenRef,
|
|
17
|
+
parseAnyTokenName,
|
|
18
|
+
} from './creds-naming';
|
|
19
|
+
import { readBrokerStore, resolveBrokerPath } from './creds-store';
|
|
20
|
+
import { resolveGithubRepo } from './github-api';
|
|
21
|
+
|
|
22
|
+
const fail = (message: string): false => {
|
|
23
|
+
console.error(`standards creds: ${message}`);
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
// GitHub owner and repository names are case-insensitive, so a checkout cloned
|
|
28
|
+
// as `DavidVornholt/Example` owns the token minted as `davidvornholt/example`.
|
|
29
|
+
// An exact comparison would call that token foreign and let --force delete a
|
|
30
|
+
// live credential this very checkout reconciles. Owner and name are compared
|
|
31
|
+
// together: `otherowner/example` shares this repository's name and nothing
|
|
32
|
+
// else.
|
|
33
|
+
const sameRepo = (left: string, right: string): boolean =>
|
|
34
|
+
left.toLowerCase() === right.toLowerCase();
|
|
35
|
+
|
|
36
|
+
// A brokered name carries the repository that owns it, and the reconciled
|
|
37
|
+
// remedy — delete the SOPS key, run `apply` — only works in that repository.
|
|
38
|
+
// Naming a bare `<target>:<key>` to an operator standing somewhere else would
|
|
39
|
+
// have them destroy their own live credential of the same name, so the owning
|
|
40
|
+
// repository is always named and the remedy is placed where it runs.
|
|
41
|
+
const brokeredRefusal = ({
|
|
42
|
+
tokenId,
|
|
43
|
+
name,
|
|
44
|
+
brokered,
|
|
45
|
+
currentRepo,
|
|
46
|
+
force,
|
|
47
|
+
}: {
|
|
48
|
+
readonly tokenId: string;
|
|
49
|
+
readonly name: string;
|
|
50
|
+
readonly brokered: BrokeredTokenRef;
|
|
51
|
+
readonly currentRepo: string | null;
|
|
52
|
+
readonly force: boolean;
|
|
53
|
+
}): string | null => {
|
|
54
|
+
// Without a repository here, the one thing --force claims to override — that
|
|
55
|
+
// this checkout is not the owner — is the thing that could not be checked,
|
|
56
|
+
// so the override is refused rather than granted on an unevaluated guard.
|
|
57
|
+
if (currentRepo === null) {
|
|
58
|
+
return `token ${tokenId} is brokered as ${name}, which belongs to ${brokered.repo}, and this checkout cannot resolve the GitHub repository from its origin remote, so whether this checkout owns the token could not be determined; --force widens only that ownership check, so here it has nothing to widen. Re-run from a checkout whose origin remote names a GitHub repository, or point --dir at one. If ${brokered.repo} still exists, the remedy runs there: delete ${brokered.target}:${brokered.key} from its SOPS target and run \`standards creds apply\` in that checkout`;
|
|
59
|
+
}
|
|
60
|
+
if (brokered.repo === currentRepo) {
|
|
61
|
+
return `token ${tokenId} is brokered as ${name} for this repository (${brokered.repo}); delete ${brokered.target}:${brokered.key} from this repository's SOPS target and run \`standards creds apply\`, which revokes it and keeps the secret in step`;
|
|
62
|
+
}
|
|
63
|
+
// Revoke compares repository names case-insensitively, but reconciliation
|
|
64
|
+
// matches the token name exactly, so a checkout whose origin differs only in
|
|
65
|
+
// capitalisation renews nothing and revokes nothing. Sending the operator to
|
|
66
|
+
// the reconciled remedy would prescribe a no-op.
|
|
67
|
+
if (sameRepo(brokered.repo, currentRepo)) {
|
|
68
|
+
return `token ${tokenId} is brokered as ${name}, and this checkout's origin names ${currentRepo}, which differs from ${brokered.repo} only in capitalisation; reconciliation matches the token name exactly, so from here it will neither renew nor revoke this token. Re-point the origin remote at ${brokered.repo} to bring the token back under reconciliation, or retire it in the Cloudflare dashboard`;
|
|
69
|
+
}
|
|
70
|
+
if (force) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return `token ${tokenId} is brokered as ${name}, which belongs to ${brokered.repo}, and this checkout is ${currentRepo}; the remedy runs there: delete ${brokered.target}:${brokered.key} from ${brokered.repo}'s SOPS target and run \`standards creds apply\` in that checkout. Pass --force only if ${brokered.repo} was renamed, transferred, or deleted, so no checkout resolves to that name and no apply will ever revoke this token`;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export const runCredsRevoke = async (options: {
|
|
77
|
+
readonly account: string | undefined;
|
|
78
|
+
readonly tokenId: string | undefined;
|
|
79
|
+
readonly dir: string;
|
|
80
|
+
readonly force: boolean;
|
|
81
|
+
}): Promise<boolean> => {
|
|
82
|
+
const { tokenId } = options;
|
|
83
|
+
if (tokenId === undefined) {
|
|
84
|
+
return fail(
|
|
85
|
+
'revoke requires --token-id <id>; `standards creds plan` prints the ID inside every revoke command it offers, and the Cloudflare dashboard has the ID of anything that listing leaves out',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
if (!isCloudflareId(tokenId)) {
|
|
89
|
+
return fail(
|
|
90
|
+
`not a token ID: ${tokenId}; --token-id takes the 32-character hexadecimal token ID`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
const store = await readBrokerStore(resolveBrokerPath());
|
|
94
|
+
const account = selectAccount(store, options.account);
|
|
95
|
+
if (account === null) {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
const identified = await identifyCloudflareBootstrapAuthority(
|
|
99
|
+
account.accountId,
|
|
100
|
+
account.token,
|
|
101
|
+
);
|
|
102
|
+
if (!identified.ok) {
|
|
103
|
+
return fail(`account ${account.accountId}: ${identified.problem}`);
|
|
104
|
+
}
|
|
105
|
+
// The bootstrap token is matched by its verified ID rather than its name,
|
|
106
|
+
// because deleting it would strip the broker of every power it has and no
|
|
107
|
+
// rename should be able to disguise it.
|
|
108
|
+
if (identified.value.id === tokenId) {
|
|
109
|
+
return fail(
|
|
110
|
+
`token ${tokenId} is this account's broker bootstrap credential; revoking it would leave the broker unable to mint or reconcile anything`,
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const target = identified.value.tokens.find((entry) => entry.id === tokenId);
|
|
114
|
+
if (target === undefined) {
|
|
115
|
+
return fail(
|
|
116
|
+
`no token ${tokenId} in account ${account.accountId}; revoke names a token in the account the bootstrap credential belongs to`,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
// The broker store is machine-global and `creds login` is a per-machine
|
|
120
|
+
// bootstrap, so one account can hold several tokens under the reserved name
|
|
121
|
+
// — one per machine. Only this machine's is identified by ID above; the rest
|
|
122
|
+
// are refused by name, because deleting one locks another machine out of the
|
|
123
|
+
// account and the broker cannot re-mint its own root credential.
|
|
124
|
+
if (target.name === BROKER_IDENTITY_NAME) {
|
|
125
|
+
return fail(
|
|
126
|
+
`token ${tokenId} is named ${BROKER_IDENTITY_NAME}, the reserved name for a machine's broker bootstrap credential; this machine's own bootstrap is a different token, so this one belongs to another machine or is a superseded one — retire it in the Cloudflare dashboard, where you can confirm which machine it belongs to`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const brokered = parseAnyTokenName(target.name);
|
|
130
|
+
if (brokered !== null) {
|
|
131
|
+
const refusal = brokeredRefusal({
|
|
132
|
+
tokenId,
|
|
133
|
+
name: target.name,
|
|
134
|
+
brokered,
|
|
135
|
+
currentRepo: resolveGithubRepo(options.dir),
|
|
136
|
+
force: options.force,
|
|
137
|
+
});
|
|
138
|
+
if (refusal !== null) {
|
|
139
|
+
return fail(refusal);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const deleted = await deleteAccountToken(
|
|
143
|
+
account.accountId,
|
|
144
|
+
account.token,
|
|
145
|
+
tokenId,
|
|
146
|
+
);
|
|
147
|
+
if (!deleted.ok) {
|
|
148
|
+
return fail(`${target.name}: ${deleted.problem}`);
|
|
149
|
+
}
|
|
150
|
+
console.log(
|
|
151
|
+
`standards creds: revoked ${target.name} (${account.accountId}/${tokenId})`,
|
|
152
|
+
);
|
|
153
|
+
if (brokered !== null) {
|
|
154
|
+
console.log(
|
|
155
|
+
` it was brokered to ${brokered.repo}; --force took it because this checkout is not that repository`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
console.log(
|
|
159
|
+
' any stored copy of its value is now dead; replace it with `standards creds add cloudflare` if something still needs it',
|
|
160
|
+
);
|
|
161
|
+
return true;
|
|
162
|
+
};
|
|
@@ -10,6 +10,13 @@ const OWNER_ONLY_FILE_MODE = 0o600;
|
|
|
10
10
|
const HOLDER_PREFIX = 'holder-';
|
|
11
11
|
const HOLDER_SUFFIX = '.json';
|
|
12
12
|
const LEASE_DIVISOR = 3;
|
|
13
|
+
// Both codes mean the lock is no longer this owner's to clean up. ENOENT: it is
|
|
14
|
+
// already gone. ENOTEMPTY: a contender published its own generation into the
|
|
15
|
+
// directory during the moment the unlink left it empty — renaming onto an empty
|
|
16
|
+
// directory succeeds, which is exactly how a waiter takes the lock. Releasing
|
|
17
|
+
// runs from a finally, so throwing here would replace the result of an
|
|
18
|
+
// operation that already succeeded.
|
|
19
|
+
const RELEASABLE_RMDIR_CODES = ['ENOENT', 'ENOTEMPTY'] as const;
|
|
13
20
|
|
|
14
21
|
export type HeldBrokerLock = {
|
|
15
22
|
readonly holderPath: string;
|
|
@@ -91,7 +98,7 @@ export const releaseBrokerLock = async ({
|
|
|
91
98
|
try {
|
|
92
99
|
await rmdir(lockPath);
|
|
93
100
|
} catch (error) {
|
|
94
|
-
if (!isErrorCode(error,
|
|
101
|
+
if (!RELEASABLE_RMDIR_CODES.some((code) => isErrorCode(error, code))) {
|
|
95
102
|
throw error;
|
|
96
103
|
}
|
|
97
104
|
}
|
package/src/creds-store-lock.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// place before its operation starts. A worker-thread lease remains live while
|
|
3
3
|
// synchronous SOPS/Nix blocks the main event loop. Cleanup unlinks the exact
|
|
4
4
|
// generation token before removing the directory, so an old owner cannot
|
|
5
|
-
// remove a replacement
|
|
5
|
+
// remove a replacement — and finding one there instead of an empty directory
|
|
6
|
+
// is a no-op, never a failure of the operation being unwound.
|
|
6
7
|
|
|
7
8
|
import { mkdir } from 'node:fs/promises';
|
|
8
9
|
import { dirname } from 'node:path';
|
|
@@ -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');
|