@smoothbricks/cli 0.11.17 → 0.11.19
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 +45 -10
- package/dist/cli.js +10 -7
- package/dist/github-ci/index.d.ts.map +1 -1
- package/dist/github-ci/index.js +6 -20
- package/dist/lib/deploy-tags.d.ts +17 -3
- package/dist/lib/deploy-tags.d.ts.map +1 -1
- package/dist/lib/deploy-tags.js +23 -4
- package/dist/monorepo/ci-workflow.d.ts +56 -0
- package/dist/monorepo/ci-workflow.d.ts.map +1 -1
- package/dist/monorepo/ci-workflow.js +199 -2
- package/dist/monorepo/managed-files.d.ts +28 -2
- package/dist/monorepo/managed-files.d.ts.map +1 -1
- package/dist/monorepo/managed-files.js +74 -22
- package/dist/release/github-release.d.ts +10 -0
- package/dist/release/github-release.d.ts.map +1 -1
- package/dist/release/github-release.js +11 -5
- package/dist/release/index.d.ts.map +1 -1
- package/dist/release/index.js +4 -5
- package/dist/secrets/commands.d.ts +52 -13
- package/dist/secrets/commands.d.ts.map +1 -1
- package/dist/secrets/commands.js +220 -34
- package/dist/secrets/index.d.ts +21 -1
- package/dist/secrets/index.d.ts.map +1 -1
- package/dist/secrets/index.js +110 -4
- package/dist/secrets/repository.d.ts +60 -0
- package/dist/secrets/repository.d.ts.map +1 -0
- package/dist/secrets/repository.js +145 -0
- package/dist/wrangler/cloudflare.d.ts +7 -0
- package/dist/wrangler/cloudflare.d.ts.map +1 -1
- package/dist/wrangler/cloudflare.js +10 -0
- package/dist/wrangler/deploy-stage.d.ts.map +1 -1
- package/dist/wrangler/deploy-stage.js +50 -24
- package/dist/wrangler/stage-secrets.d.ts +57 -0
- package/dist/wrangler/stage-secrets.d.ts.map +1 -0
- package/dist/wrangler/stage-secrets.js +178 -0
- package/managed/raw/tooling/direnv/devenv.smoo.nix +9 -1
- package/managed/raw/tooling/git-hooks/pre-push.sh +30 -29
- package/package.json +2 -2
- package/src/cli.ts +33 -10
- package/src/github-ci/index.test.ts +6 -7
- package/src/github-ci/index.ts +11 -19
- package/src/lib/deploy-tags.ts +22 -4
- package/src/monorepo/__tests__/ci-workflow.test.ts +161 -0
- package/src/monorepo/ci-workflow.ts +277 -2
- package/src/monorepo/managed-files.test.ts +96 -37
- package/src/monorepo/managed-files.ts +94 -23
- package/src/monorepo/package-policy.test.ts +1 -1
- package/src/release/__tests__/github-release.test.ts +8 -4
- package/src/release/github-release.ts +13 -5
- package/src/release/index.ts +4 -4
- package/src/secrets/commands.test.ts +28 -0
- package/src/secrets/commands.ts +237 -35
- package/src/secrets/index.test.ts +118 -1
- package/src/secrets/index.ts +108 -4
- package/src/secrets/repository.test.ts +98 -0
- package/src/secrets/repository.ts +164 -0
- package/src/wrangler/cloudflare.ts +18 -0
- package/src/wrangler/deploy-stage.test.ts +258 -11
- package/src/wrangler/deploy-stage.ts +70 -38
- package/src/wrangler/stage-secrets.ts +146 -0
package/dist/secrets/commands.js
CHANGED
|
@@ -5,15 +5,70 @@
|
|
|
5
5
|
* world-readable) and never printed. Reading one from the terminal disables
|
|
6
6
|
* echo, so a pasted key does not end up in a scrollback buffer or a screen
|
|
7
7
|
* share.
|
|
8
|
+
*
|
|
9
|
+
* A value lives at repository scope or inside a named GitHub Environment, and
|
|
10
|
+
* a job bound to an environment resolves `secrets.X` from that environment
|
|
11
|
+
* first and from the repository second — which is how one name carries test
|
|
12
|
+
* credentials on a preview stage and live ones in production. Every scope the
|
|
13
|
+
* workflows bind is read before anything is called missing: reading only the
|
|
14
|
+
* repository reports values that do exist as absent, and sends an operator
|
|
15
|
+
* hunting for a secret that is already set.
|
|
8
16
|
*/
|
|
9
17
|
import { spawn, spawnSync } from 'node:child_process';
|
|
10
18
|
import { getWorkspacePackages } from '../lib/workspace.js';
|
|
11
|
-
import { fetchLocalSecret, localSecretCommandNames, reconcileSecrets, secretNameMapping, unsatisfiedSecrets, unwiredSecrets, workerSecretNames, workflowSecretNames, } from './index.js';
|
|
12
|
-
|
|
13
|
-
|
|
19
|
+
import { environmentsMissing, fetchLocalSecret, localSecretCommandNames, reconcileSecrets, secretNameMapping, unsatisfiedSecrets, unwiredSecrets, workerSecretNames, workflowEnvironments, workflowSecretNames, } from './index.js';
|
|
20
|
+
import { resolveRepository } from './repository.js';
|
|
21
|
+
/**
|
|
22
|
+
* Every secret this checkout declares that the repository does not hold, in
|
|
23
|
+
* the order an operator should fix them: the ones a workflow already promises
|
|
24
|
+
* break a deploy today, the rest break the next one.
|
|
25
|
+
*/
|
|
26
|
+
export function secretsNeedingValues(rows) {
|
|
27
|
+
const missing = rows.filter((row) => !row.onRepository);
|
|
28
|
+
return [...missing.filter((row) => row.suppliedByWorkflow), ...missing.filter((row) => !row.suppliedByWorkflow)];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Every secret this checkout declares that the named environment does not
|
|
32
|
+
* hold. A name the repository already holds is still offered: an environment
|
|
33
|
+
* value overrides the repository's for a job bound to it, and overriding one
|
|
34
|
+
* stage's credentials is the reason to write at environment scope at all. A
|
|
35
|
+
* repository secret no source declares is not offered — that is a stale value
|
|
36
|
+
* to review, not one to copy into an environment.
|
|
37
|
+
*/
|
|
38
|
+
export function secretsMissingInEnvironment(rows, environment) {
|
|
39
|
+
const missing = rows.filter((row) => !row.heldByEnvironment.includes(environment) &&
|
|
40
|
+
(row.declaredByWorkers.length > 0 || row.suppliedByWorkflow || row.fetchableLocally));
|
|
41
|
+
return [...missing.filter((row) => row.suppliedByWorkflow), ...missing.filter((row) => !row.suppliedByWorkflow)];
|
|
42
|
+
}
|
|
43
|
+
/** Why a secret is wanted, so a prompt states what will break without it. */
|
|
44
|
+
export function describeNeed(row) {
|
|
45
|
+
const parts = [];
|
|
46
|
+
if (row.declaredByWorkers.length > 0)
|
|
47
|
+
parts.push(`declared by ${row.declaredByWorkers.join(', ')}`);
|
|
48
|
+
if (row.suppliedByWorkflow)
|
|
49
|
+
parts.push('passed by a managed workflow');
|
|
50
|
+
if (row.fetchableLocally)
|
|
51
|
+
parts.push('fetchable locally via smoo.secrets');
|
|
52
|
+
if (row.repositorySecret !== row.name)
|
|
53
|
+
parts.push(`read as ${row.name}`);
|
|
54
|
+
// Naming the environments that already hold it stops an operator from
|
|
55
|
+
// answering a per-stage prompt with a repository-wide value.
|
|
56
|
+
if (row.heldByEnvironment.length > 0)
|
|
57
|
+
parts.push(`held by ${row.heldByEnvironment.join(', ')}`);
|
|
58
|
+
return parts.length > 0 ? ` - ${parts.join('; ')}` : '';
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Secret names GitHub currently holds at the given scope, or a refusal naming
|
|
62
|
+
* what failed. With no environment that is repository scope; with one it is
|
|
63
|
+
* that environment's own secrets, which are the values a bound job reads
|
|
64
|
+
* first.
|
|
65
|
+
*/
|
|
66
|
+
export function readRepositorySecrets(repo, environment) {
|
|
14
67
|
const args = ['secret', 'list', '--json', 'name'];
|
|
15
68
|
if (repo)
|
|
16
69
|
args.push('--repo', repo);
|
|
70
|
+
if (environment)
|
|
71
|
+
args.push('--env', environment);
|
|
17
72
|
const result = spawnSync('gh', args, { encoding: 'utf8' });
|
|
18
73
|
if (result.status !== 0) {
|
|
19
74
|
return {
|
|
@@ -37,11 +92,13 @@ export function readRepositorySecrets(repo) {
|
|
|
37
92
|
}
|
|
38
93
|
return { ok: true, names: names.sort((left, right) => left.localeCompare(right)) };
|
|
39
94
|
}
|
|
40
|
-
/** Write one secret, value over stdin so it never reaches argv. */
|
|
41
|
-
export async function writeRepositorySecret(name, value, repo) {
|
|
95
|
+
/** Write one secret at the given scope, value over stdin so it never reaches argv. */
|
|
96
|
+
export async function writeRepositorySecret(name, value, repo, environment) {
|
|
42
97
|
const args = ['secret', 'set', name];
|
|
43
98
|
if (repo)
|
|
44
99
|
args.push('--repo', repo);
|
|
100
|
+
if (environment)
|
|
101
|
+
args.push('--env', environment);
|
|
45
102
|
const child = spawn('gh', args, { stdio: ['pipe', 'inherit', 'inherit'] });
|
|
46
103
|
child.stdin.end(value);
|
|
47
104
|
const status = await new Promise((resolvePromise) => {
|
|
@@ -51,7 +108,7 @@ export async function writeRepositorySecret(name, value, repo) {
|
|
|
51
108
|
? { ok: true }
|
|
52
109
|
: { ok: false, reason: `gh ${args.join(' ')} exited ${status ?? 'without status'}` };
|
|
53
110
|
}
|
|
54
|
-
function collectSources(root, repositorySecrets) {
|
|
111
|
+
function collectSources(root, repositorySecrets, environmentSecrets = {}) {
|
|
55
112
|
const workspaceDirs = getWorkspacePackages(root).map((pkg) => pkg.path);
|
|
56
113
|
const workerSecrets = workerSecretNames(root, workspaceDirs);
|
|
57
114
|
const workflowSecrets = workflowSecretNames(root);
|
|
@@ -65,12 +122,18 @@ function collectSources(root, repositorySecrets) {
|
|
|
65
122
|
secretNames: secretNameMapping(root, envNames),
|
|
66
123
|
localCommands,
|
|
67
124
|
repositorySecrets,
|
|
125
|
+
environmentSecrets,
|
|
68
126
|
};
|
|
69
127
|
}
|
|
70
|
-
|
|
128
|
+
/** Every scope holding a value for this name, repository first. */
|
|
129
|
+
function heldBy(row) {
|
|
130
|
+
const scopes = row.onRepository ? ['repository', ...row.heldByEnvironment] : row.heldByEnvironment;
|
|
131
|
+
return scopes.length > 0 ? scopes.join(', ') : 'ABSENT';
|
|
132
|
+
}
|
|
133
|
+
function describe(row, scopeWidth) {
|
|
71
134
|
const where = row.declaredByWorkers.length > 0 ? row.declaredByWorkers.join(', ') : '—';
|
|
72
135
|
return [
|
|
73
|
-
row.
|
|
136
|
+
heldBy(row).padEnd(scopeWidth),
|
|
74
137
|
row.name.padEnd(32),
|
|
75
138
|
row.suppliedByWorkflow ? 'workflow' : ' ',
|
|
76
139
|
row.fetchableLocally ? 'local' : ' ',
|
|
@@ -78,22 +141,52 @@ function describe(row) {
|
|
|
78
141
|
].join(' ');
|
|
79
142
|
}
|
|
80
143
|
/**
|
|
81
|
-
* Print every known secret
|
|
82
|
-
* promises a value
|
|
83
|
-
* that fails a deploy, and it fails talking about the
|
|
84
|
-
* missing secret.
|
|
144
|
+
* Print every known secret and the scopes holding it. Exit code 1 when a
|
|
145
|
+
* workflow promises a value no scope a bound job reads can supply — that
|
|
146
|
+
* combination is the one that fails a deploy, and it fails talking about the
|
|
147
|
+
* value rather than the missing secret.
|
|
85
148
|
*/
|
|
86
149
|
export function secretsStatus(root, options) {
|
|
87
|
-
const
|
|
150
|
+
const resolved = resolveRepository(root, options.repo);
|
|
151
|
+
if (!resolved.ok) {
|
|
152
|
+
console.error(resolved.reason);
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
const { repo, source } = resolved.choice;
|
|
156
|
+
const repositorySecrets = readRepositorySecrets(repo);
|
|
88
157
|
if (!repositorySecrets.ok) {
|
|
89
158
|
console.error(repositorySecrets.reason);
|
|
90
159
|
return 1;
|
|
91
160
|
}
|
|
92
|
-
|
|
93
|
-
|
|
161
|
+
// The workflows decide which environments a job reads; an explicitly asked
|
|
162
|
+
// for one is shown too, so an operator can inspect a scope before a workflow
|
|
163
|
+
// binds it.
|
|
164
|
+
const boundEnvironments = workflowEnvironments(root);
|
|
165
|
+
const asked = options.env !== undefined && !boundEnvironments.includes(options.env) ? [options.env] : [];
|
|
166
|
+
const environmentSecrets = {};
|
|
167
|
+
const unreadable = [];
|
|
168
|
+
for (const environment of [...boundEnvironments, ...asked]) {
|
|
169
|
+
const held = readRepositorySecrets(repo, environment);
|
|
170
|
+
if (held.ok)
|
|
171
|
+
environmentSecrets[environment] = held.names;
|
|
172
|
+
else
|
|
173
|
+
unreadable.push({ environment, reason: held.reason });
|
|
174
|
+
}
|
|
175
|
+
// An environment that cannot be read is reported, never assumed empty:
|
|
176
|
+
// guessing produces a refusal about a value that may well be set.
|
|
177
|
+
const readable = boundEnvironments.filter((environment) => environment in environmentSecrets);
|
|
178
|
+
const rows = reconcileSecrets(collectSources(root, repositorySecrets.names, environmentSecrets));
|
|
179
|
+
console.log(`repository ${repo} (${source}), holding ${repositorySecrets.names.length} secrets`);
|
|
180
|
+
for (const [environment, names] of Object.entries(environmentSecrets)) {
|
|
181
|
+
console.log(`environment ${environment}, holding ${names.length} secrets`);
|
|
182
|
+
}
|
|
183
|
+
for (const { environment, reason } of unreadable) {
|
|
184
|
+
console.log(`environment ${environment} could not be read, so nothing below claims what it holds: ${reason}`);
|
|
185
|
+
}
|
|
186
|
+
const scopeWidth = Math.max('held by'.length, ...rows.map((row) => heldBy(row).length));
|
|
187
|
+
console.log(`${'held by'.padEnd(scopeWidth)} ${'name'.padEnd(32)} workflow local declared by`);
|
|
94
188
|
for (const row of rows)
|
|
95
|
-
console.log(describe(row));
|
|
96
|
-
const unsatisfied = unsatisfiedSecrets(rows);
|
|
189
|
+
console.log(describe(row, scopeWidth));
|
|
97
190
|
const unwired = unwiredSecrets(rows);
|
|
98
191
|
if (unwired.length > 0) {
|
|
99
192
|
console.log('');
|
|
@@ -102,15 +195,39 @@ export function secretsStatus(root, options) {
|
|
|
102
195
|
'declare it in smoo.github.deploySecrets to have CI supply it.');
|
|
103
196
|
}
|
|
104
197
|
}
|
|
105
|
-
|
|
198
|
+
// A value an environment holds is not a value to duplicate at repository
|
|
199
|
+
// scope: offering it invites two sources of truth for one credential, and
|
|
200
|
+
// the table above already shows where it lives. Only a name no scope holds
|
|
201
|
+
// is a name to set here.
|
|
202
|
+
const needed = secretsNeedingValues(rows).filter((row) => !row.onRepository && row.heldByEnvironment.length === 0);
|
|
203
|
+
if (needed.length > 0) {
|
|
204
|
+
console.log('');
|
|
205
|
+
console.log(`${needed.length} secret(s) have no value in any scope on ${repo}:`);
|
|
206
|
+
for (const row of needed)
|
|
207
|
+
console.log(` ${row.repositorySecret}${describeNeed(row)}`);
|
|
106
208
|
console.log('');
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
209
|
+
console.log(`set them all, one prompt each: smoo secrets set -R ${repo}`);
|
|
210
|
+
console.log(`set one: smoo secrets set ${needed[0]?.repositorySecret ?? 'NAME'} -R ${repo}`);
|
|
211
|
+
}
|
|
212
|
+
const unsatisfied = unsatisfiedSecrets(rows, readable);
|
|
213
|
+
if (unsatisfied.length === 0)
|
|
214
|
+
return 0;
|
|
215
|
+
console.log('');
|
|
216
|
+
for (const row of unsatisfied) {
|
|
217
|
+
const missing = environmentsMissing(row, readable);
|
|
218
|
+
const scopes = missing.length > 0 ? missing.join(', ') : 'the repository';
|
|
219
|
+
console.error(`missing: ${row.name} — a workflow passes secrets.${row.name} and no value exists in ${scopes}.`);
|
|
220
|
+
if (missing.length === 0) {
|
|
221
|
+
console.error(` smoo secrets set ${row.repositorySecret} -R ${repo}`);
|
|
222
|
+
continue;
|
|
110
223
|
}
|
|
111
|
-
|
|
224
|
+
for (const environment of missing) {
|
|
225
|
+
const named = environment.includes(' ') ? `'${environment}'` : environment;
|
|
226
|
+
console.error(` smoo secrets set ${row.repositorySecret} -R ${repo} --env ${named}`);
|
|
227
|
+
}
|
|
228
|
+
console.error(` or one value for every environment: smoo secrets set ${row.repositorySecret} -R ${repo}`);
|
|
112
229
|
}
|
|
113
|
-
return
|
|
230
|
+
return 1;
|
|
114
231
|
}
|
|
115
232
|
/** Read a value with echo disabled when stdin is a terminal; otherwise read piped input. */
|
|
116
233
|
async function readSecretValue(prompt) {
|
|
@@ -143,32 +260,101 @@ async function readSecretValue(prompt) {
|
|
|
143
260
|
}
|
|
144
261
|
return value;
|
|
145
262
|
}
|
|
146
|
-
/**
|
|
147
|
-
|
|
148
|
-
|
|
263
|
+
/**
|
|
264
|
+
* Set one secret, or - with no name - walk every secret this checkout declares
|
|
265
|
+
* and the target scope does not hold, prompting once each. The walk is the
|
|
266
|
+
* point of the command: an operator should never have to transcribe names out
|
|
267
|
+
* of a status table to know what to paste.
|
|
268
|
+
*/
|
|
269
|
+
export async function secretsSet(root, name, options) {
|
|
270
|
+
const resolved = resolveRepository(root, options.repo);
|
|
271
|
+
if (!resolved.ok) {
|
|
272
|
+
console.error(resolved.reason);
|
|
273
|
+
return 1;
|
|
274
|
+
}
|
|
275
|
+
const { repo, source } = resolved.choice;
|
|
276
|
+
const environment = options.env;
|
|
277
|
+
if (name !== undefined)
|
|
278
|
+
return setOne(name, name, repo, environment);
|
|
279
|
+
const held = readRepositorySecrets(repo);
|
|
280
|
+
if (!held.ok) {
|
|
281
|
+
console.error(held.reason);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
const environmentSecrets = {};
|
|
285
|
+
if (environment !== undefined) {
|
|
286
|
+
const inEnvironment = readRepositorySecrets(repo, environment);
|
|
287
|
+
if (!inEnvironment.ok) {
|
|
288
|
+
console.error(inEnvironment.reason);
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
environmentSecrets[environment] = inEnvironment.names;
|
|
292
|
+
}
|
|
293
|
+
const rows = reconcileSecrets(collectSources(root, held.names, environmentSecrets));
|
|
294
|
+
const needed = environment === undefined ? secretsNeedingValues(rows) : secretsMissingInEnvironment(rows, environment);
|
|
295
|
+
const target = environment === undefined ? `${repo} (${source})` : `environment ${environment} on ${repo} (${source})`;
|
|
296
|
+
if (needed.length === 0) {
|
|
297
|
+
console.log(`${target} already holds every secret this checkout declares.`);
|
|
298
|
+
return 0;
|
|
299
|
+
}
|
|
300
|
+
console.log(`${needed.length} secret(s) to set on ${target}. Empty input skips one.`);
|
|
301
|
+
let failed = 0;
|
|
302
|
+
let set = 0;
|
|
303
|
+
for (const row of needed) {
|
|
304
|
+
console.log('');
|
|
305
|
+
console.log(`${row.repositorySecret}${describeNeed(row)}`);
|
|
306
|
+
const value = await readSecretValue(' paste value (not echoed, empty to skip): ');
|
|
307
|
+
if (value.length === 0) {
|
|
308
|
+
console.log(`skip ${row.repositorySecret}`);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
const written = await writeRepositorySecret(row.repositorySecret, value, repo, environment);
|
|
312
|
+
if (!written.ok) {
|
|
313
|
+
console.error(`fail ${row.repositorySecret}: ${written.reason}`);
|
|
314
|
+
failed += 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
console.log(`set ${row.repositorySecret}`);
|
|
318
|
+
set += 1;
|
|
319
|
+
}
|
|
320
|
+
console.log('');
|
|
321
|
+
console.log(`${set} set, ${needed.length - set - failed} skipped, ${failed} failed.`);
|
|
322
|
+
return failed === 0 ? 0 : 1;
|
|
323
|
+
}
|
|
324
|
+
async function setOne(envName, repositorySecret, repo, environment) {
|
|
325
|
+
const scope = environment === undefined ? '' : ` in ${environment}`;
|
|
326
|
+
const value = await readSecretValue(`Value for ${repositorySecret}${scope} (not echoed): `);
|
|
149
327
|
if (value.length === 0) {
|
|
150
|
-
console.error(`${
|
|
328
|
+
console.error(`${repositorySecret}: refusing to set an empty value.`);
|
|
151
329
|
return 1;
|
|
152
330
|
}
|
|
153
|
-
const written = await writeRepositorySecret(
|
|
331
|
+
const written = await writeRepositorySecret(repositorySecret, value, repo, environment);
|
|
154
332
|
if (!written.ok) {
|
|
155
333
|
console.error(written.reason);
|
|
156
334
|
return 1;
|
|
157
335
|
}
|
|
158
|
-
console.log(`set ${
|
|
336
|
+
console.log(`set ${repositorySecret}${scope}${envName === repositorySecret ? '' : ` (reads as ${envName})`}`);
|
|
159
337
|
return 0;
|
|
160
338
|
}
|
|
161
339
|
/**
|
|
162
|
-
* Push every locally fetchable secret to the repository
|
|
163
|
-
* `smoo.secrets` declares a command for: everything else has no
|
|
164
|
-
* and must be pasted with `smoo secrets set`.
|
|
340
|
+
* Push every locally fetchable secret to the repository, or to one environment.
|
|
341
|
+
* Only names `smoo.secrets` declares a command for: everything else has no
|
|
342
|
+
* source here and must be pasted with `smoo secrets set`.
|
|
165
343
|
*/
|
|
166
344
|
export async function secretsSync(root, options) {
|
|
345
|
+
const resolved = resolveRepository(root, options.repo);
|
|
346
|
+
if (!resolved.ok) {
|
|
347
|
+
console.error(resolved.reason);
|
|
348
|
+
return 1;
|
|
349
|
+
}
|
|
350
|
+
const { repo } = resolved.choice;
|
|
351
|
+
const environment = options.env;
|
|
167
352
|
const names = localSecretCommandNames(root);
|
|
168
353
|
if (names.length === 0) {
|
|
169
354
|
console.error('smoo.secrets declares no fetch commands; nothing to sync.');
|
|
170
355
|
return 1;
|
|
171
356
|
}
|
|
357
|
+
const scope = environment === undefined ? '' : ` in ${environment}`;
|
|
172
358
|
let failed = 0;
|
|
173
359
|
for (const name of names) {
|
|
174
360
|
const fetched = fetchLocalSecret(root, name);
|
|
@@ -177,13 +363,13 @@ export async function secretsSync(root, options) {
|
|
|
177
363
|
failed += 1;
|
|
178
364
|
continue;
|
|
179
365
|
}
|
|
180
|
-
const written = await writeRepositorySecret(name, fetched.value,
|
|
366
|
+
const written = await writeRepositorySecret(name, fetched.value, repo, environment);
|
|
181
367
|
if (!written.ok) {
|
|
182
368
|
console.error(`fail ${name}: ${written.reason}`);
|
|
183
369
|
failed += 1;
|
|
184
370
|
continue;
|
|
185
371
|
}
|
|
186
|
-
console.log(`set ${name}`);
|
|
372
|
+
console.log(`set ${name}${scope}`);
|
|
187
373
|
}
|
|
188
374
|
return failed === 0 ? 0 : 1;
|
|
189
375
|
}
|
package/dist/secrets/index.d.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface SecretRow {
|
|
|
31
31
|
fetchableLocally: boolean;
|
|
32
32
|
/** True when the repository holds a secret of this name. */
|
|
33
33
|
onRepository: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* GitHub Environments holding their own value for this name. A job bound to
|
|
36
|
+
* an environment reads that value in preference to the repository's, which
|
|
37
|
+
* is how one name carries test credentials on a preview stage and live ones
|
|
38
|
+
* in production.
|
|
39
|
+
*/
|
|
40
|
+
heldByEnvironment: string[];
|
|
34
41
|
}
|
|
35
42
|
export interface SecretSources {
|
|
36
43
|
/** Worker label -> env names it declares. */
|
|
@@ -43,6 +50,8 @@ export interface SecretSources {
|
|
|
43
50
|
localCommands: readonly string[];
|
|
44
51
|
/** Repository secret names GitHub currently holds. */
|
|
45
52
|
repositorySecrets: readonly string[];
|
|
53
|
+
/** Environment name -> secret names that environment holds. */
|
|
54
|
+
environmentSecrets?: Readonly<Record<string, readonly string[]>>;
|
|
46
55
|
}
|
|
47
56
|
/**
|
|
48
57
|
* One row per name known to any source, sorted, so a reader sees the whole
|
|
@@ -55,11 +64,22 @@ export declare function reconcileSecrets(sources: SecretSources): SecretRow[];
|
|
|
55
64
|
* separately from "declared but not wired into any workflow", because the
|
|
56
65
|
* remedies differ — set a secret, versus declare it in `smoo.github`.
|
|
57
66
|
*/
|
|
58
|
-
export declare function unsatisfiedSecrets(rows: readonly SecretRow[]): SecretRow[];
|
|
67
|
+
export declare function unsatisfiedSecrets(rows: readonly SecretRow[], boundEnvironments?: readonly string[]): SecretRow[];
|
|
68
|
+
/** Bound environments that lack their own value and cannot fall back to the repository. */
|
|
69
|
+
export declare function environmentsMissing(row: SecretRow, boundEnvironments: readonly string[]): string[];
|
|
59
70
|
/** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
|
|
60
71
|
export declare function unwiredSecrets(rows: readonly SecretRow[]): SecretRow[];
|
|
61
72
|
/** Env names the managed workflows pass into a job, from the declarations that render them. */
|
|
62
73
|
export declare function workflowSecretNames(root: string): string[];
|
|
74
|
+
/**
|
|
75
|
+
* GitHub Environments the rendered workflows bind, read from the workflow
|
|
76
|
+
* files rather than from config: a binding may be hand-authored inside a
|
|
77
|
+
* `smoo-local` block, and the file is what GitHub executes either way. A job
|
|
78
|
+
* bound to an environment resolves `secrets.X` from that environment first and
|
|
79
|
+
* from the repository second, so these names are the scopes a value can live
|
|
80
|
+
* in. Expressions are skipped - a computed environment names no fixed scope.
|
|
81
|
+
*/
|
|
82
|
+
export declare function workflowEnvironments(root: string): string[];
|
|
63
83
|
/**
|
|
64
84
|
* Env name -> repository secret for every name in play, following the naming
|
|
65
85
|
* convention and honouring the declared exceptions a repository still needs.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/secrets/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AASH,mGAAmG;AACnG,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,gBAAgB,EAAE,MAAM,CAAC;IACzB,+DAA+D;IAC/D,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,wEAAwE;IACxE,kBAAkB,EAAE,OAAO,CAAC;IAC5B,mEAAmE;IACnE,gBAAgB,EAAE,OAAO,CAAC;IAC1B,4DAA4D;IAC5D,YAAY,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/secrets/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;GAmBG;AASH,mGAAmG;AACnG,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,oFAAoF;IACpF,gBAAgB,EAAE,MAAM,CAAC;IACzB,+DAA+D;IAC/D,iBAAiB,EAAE,MAAM,EAAE,CAAC;IAC5B,wEAAwE;IACxE,kBAAkB,EAAE,OAAO,CAAC;IAC5B,mEAAmE;IACnE,gBAAgB,EAAE,OAAO,CAAC;IAC1B,4DAA4D;IAC5D,YAAY,EAAE,OAAO,CAAC;IACtB;;;;;OAKG;IACH,iBAAiB,EAAE,MAAM,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,aAAa;IAC5B,6CAA6C;IAC7C,aAAa,EAAE,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC;IACjD,sDAAsD;IACtD,eAAe,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,6EAA6E;IAC7E,WAAW,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IAC9C,kDAAkD;IAClD,aAAa,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC,sDAAsD;IACtD,iBAAiB,EAAE,SAAS,MAAM,EAAE,CAAC;IACrC,+DAA+D;IAC/D,kBAAkB,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,CAAC,CAAC,CAAC;CAClE;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,aAAa,GAAG,SAAS,EAAE,CAoCpE;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,SAAS,SAAS,EAAE,EAAE,iBAAiB,GAAE,SAAS,MAAM,EAAO,GAAG,SAAS,EAAE,CAUrH;AAED,2FAA2F;AAC3F,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,SAAS,EAAE,iBAAiB,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,EAAE,CAGlG;AAED,2FAA2F;AAC3F,wBAAgB,cAAc,CAAC,IAAI,EAAE,SAAS,SAAS,EAAE,GAAG,SAAS,EAAE,CAEtE;AAED,+FAA+F;AAC/F,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAkB1D;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAW3D;AAoDD;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMnG;AAED,sFAAsF;AACtF,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,CAS1G;AAED,yDAAyD;AACzD,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAG9D;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,MAAM,GACX;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAqB7D"}
|
package/dist/secrets/index.js
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* payment library's own `publishable_key_mismatch` refusal.
|
|
20
20
|
*/
|
|
21
21
|
import { spawnSync } from 'node:child_process';
|
|
22
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { repositoryOwnerFromUrl, repositorySecretMapping } from '../lib/secret-names.js';
|
|
25
25
|
import { readPackageJsonObject, repositoryInfo } from '../lib/workspace.js';
|
|
@@ -36,9 +36,17 @@ export function reconcileSecrets(sources) {
|
|
|
36
36
|
// GITHUB_CLIENT_SECRET would report one value as two secrets, one of them
|
|
37
37
|
// permanently "declared by nothing".
|
|
38
38
|
const carriesKnownEnvName = new Set(names.size > 0 ? [...names].map((name) => sources.secretNames[name] ?? name) : []);
|
|
39
|
-
|
|
39
|
+
// A value only an environment holds is still a value: leaving it out of the
|
|
40
|
+
// rows is how an operator ends up hunting for a secret that is already set.
|
|
41
|
+
const addUndeclared = (secret) => {
|
|
40
42
|
if (!carriesKnownEnvName.has(secret))
|
|
41
43
|
names.add(secret);
|
|
44
|
+
};
|
|
45
|
+
for (const secret of sources.repositorySecrets)
|
|
46
|
+
addUndeclared(secret);
|
|
47
|
+
for (const held of Object.values(sources.environmentSecrets ?? {})) {
|
|
48
|
+
for (const secret of held)
|
|
49
|
+
addUndeclared(secret);
|
|
42
50
|
}
|
|
43
51
|
return [...names]
|
|
44
52
|
.sort((left, right) => left.localeCompare(right))
|
|
@@ -52,6 +60,10 @@ export function reconcileSecrets(sources) {
|
|
|
52
60
|
suppliedByWorkflow: sources.workflowSecrets.includes(name),
|
|
53
61
|
fetchableLocally: sources.localCommands.includes(name),
|
|
54
62
|
onRepository: sources.repositorySecrets.includes(sources.secretNames[name] ?? name),
|
|
63
|
+
heldByEnvironment: Object.entries(sources.environmentSecrets ?? {})
|
|
64
|
+
.filter(([, held]) => held.includes(sources.secretNames[name] ?? name))
|
|
65
|
+
.map(([environment]) => environment)
|
|
66
|
+
.sort((left, right) => left.localeCompare(right)),
|
|
55
67
|
}));
|
|
56
68
|
}
|
|
57
69
|
/**
|
|
@@ -60,8 +72,25 @@ export function reconcileSecrets(sources) {
|
|
|
60
72
|
* separately from "declared but not wired into any workflow", because the
|
|
61
73
|
* remedies differ — set a secret, versus declare it in `smoo.github`.
|
|
62
74
|
*/
|
|
63
|
-
export function unsatisfiedSecrets(rows) {
|
|
64
|
-
return rows.filter((row) =>
|
|
75
|
+
export function unsatisfiedSecrets(rows, boundEnvironments = []) {
|
|
76
|
+
return rows.filter((row) => {
|
|
77
|
+
if (!row.suppliedByWorkflow)
|
|
78
|
+
return false;
|
|
79
|
+
if (row.onRepository)
|
|
80
|
+
return false;
|
|
81
|
+
// With no environment bound, the repository is the only scope a job reads.
|
|
82
|
+
// With environments bound, each one can carry the value instead - so the
|
|
83
|
+
// name is satisfied only when every bound environment holds it.
|
|
84
|
+
if (boundEnvironments.length === 0)
|
|
85
|
+
return true;
|
|
86
|
+
return !boundEnvironments.every((environment) => row.heldByEnvironment.includes(environment));
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
/** Bound environments that lack their own value and cannot fall back to the repository. */
|
|
90
|
+
export function environmentsMissing(row, boundEnvironments) {
|
|
91
|
+
if (row.onRepository)
|
|
92
|
+
return [];
|
|
93
|
+
return boundEnvironments.filter((environment) => !row.heldByEnvironment.includes(environment));
|
|
65
94
|
}
|
|
66
95
|
/** Worker-declared names no managed workflow passes: a CI deploy will run without them. */
|
|
67
96
|
export function unwiredSecrets(rows) {
|
|
@@ -92,6 +121,83 @@ export function workflowSecretNames(root) {
|
|
|
92
121
|
names.add(smoo.privateNpm.publishTokenEnv);
|
|
93
122
|
return [...names].sort((left, right) => left.localeCompare(right));
|
|
94
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* GitHub Environments the rendered workflows bind, read from the workflow
|
|
126
|
+
* files rather than from config: a binding may be hand-authored inside a
|
|
127
|
+
* `smoo-local` block, and the file is what GitHub executes either way. A job
|
|
128
|
+
* bound to an environment resolves `secrets.X` from that environment first and
|
|
129
|
+
* from the repository second, so these names are the scopes a value can live
|
|
130
|
+
* in. Expressions are skipped - a computed environment names no fixed scope.
|
|
131
|
+
*/
|
|
132
|
+
export function workflowEnvironments(root) {
|
|
133
|
+
const directory = join(root, '.github', 'workflows');
|
|
134
|
+
if (!existsSync(directory))
|
|
135
|
+
return [];
|
|
136
|
+
const environments = new Set();
|
|
137
|
+
for (const entry of readdirSync(directory)) {
|
|
138
|
+
if (!entry.endsWith('.yml') && !entry.endsWith('.yaml'))
|
|
139
|
+
continue;
|
|
140
|
+
for (const bound of environmentBindings(readFileSync(join(directory, entry), 'utf8'))) {
|
|
141
|
+
environments.add(bound);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return [...environments].sort((left, right) => left.localeCompare(right));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The environments one workflow file binds, in both spellings GitHub accepts:
|
|
148
|
+
* `environment: staging`, and the block form whose `name:` sits under it when
|
|
149
|
+
* the job also records a deployment URL. Only a `name:` indented inside an
|
|
150
|
+
* `environment:` block is a binding - a workflow's, a job's and a step's are
|
|
151
|
+
* not, and reading those as environments would send an operator to set
|
|
152
|
+
* secrets in scopes that do not exist.
|
|
153
|
+
*/
|
|
154
|
+
function environmentBindings(text) {
|
|
155
|
+
const lines = text.split('\n');
|
|
156
|
+
const bound = [];
|
|
157
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
158
|
+
const binding = /^(\s*)environment:(.*)$/.exec(lines[index] ?? '');
|
|
159
|
+
if (!binding)
|
|
160
|
+
continue;
|
|
161
|
+
const indent = (binding[1] ?? '').length;
|
|
162
|
+
const value = binding[2] ?? '';
|
|
163
|
+
// Nothing but a comment after the colon is the block form; anything else
|
|
164
|
+
// is the value itself.
|
|
165
|
+
if (!/^\s*(?:#.*)?$/.test(value)) {
|
|
166
|
+
const name = literalEnvironmentName(value);
|
|
167
|
+
if (name !== null)
|
|
168
|
+
bound.push(name);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
for (let next = index + 1; next < lines.length; next += 1) {
|
|
172
|
+
const line = lines[next] ?? '';
|
|
173
|
+
const content = line.trimStart();
|
|
174
|
+
if (content.length === 0 || content.startsWith('#'))
|
|
175
|
+
continue;
|
|
176
|
+
if (line.length - content.length <= indent)
|
|
177
|
+
break;
|
|
178
|
+
const named = /^name:(.*)$/.exec(content);
|
|
179
|
+
if (!named)
|
|
180
|
+
continue;
|
|
181
|
+
const name = literalEnvironmentName(named[1] ?? '');
|
|
182
|
+
if (name !== null)
|
|
183
|
+
bound.push(name);
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return bound;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The fixed name a YAML value denotes, or null when it denotes no fixed scope:
|
|
191
|
+
* an expression is computed per run, and a flow mapping or a list is not a
|
|
192
|
+
* name. Names may contain spaces, which is why the renderer quotes them.
|
|
193
|
+
*/
|
|
194
|
+
function literalEnvironmentName(value) {
|
|
195
|
+
const scalar = value.replace(/\s+#.*$/, '').trim();
|
|
196
|
+
if (scalar.length === 0 || scalar.includes('${{'))
|
|
197
|
+
return null;
|
|
198
|
+
const unquoted = /^(['"])(.*)\1$/.exec(scalar)?.[2] ?? scalar;
|
|
199
|
+
return /^[A-Za-z0-9._-][A-Za-z0-9._ -]*$/.test(unquoted) ? unquoted : null;
|
|
200
|
+
}
|
|
95
201
|
/**
|
|
96
202
|
* Env name -> repository secret for every name in play, following the naming
|
|
97
203
|
* convention and honouring the declared exceptions a repository still needs.
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which GitHub repository `smoo secrets` reads and writes.
|
|
3
|
+
*
|
|
4
|
+
* `gh` refuses on a checkout with more than one remote ("multiple remotes
|
|
5
|
+
* detected"), which is exactly the public/private mirror layout this CLI's own
|
|
6
|
+
* users run — so the repository has to be resolved here, from the checkout,
|
|
7
|
+
* before `gh` is spawned. The current branch's upstream decides it: a branch
|
|
8
|
+
* that pushes to the private mirror is a branch whose secrets live there. That
|
|
9
|
+
* is a fact the checkout already records, not a preference to configure.
|
|
10
|
+
*/
|
|
11
|
+
export interface RepositoryChoice {
|
|
12
|
+
/** `owner/name`, the form `gh --repo` takes. */
|
|
13
|
+
readonly repo: string;
|
|
14
|
+
/** Why this repository, for the operator to read back. */
|
|
15
|
+
readonly source: string;
|
|
16
|
+
}
|
|
17
|
+
/** `owner/name` from any git remote URL form, or null when it names no repository. */
|
|
18
|
+
export declare function repositorySlugFromUrl(url: string): string | null;
|
|
19
|
+
/**
|
|
20
|
+
* Pure resolution, so the precedence is testable without a checkout.
|
|
21
|
+
*
|
|
22
|
+
* A requested value is either a remote name or an `owner/name` slug; naming a
|
|
23
|
+
* remote is what an operator reaches for first (`-R private-repo`), and
|
|
24
|
+
* refusing it because it lacks a slash would be pedantry.
|
|
25
|
+
*
|
|
26
|
+
* With no request and no upstream - a branch pushed by explicit refspec, which
|
|
27
|
+
* is how the mirror layout is driven - the remote-tracking refs still record
|
|
28
|
+
* where this branch has been pushed. One remote carrying it is an answer; two
|
|
29
|
+
* is a genuine ambiguity worth refusing.
|
|
30
|
+
*/
|
|
31
|
+
export declare function chooseRepository(inputs: {
|
|
32
|
+
readonly remotes: ReadonlyMap<string, string>;
|
|
33
|
+
readonly upstreamRemote: string | null;
|
|
34
|
+
/** Remotes that already carry the current branch, from its remote-tracking refs. */
|
|
35
|
+
readonly remotesCarryingBranch?: readonly string[];
|
|
36
|
+
/** Current branch name, for the refusal to say which branch decided nothing. */
|
|
37
|
+
readonly branch?: string | null;
|
|
38
|
+
readonly requested: string | undefined;
|
|
39
|
+
}): {
|
|
40
|
+
ok: true;
|
|
41
|
+
choice: RepositoryChoice;
|
|
42
|
+
} | {
|
|
43
|
+
ok: false;
|
|
44
|
+
reason: string;
|
|
45
|
+
};
|
|
46
|
+
/** Remote name -> URL, in config order. */
|
|
47
|
+
export declare function readRemotes(root: string): Map<string, string>;
|
|
48
|
+
/** Remotes with a remote-tracking ref for the current branch: where it has been pushed. */
|
|
49
|
+
export declare function readRemotesCarryingBranch(root: string, remotes: ReadonlyMap<string, string>): string[];
|
|
50
|
+
/** The remote the current branch tracks, or null when it tracks nothing. */
|
|
51
|
+
export declare function readUpstreamRemote(root: string): string | null;
|
|
52
|
+
/** The repository to operate on, resolved from the checkout. */
|
|
53
|
+
export declare function resolveRepository(root: string, requested: string | undefined): {
|
|
54
|
+
ok: true;
|
|
55
|
+
choice: RepositoryChoice;
|
|
56
|
+
} | {
|
|
57
|
+
ok: false;
|
|
58
|
+
reason: string;
|
|
59
|
+
};
|
|
60
|
+
//# sourceMappingURL=repository.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"repository.d.ts","sourceRoot":"","sources":["../../src/secrets/repository.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAIH,MAAM,WAAW,gBAAgB;IAC/B,gDAAgD;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,0DAA0D;IAC1D,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;CACzB;AAED,sFAAsF;AACtF,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAehE;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE;IACvC,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9C,QAAQ,CAAC,cAAc,EAAE,MAAM,GAAG,IAAI,CAAC;IACvC,oFAAoF;IACpF,QAAQ,CAAC,qBAAqB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnD,gFAAgF;IAChF,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,QAAQ,CAAC,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;CACxC,GAAG;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAoDzE;AAOD,2CAA2C;AAC3C,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAS7D;AAED,2FAA2F;AAC3F,wBAAgB,yBAAyB,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,EAAE,CAUtG;AAED,4EAA4E;AAC5E,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAI9D;AAED,gEAAgE;AAChE,wBAAgB,iBAAiB,CAC/B,IAAI,EAAE,MAAM,EACZ,SAAS,EAAE,MAAM,GAAG,SAAS,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,gBAAgB,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAUxE"}
|