breakaway 2.0.0-main.99 → 2.0.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/.agents/skills/tasks/SKILL.md +3 -2
- package/LICENSE +45 -68
- package/README.md +17 -14
- package/npm-shrinkwrap.json +3 -3
- package/package.json +6 -2
- package/prompts/core.md +7 -4
- package/scripts/board-files.mjs +8 -1
- package/scripts/brand-lint.mjs +1 -0
- package/scripts/lib/brand-lint.js +25 -1
- package/scripts/site-roadmap.mjs +40 -0
- package/scripts/tasks/cli.js +14 -1
- package/scripts/tasks/infra-read.js +2 -0
- package/scripts/tasks/infra.js +15 -61
- package/scripts/tasks/pipeline.js +3 -170
- package/scripts/tasks.mjs +23 -4
- package/src/infra-cloudflare-analytics.js +24 -5
- package/src/infra-cloudflare-plan.js +38 -3
- package/src/infra-cloudflare-tokens.js +133 -0
- package/src/infra-cloudflare.js +385 -101
- package/src/infra-desired.js +10 -2
- package/src/infra-health.js +196 -0
- package/src/infra-inventory.js +11 -8
- package/src/infra-plans.js +3 -2
- package/src/infra-policy.js +198 -92
- package/src/infra-provider.js +217 -3
- package/src/infra-runner-render.js +172 -0
- package/src/init.js +1 -1
- package/src/workflow-render.js +180 -0
- package/template/README.md +1 -1
- package/template/infra/apply.yml +4 -0
package/scripts/tasks/infra.js
CHANGED
|
@@ -14,7 +14,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
14
14
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
15
15
|
import { dirname, join } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
-
import { DESIRED_DIR
|
|
17
|
+
import { DESIRED_DIR } from '../../src/infra-desired.js';
|
|
18
18
|
import { SHORT_LIVED_FILE } from '../../src/infra-short-lived.js';
|
|
19
19
|
import { checkApplyResult } from '../../src/infra-provider.js';
|
|
20
20
|
import { providers as registry } from '../../src/infra-providers.js';
|
|
@@ -29,15 +29,21 @@ import {
|
|
|
29
29
|
runPath,
|
|
30
30
|
runReport,
|
|
31
31
|
} from '../../src/infra-runner.js';
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
RUNNER_RENDER_HEADER,
|
|
34
|
+
RUNNER_TEMPLATE,
|
|
35
|
+
RunnerRenderError,
|
|
36
|
+
environmentsIn,
|
|
37
|
+
renderRunner,
|
|
38
|
+
runnerStep,
|
|
39
|
+
} from '../../src/infra-runner-render.js';
|
|
33
40
|
|
|
34
41
|
/** The first line of the rendered workflow: how `--update` knows the file is its own to replace. */
|
|
35
|
-
export const HEADER =
|
|
42
|
+
export const HEADER = RUNNER_RENDER_HEADER;
|
|
36
43
|
/** Where the runner keeps the checked plan between its steps, in the job's temporary folder. */
|
|
37
44
|
export const STATE_FILE = 'breakaway-infra-run.json';
|
|
38
|
-
const BRANCH = /^(?!.*\.\.)[A-Za-z0-9._/-]{1,100}$/u;
|
|
39
45
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
40
|
-
const TEMPLATE = join(HERE, '..', '..',
|
|
46
|
+
const TEMPLATE = join(HERE, '..', '..', RUNNER_TEMPLATE);
|
|
41
47
|
const USER_AGENT = 'breakaway-infra-runner';
|
|
42
48
|
|
|
43
49
|
/** A mistake `infra init` can name. */
|
|
@@ -48,66 +54,14 @@ const bad = (message) => {
|
|
|
48
54
|
|
|
49
55
|
// ---- infra init ----------------------------------------------------------------------------
|
|
50
56
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
* sorted, skipping policy.json, scaling.json, and anything that isn't JSON.
|
|
54
|
-
* @param {string[]} names
|
|
55
|
-
*/
|
|
56
|
-
export function environmentsIn(names) {
|
|
57
|
-
const environments = [];
|
|
58
|
-
for (const name of names) {
|
|
59
|
-
const found = environmentOfFile(name);
|
|
60
|
-
if (!found) continue;
|
|
61
|
-
if ('problem' in found) throw new InfraError(`${DESIRED_DIR}/${found.problem}.`);
|
|
62
|
-
environments.push(found.environment);
|
|
63
|
-
}
|
|
64
|
-
if (!environments.length)
|
|
65
|
-
bad(
|
|
66
|
-
`${DESIRED_DIR}/ has no environment's file, so there's nothing to apply to: write <environment>.json for each (like staging.json), then run this again.`,
|
|
67
|
-
);
|
|
68
|
-
return environments.sort();
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* The runner's workflow for these environments, applied from `branch`, running breakaway `version`. With `shortLived`
|
|
73
|
-
* (the folder has short-lived.json), the environment is any name, since each short-lived one is new, and the run may
|
|
74
|
-
* name the GitHub environment that holds the write token: `short-lived` for all of them (BRK-242).
|
|
75
|
-
* @param {{ environments: string[], branch: string, version: string, shortLived?: boolean }} input
|
|
76
|
-
* @param {string} template template/infra/apply.yml
|
|
77
|
-
* @returns {{ path: string, text: string }}
|
|
78
|
-
*/
|
|
79
|
-
export function renderRunner({ environments, branch, version, shortLived = false }, template) {
|
|
80
|
-
if (!BRANCH.test(branch)) bad(`${branch.slice(0, 80)} isn't a branch's name.`);
|
|
81
|
-
if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/u.test(version)) bad(`${version} isn't a breakaway release.`);
|
|
82
|
-
const text = fill(template, {
|
|
83
|
-
header: HEADER,
|
|
84
|
-
branchName: branch,
|
|
85
|
-
version,
|
|
86
|
-
shortLived,
|
|
87
|
-
fixedOnly: !shortLived,
|
|
88
|
-
environments: environments.map((name) => `- ${JSON.stringify(name)}`),
|
|
89
|
-
});
|
|
90
|
-
const problems = lintWorkflow(text);
|
|
91
|
-
if (problems.length) throw new Error(`${RUNNER_WORKFLOW} renders with problems: ${problems.join('; ')}`);
|
|
92
|
-
return { path: RUNNER_WORKFLOW, text };
|
|
93
|
-
}
|
|
57
|
+
// The render is shared with the board (src/infra-runner-render.js, BRK-307), so both write the same file.
|
|
58
|
+
export { environmentsIn, renderRunner };
|
|
94
59
|
|
|
95
60
|
/**
|
|
96
61
|
* What `infra init` does with the rendered file, given what's there (null when nothing): write it, leave it (the
|
|
97
62
|
* same), or refuse it (the repository's own, or rendered before when `update` isn't set).
|
|
98
|
-
* @param {{ path: string, text: string }} file
|
|
99
|
-
* @param {string | null} there
|
|
100
|
-
* @param {{ update?: boolean }} [options]
|
|
101
|
-
* @returns {{ write: boolean, same?: boolean, refused?: string }}
|
|
102
63
|
*/
|
|
103
|
-
export
|
|
104
|
-
if (there === null) return { write: true };
|
|
105
|
-
if (there === file.text) return { write: false, same: true };
|
|
106
|
-
if (!there.startsWith(`# ${HEADER}`))
|
|
107
|
-
return { write: false, refused: "it's the repository's own, not rendered: rename it, then run infra init again" };
|
|
108
|
-
if (!update) return { write: false, refused: 'it differs from what infra init renders: run infra init --update' };
|
|
109
|
-
return { write: true };
|
|
110
|
-
}
|
|
64
|
+
export const initStep = runnerStep;
|
|
111
65
|
|
|
112
66
|
/** The checkout's top folder, or the folder the command runs in. */
|
|
113
67
|
function topOf(cwd) {
|
|
@@ -341,7 +295,7 @@ export async function run(args, opts, io = {}) {
|
|
|
341
295
|
if (command !== 'init') bad('infra has init. npx breakaway help says what it does.');
|
|
342
296
|
return init(opts, { cwd, log, error, version: io.version });
|
|
343
297
|
} catch (e) {
|
|
344
|
-
if (!(e instanceof InfraError)) throw e;
|
|
298
|
+
if (!(e instanceof InfraError) && !(e instanceof RunnerRenderError)) throw e;
|
|
345
299
|
error(`infra: ${e.message}`);
|
|
346
300
|
return 1;
|
|
347
301
|
}
|
|
@@ -13,6 +13,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
|
13
13
|
import { dirname, join } from 'node:path';
|
|
14
14
|
import { fileURLToPath } from 'node:url';
|
|
15
15
|
import { YamlError, parseYaml as readYaml } from '../../src/yaml.js';
|
|
16
|
+
import { fill, lintWorkflow } from '../../src/workflow-render.js';
|
|
17
|
+
|
|
18
|
+
export { fill, lintWorkflow };
|
|
16
19
|
|
|
17
20
|
export const CONFIG_PATH = '.github/breakaway-pipeline.json';
|
|
18
21
|
export const DEPLOY_PATHS = '.github/deploy-paths.json';
|
|
@@ -43,8 +46,6 @@ export const HELPERS = {
|
|
|
43
46
|
rollback: ['scripts/deploy-plan.mjs', 'scripts/record-deployment.mjs'],
|
|
44
47
|
release: ['scripts/deploy-plan.mjs', 'scripts/package-release.mjs'],
|
|
45
48
|
};
|
|
46
|
-
/** The secrets a rendered workflow may read: each lives in a GitHub environment only the default branch can use. */
|
|
47
|
-
const SECRETS = new Set(['CLOUDFLARE_API_TOKEN', 'NPM_TOKEN']);
|
|
48
49
|
|
|
49
50
|
/** A config's or a workflow's mistake, in words the owner or the agent can act on. */
|
|
50
51
|
export class PipelineError extends Error {}
|
|
@@ -233,46 +234,6 @@ export function checkConfig(raw, { packageJson = () => null } = {}) {
|
|
|
233
234
|
return { workers, wranglerEnv, branch, checks, install, build, beforeDeploy, deployPaths, healthCheck, package: pkg };
|
|
234
235
|
}
|
|
235
236
|
|
|
236
|
-
/**
|
|
237
|
-
* A template with its values: `{{key}}` is replaced inline, a line that is only `{{@key}}` becomes the list's lines at
|
|
238
|
-
* that indentation (none for an empty list), and lines between `{{#if key}}` and `{{/if}}` stay only when the value is
|
|
239
|
-
* truthy.
|
|
240
|
-
* @param {string} template
|
|
241
|
-
* @param {Record<string, any>} values
|
|
242
|
-
*/
|
|
243
|
-
export function fill(template, values) {
|
|
244
|
-
const out = [];
|
|
245
|
-
const keep = [true];
|
|
246
|
-
for (const line of template.split('\n')) {
|
|
247
|
-
const cond = /^\s*\{\{#if (\w+)\}\}\s*$/u.exec(line);
|
|
248
|
-
if (cond) {
|
|
249
|
-
keep.push(keep.at(-1) && Boolean(values[cond[1]]));
|
|
250
|
-
continue;
|
|
251
|
-
}
|
|
252
|
-
if (/^\s*\{\{\/if\}\}\s*$/u.test(line)) {
|
|
253
|
-
if (keep.length === 1) throw new Error('{{/if}} without {{#if}}');
|
|
254
|
-
keep.pop();
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
if (!keep.at(-1)) continue;
|
|
258
|
-
const list = /^(\s*)\{\{@(\w+)\}\}\s*$/u.exec(line);
|
|
259
|
-
if (list) {
|
|
260
|
-
const items = values[list[2]];
|
|
261
|
-
if (!Array.isArray(items)) throw new Error(`{{@${list[2]}}} needs a list`);
|
|
262
|
-
for (const item of items) out.push(`${list[1]}${item}`);
|
|
263
|
-
continue;
|
|
264
|
-
}
|
|
265
|
-
out.push(
|
|
266
|
-
line.replace(/(?<!\$)\{\{(\w+)\}\}/gu, (_, key) => {
|
|
267
|
-
if (!(key in values)) throw new Error(`the template's {{${key}}} has no value`);
|
|
268
|
-
return String(values[key]);
|
|
269
|
-
}),
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
if (keep.length !== 1) throw new Error('{{#if}} without {{/if}}');
|
|
273
|
-
return out.join('\n');
|
|
274
|
-
}
|
|
275
|
-
|
|
276
237
|
const quote = (value) => JSON.stringify(value ?? '');
|
|
277
238
|
|
|
278
239
|
/** The values the templates take, from a checked config. */
|
|
@@ -344,134 +305,6 @@ export function parseYaml(text) {
|
|
|
344
305
|
}
|
|
345
306
|
}
|
|
346
307
|
|
|
347
|
-
const CONTEXTS = new Set([
|
|
348
|
-
'github',
|
|
349
|
-
'inputs',
|
|
350
|
-
'secrets',
|
|
351
|
-
'vars',
|
|
352
|
-
'steps',
|
|
353
|
-
'env',
|
|
354
|
-
'job',
|
|
355
|
-
'needs',
|
|
356
|
-
'runner',
|
|
357
|
-
'matrix',
|
|
358
|
-
'strategy',
|
|
359
|
-
]);
|
|
360
|
-
|
|
361
|
-
/** The `${{ }}` expressions in a value, and the value itself when it's an `if:` (an expression without the braces). */
|
|
362
|
-
function expressionsIn(text, isIf) {
|
|
363
|
-
const found = [...String(text).matchAll(/\$\{\{([\s\S]*?)\}\}/gu)].map((m) => m[1]);
|
|
364
|
-
if (isIf && !found.length) found.push(String(text));
|
|
365
|
-
return found;
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/** Every string under a value, with whether it's an `if:`. */
|
|
369
|
-
function stringsIn(value, key = '') {
|
|
370
|
-
if (typeof value === 'string') return [{ text: value, isIf: key === 'if' }];
|
|
371
|
-
if (Array.isArray(value)) return value.flatMap((item) => stringsIn(item));
|
|
372
|
-
if (isObject(value)) return Object.entries(value).flatMap(([k, v]) => stringsIn(v, k));
|
|
373
|
-
return [];
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* The problems GitHub (or actionlint) would find in a rendered workflow, as messages; none when it's sound. It parses
|
|
378
|
-
* the YAML, then checks the shape (on, permissions, jobs with runs-on, a timeout, and steps), that every action is
|
|
379
|
-
* pinned to a commit, that every expression names a context Actions has, a step that ran before, an input the workflow
|
|
380
|
-
* takes, or a secret the pipeline uses, that secrets are read only in a job with an environment, that run scripts take
|
|
381
|
-
* no `${{ }}` (they read env instead), and that nothing of the template is left over.
|
|
382
|
-
* @param {string} text
|
|
383
|
-
* @returns {string[]}
|
|
384
|
-
*/
|
|
385
|
-
export function lintWorkflow(text) {
|
|
386
|
-
const problems = [];
|
|
387
|
-
const leftover = text.replace(/\$\{\{[\s\S]*?\}\}/gu, '').match(/\{\{[#/@]?\w+\}\}/u);
|
|
388
|
-
if (leftover) problems.push(`the template's ${leftover[0]} is left over`);
|
|
389
|
-
let doc;
|
|
390
|
-
try {
|
|
391
|
-
doc = parseYaml(text);
|
|
392
|
-
} catch (error) {
|
|
393
|
-
return [...problems, error.message];
|
|
394
|
-
}
|
|
395
|
-
if (typeof doc.name !== 'string' || !doc.name) problems.push('it has no name');
|
|
396
|
-
if (!isObject(doc.on)) problems.push('it has no on: triggers');
|
|
397
|
-
else if ('pull_request_target' in doc.on) problems.push('pull_request_target runs untrusted code with secrets');
|
|
398
|
-
if (!isObject(doc.permissions) || Object.keys(doc.permissions).length)
|
|
399
|
-
problems.push('the workflow sets permissions: {}, and each job asks for what it needs');
|
|
400
|
-
if (!isObject(doc.jobs) || !Object.keys(doc.jobs).length) return [...problems, 'it has no jobs'];
|
|
401
|
-
const inputs = Object.keys(doc.on?.workflow_dispatch?.inputs ?? {});
|
|
402
|
-
for (const [name, input] of Object.entries(doc.on?.workflow_dispatch?.inputs ?? {}))
|
|
403
|
-
if (!['string', 'boolean', 'choice', 'number', 'environment'].includes(input?.type))
|
|
404
|
-
problems.push(`input ${name} has no type`);
|
|
405
|
-
|
|
406
|
-
const checkExpressions = (strings, where, steps, jobLevel) => {
|
|
407
|
-
for (const { text: value, isIf } of strings)
|
|
408
|
-
for (const expression of expressionsIn(value, isIf)) {
|
|
409
|
-
const code = expression.replace(/'(?:[^']|'')*'/gu, "''");
|
|
410
|
-
for (const m of code.matchAll(/(?<![\w.-])([A-Za-z_][\w-]*)((?:\.[\w-]+)*)/gu)) {
|
|
411
|
-
const [, context, path] = m;
|
|
412
|
-
if (!path) {
|
|
413
|
-
if (!/^\s*\(/u.test(code.slice(m.index + context.length)) && !['true', 'false', 'null'].includes(context))
|
|
414
|
-
problems.push(`${where}: "${context}" isn't a context or a function`);
|
|
415
|
-
continue;
|
|
416
|
-
}
|
|
417
|
-
const [first] = path.slice(1).split('.');
|
|
418
|
-
if (!CONTEXTS.has(context)) problems.push(`${where}: no context "${context}"`);
|
|
419
|
-
else if (context === 'secrets' && !SECRETS.has(first))
|
|
420
|
-
problems.push(`${where}: the pipeline uses no secret ${first}`);
|
|
421
|
-
else if (context === 'inputs' && !inputs.includes(first)) problems.push(`${where}: no input ${first}`);
|
|
422
|
-
else if (context === 'steps' && !steps.includes(first))
|
|
423
|
-
problems.push(`${where}: no step ${first} before this one`);
|
|
424
|
-
else if (context === 'env' && jobLevel) problems.push(`${where}: env isn't available here`);
|
|
425
|
-
}
|
|
426
|
-
}
|
|
427
|
-
};
|
|
428
|
-
checkExpressions(stringsIn({ on: doc.on, env: doc.env, concurrency: doc.concurrency }), 'the workflow', [], false);
|
|
429
|
-
|
|
430
|
-
for (const [jobName, job] of Object.entries(doc.jobs)) {
|
|
431
|
-
const where = `job ${jobName}`;
|
|
432
|
-
if (!isObject(job)) {
|
|
433
|
-
problems.push(`${where} isn't a mapping`);
|
|
434
|
-
continue;
|
|
435
|
-
}
|
|
436
|
-
if (!job['runs-on']) problems.push(`${where} has no runs-on`);
|
|
437
|
-
if (typeof job['timeout-minutes'] !== 'number') problems.push(`${where} has no timeout-minutes`);
|
|
438
|
-
if (!isObject(job.permissions)) problems.push(`${where} doesn't say its permissions`);
|
|
439
|
-
for (const need of [job.needs ?? []].flat())
|
|
440
|
-
if (!(need in doc.jobs)) problems.push(`${where} needs ${need}, which isn't a job`);
|
|
441
|
-
const { steps = [], ...rest } = job;
|
|
442
|
-
checkExpressions(stringsIn({ if: rest.if }), where, [], true);
|
|
443
|
-
checkExpressions(stringsIn({ ...rest, if: undefined, outputs: undefined }), where, [], false);
|
|
444
|
-
// A job's outputs read its steps once they've all run.
|
|
445
|
-
const stepIds = Array.isArray(steps) ? steps.map((s) => s?.id).filter(Boolean) : [];
|
|
446
|
-
checkExpressions(stringsIn({ outputs: rest.outputs }), `${where}'s outputs`, stepIds, false);
|
|
447
|
-
if (stringsIn(job).some(({ text: value }) => /\bsecrets\./u.test(value)) && !job.environment)
|
|
448
|
-
problems.push(`${where} reads a secret outside an environment`);
|
|
449
|
-
if (!Array.isArray(steps) || !steps.length) {
|
|
450
|
-
problems.push(`${where} has no steps`);
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
const ids = [];
|
|
454
|
-
steps.forEach((step, i) => {
|
|
455
|
-
const at = `${where}, step ${i + 1}${step?.name ? ` (${step.name})` : ''}`;
|
|
456
|
-
if (!isObject(step)) {
|
|
457
|
-
problems.push(`${at} isn't a mapping`);
|
|
458
|
-
return;
|
|
459
|
-
}
|
|
460
|
-
if ('uses' in step === 'run' in step) problems.push(`${at} has one of uses or run`);
|
|
461
|
-
if ('uses' in step && !/^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/u.test(String(step.uses)))
|
|
462
|
-
problems.push(`${at} uses ${step.uses}, which isn't pinned to a commit`);
|
|
463
|
-
if (typeof step.run === 'string' && step.run.includes('${{'))
|
|
464
|
-
problems.push(`${at} puts \${{ }} in its script: pass it through env`);
|
|
465
|
-
checkExpressions(stringsIn(step), at, ids, false);
|
|
466
|
-
if (step.id !== undefined) {
|
|
467
|
-
if (ids.includes(step.id)) problems.push(`${at} repeats the id ${step.id}`);
|
|
468
|
-
ids.push(String(step.id));
|
|
469
|
-
}
|
|
470
|
-
});
|
|
471
|
-
}
|
|
472
|
-
return problems;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
308
|
/** The run scripts of a parsed workflow, `[{ where, script }]`, for a shell's syntax check. */
|
|
476
309
|
export function runScripts(text) {
|
|
477
310
|
const doc = parseYaml(text);
|
package/scripts/tasks.mjs
CHANGED
|
@@ -371,13 +371,16 @@ Working
|
|
|
371
371
|
--pipeline <file.json|none> sets its deploy pipeline ({"workers": {"staging", "production"}, "package": "<npm name>", "workflows": {...}, "deployPaths"},
|
|
372
372
|
with workers, package, or both) or clears it
|
|
373
373
|
features add <slug> new feature: its tasks join by carrying <slug> as a tag [--title <text>]
|
|
374
|
-
[--brief <text> | --brief-file <path>] [--release <x.y.z>]
|
|
374
|
+
[--brief <text> | --brief-file <path>] [--release <x.y.z>]
|
|
375
375
|
--from <ref> (owner): made from the group <ref> is in on the Dependencies view: its open tasks
|
|
376
376
|
join, and tasks already in another feature stay there
|
|
377
|
-
features modify <slug> change one
|
|
377
|
+
features modify <slug> change one: --title, --brief, --brief-file, --release <x.y.z|none>; --state open|shipped is the
|
|
378
|
+
owner's. An agent's change shows in Activity and on the feature, and the owner can undo it
|
|
379
|
+
features pull <x.y.z> pull a release's open tasks, and what they wait for, into now [--into next] [--dry-run];
|
|
380
|
+
the next release goes in first. An agent's pull shows on the feature, and the owner can undo it
|
|
378
381
|
routines add <slug> new routine (owner, or the agent of a routine maker's task) --name <text> --prompt <text> | --prompt-file <path> [--done-when <text>] [--horizon now|next|later] [--gap <minutes>] [--daily <n>]
|
|
379
382
|
[--repo <slug>] the repository it runs in (default: the checkout's)
|
|
380
|
-
routines modify <slug> change one (owner, or the agent of the routine maker's task that made it): the same options (--repo <slug> moves it), and --enabled yes|no; --schedule "0 9 * * 1" runs it on a cron schedule (UTC), --schedule "" clears it; --trigger-start auto|wait sets whether a webhook or GitHub event starts the agent or waits for your Start; --github-events pr_merged,release_published,workflow_failed,issue_opened,issue_reopened (or "") starts it on those GitHub events
|
|
383
|
+
routines modify <slug> change one (owner, or the agent of the routine maker's task that made it): the same options (--repo <slug> moves it), and --enabled yes|no; --schedule "0 9 * * 1" runs it on a cron schedule (UTC), --schedule "" clears it; --trigger-start auto|wait sets whether a webhook or GitHub event starts the agent or waits for your Start; --github-events pr_merged,release_published,workflow_failed,issue_opened,issue_reopened (or "") starts it on those GitHub events; --infra-events plan.failed,drift.found:environment=staging,budget.crossed:percent=80 (or "", the owner's) starts it on those infrastructure events, each with filters environment=, kind=, resource= (several joined by +)
|
|
381
384
|
routines trigger <slug> new webhook/API trigger (owner): prints its secret once [--label <text>]
|
|
382
385
|
routines revoke <slug> <id> revoke a trigger
|
|
383
386
|
routines pause|resume stop or allow every routine [--daily-all <n>] sets the cap for all routines a day
|
|
@@ -1320,6 +1323,7 @@ const commands = {
|
|
|
1320
1323
|
enabled: yes(opts.enabled),
|
|
1321
1324
|
triggerStart: opts['trigger-start'],
|
|
1322
1325
|
githubEvents: opts['github-events'],
|
|
1326
|
+
infraEvents: opts['infra-events'],
|
|
1323
1327
|
gapMinutes: opts.gap === undefined ? undefined : Number(opts.gap),
|
|
1324
1328
|
dailyCap: opts.daily === undefined ? undefined : Number(opts.daily),
|
|
1325
1329
|
};
|
|
@@ -1426,7 +1430,7 @@ const commands = {
|
|
|
1426
1430
|
...(d.routines.length ? [''] : ['', 'No routines yet: routines add <slug> --name … --prompt …']),
|
|
1427
1431
|
...d.routines.map(
|
|
1428
1432
|
(x) =>
|
|
1429
|
-
` ${x.slug.padEnd(16)} ${several ? `${x.repo}: ` : ''}${x.enabled ? 'on ' : 'off'} ${x.openRun ? `running ${x.openRun.wid}` : x.lastRun ? `last ${x.lastRun.wid ?? ''} ${ago(x.lastRun.at)}` : 'never run'}, ${x.runsToday} of ${x.dailyCap} today${x.schedule ? `, schedule ${x.schedule}${x.nextRun ? ` (next ${x.nextRun.slice(0, 16).replace('T', ' ')} UTC)` : ''}` : ''}${x.triggers?.length ? `, ${x.triggers.length} trigger${x.triggers.length === 1 ? '' : 's'} (${x.triggerStart === 'auto' ? 'start by themselves' : 'wait for Start'}; ids ${x.triggers.map((t) => t.id).join(', ')})` : ''}${x.disabledReason ? `, off: ${x.disabledReason}` : ''}`,
|
|
1433
|
+
` ${x.slug.padEnd(16)} ${several ? `${x.repo}: ` : ''}${x.enabled ? 'on ' : 'off'} ${x.openRun ? `running ${x.openRun.wid}` : x.lastRun ? `last ${x.lastRun.wid ?? ''} ${ago(x.lastRun.at)}` : 'never run'}, ${x.runsToday} of ${x.dailyCap} today${x.schedule ? `, schedule ${x.schedule}${x.nextRun ? ` (next ${x.nextRun.slice(0, 16).replace('T', ' ')} UTC)` : ''}` : ''}${x.triggers?.length ? `, ${x.triggers.length} trigger${x.triggers.length === 1 ? '' : 's'} (${x.triggerStart === 'auto' ? 'start by themselves' : 'wait for Start'}; ids ${x.triggers.map((t) => t.id).join(', ')})` : ''}${x.infraEvents?.length ? `, on ${x.infraEvents.map((e) => e.event).join(', ')}` : ''}${x.disabledReason ? `, off: ${x.disabledReason}` : ''}`,
|
|
1430
1434
|
),
|
|
1431
1435
|
].join('\n'),
|
|
1432
1436
|
);
|
|
@@ -1550,6 +1554,21 @@ const commands = {
|
|
|
1550
1554
|
print({ feature }, (d) => `Saved ${d.feature.slug}.`);
|
|
1551
1555
|
return;
|
|
1552
1556
|
}
|
|
1557
|
+
if (sub === 'pull') {
|
|
1558
|
+
const release = need(args[1], 'release').replace(/^v(?=\d)/u, '');
|
|
1559
|
+
const into = opts.into ?? 'now';
|
|
1560
|
+
const dryRun = Boolean(opts['dry-run']);
|
|
1561
|
+
const pulled = await call('POST', `releases/${enc(release)}/pull`, { into, dryRun, ...(by ? { by } : {}) });
|
|
1562
|
+
print(pulled, (d) =>
|
|
1563
|
+
[
|
|
1564
|
+
`${d.dryRun ? 'Would move' : 'Moved'} ${d.tasks.length} ${d.tasks.length === 1 ? 'task' : 'tasks'} of ${d.release} into ${d.into}:`,
|
|
1565
|
+
...d.tasks.map(
|
|
1566
|
+
(t) => ` ${t.wid ?? t.uuid.slice(0, 8)} ${t.description}${t.chain ? ' (it waits for this)' : ''}`,
|
|
1567
|
+
),
|
|
1568
|
+
].join('\n'),
|
|
1569
|
+
);
|
|
1570
|
+
return;
|
|
1571
|
+
}
|
|
1553
1572
|
if (sub === 'show') {
|
|
1554
1573
|
const { feature } = await call('GET', `features/${enc(need(args[1], 'feature').toLowerCase())}`);
|
|
1555
1574
|
print({ feature }, (d) => featureLines(d.feature).join('\n'));
|
|
@@ -249,14 +249,28 @@ export const COST_DATASETS = {
|
|
|
249
249
|
},
|
|
250
250
|
};
|
|
251
251
|
|
|
252
|
-
/** How far back health looks: errors and slowness in the last this many minutes (BRK-188's "Observe" rows). */
|
|
252
|
+
/** How far back health looks first: errors and slowness in the last this many minutes (BRK-188's "Observe" rows). */
|
|
253
253
|
export const HEALTH_WINDOW_MINUTES = 15;
|
|
254
254
|
|
|
255
255
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
|
|
259
|
-
|
|
256
|
+
* How far behind now the health window ends (BRK-266): Cloudflare's analytics arrive a few minutes late, so the newest
|
|
257
|
+
* minutes are always empty and a window ending at now would see a busy resource fall quiet.
|
|
258
|
+
*/
|
|
259
|
+
export const HEALTH_LAG_MINUTES = 5;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The windows health reads, shortest first (BRK-266): a resource with no traffic in one is read again over the next
|
|
263
|
+
* before it's called idle, and its verdict says which window it came from.
|
|
264
|
+
*/
|
|
265
|
+
export const HEALTH_WINDOWS = [HEALTH_WINDOW_MINUTES, 60, 24 * 60];
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* The datasets health reads (BRK-188's "Observe" rows), one query each for the whole environment, over the first of
|
|
269
|
+
* HEALTH_WINDOWS, and again over the longer ones only for the resources it found quiet. `busy` says whether a
|
|
270
|
+
* resource's usage counts as traffic; a dataset without one (the queues' backlog) is read once. Which fields each
|
|
271
|
+
* dataset serves at minute resolution is checked against a real account when the owner first tries Architect
|
|
272
|
+
* (BRK-205); a dataset Cloudflare won't answer leaves its resources' health unread, saying which.
|
|
273
|
+
* @type {Record<string, Dataset & { busy?: (u: Record<string, number>) => boolean }>}
|
|
260
274
|
*/
|
|
261
275
|
export const HEALTH_DATASETS = {
|
|
262
276
|
workers: {
|
|
@@ -266,6 +280,7 @@ export const HEALTH_DATASETS = {
|
|
|
266
280
|
time: 'datetime',
|
|
267
281
|
sum: ['requests', 'errors'],
|
|
268
282
|
label: 'requests and errors',
|
|
283
|
+
busy: (u) => (u.requests ?? 0) > 0,
|
|
269
284
|
},
|
|
270
285
|
durableObjects: {
|
|
271
286
|
dataset: 'durableObjectsInvocationsAdaptiveGroups',
|
|
@@ -274,6 +289,7 @@ export const HEALTH_DATASETS = {
|
|
|
274
289
|
time: 'datetime',
|
|
275
290
|
sum: ['requests', 'errors'],
|
|
276
291
|
label: 'requests and errors',
|
|
292
|
+
busy: (u) => (u.requests ?? 0) > 0,
|
|
277
293
|
},
|
|
278
294
|
d1: {
|
|
279
295
|
dataset: 'd1AnalyticsAdaptiveGroups',
|
|
@@ -283,6 +299,7 @@ export const HEALTH_DATASETS = {
|
|
|
283
299
|
sum: ['readQueries', 'writeQueries'],
|
|
284
300
|
avg: ['queryBatchTimeMs'],
|
|
285
301
|
label: 'queries and their time',
|
|
302
|
+
busy: (u) => (u.readQueries ?? 0) + (u.writeQueries ?? 0) > 0,
|
|
286
303
|
},
|
|
287
304
|
kv: {
|
|
288
305
|
dataset: 'kvOperationsAdaptiveGroups',
|
|
@@ -291,6 +308,7 @@ export const HEALTH_DATASETS = {
|
|
|
291
308
|
time: 'datetime',
|
|
292
309
|
sum: ['requests'],
|
|
293
310
|
label: 'operations',
|
|
311
|
+
busy: (u) => (u.requests ?? 0) > 0,
|
|
294
312
|
},
|
|
295
313
|
r2: {
|
|
296
314
|
dataset: 'r2OperationsAdaptiveGroups',
|
|
@@ -300,6 +318,7 @@ export const HEALTH_DATASETS = {
|
|
|
300
318
|
sum: ['requests'],
|
|
301
319
|
by: 'responseStatusCode',
|
|
302
320
|
label: 'operations by response status',
|
|
321
|
+
busy: (u) => Object.entries(u).some(([k, v]) => k.startsWith('requests:') && v > 0),
|
|
303
322
|
},
|
|
304
323
|
queues: {
|
|
305
324
|
dataset: 'queuesBacklogAdaptiveGroups',
|
|
@@ -266,8 +266,13 @@ function bindingChange(worker, live, wanted, resolve) {
|
|
|
266
266
|
export async function plan(ctx, desired) {
|
|
267
267
|
const found = await discover(ctx, { live: true });
|
|
268
268
|
const live = /** @type {LiveAccount} */ (found.live);
|
|
269
|
-
|
|
270
|
-
|
|
269
|
+
const target = String(ctx.scope?.target ?? '');
|
|
270
|
+
// A target the desired state makes (a new environment built from the console, BRK-291) is planned like any Worker.
|
|
271
|
+
const makes = desired.resources.some((r) => r.kind === 'worker' && r.name === target);
|
|
272
|
+
if (!live.account || !(live.scripts.includes(target) || (target && makes)))
|
|
273
|
+
refuse(
|
|
274
|
+
`${ctx.scope?.target ?? 'the target'} isn’t a Worker on the account yet: deploy it first, or add it to the desired state, then plan`,
|
|
275
|
+
);
|
|
271
276
|
if (ctx.scope?.board && ctx.scope.board === ctx.scope.target)
|
|
272
277
|
refuse(
|
|
273
278
|
`${ctx.scope.target} is the Worker this board runs on: Architect only observes it, so it never plans for it`,
|
|
@@ -600,6 +605,35 @@ function writer(ctx) {
|
|
|
600
605
|
};
|
|
601
606
|
}
|
|
602
607
|
|
|
608
|
+
/** The module a new Worker starts with (BRK-270): it answers /health until the repository's deploy puts its code on it. */
|
|
609
|
+
export const STARTER_MODULE = 'starter.js';
|
|
610
|
+
export const STARTER_SCRIPT = `// Made by breakaway's Architect. The repository's deploy replaces it.
|
|
611
|
+
export default {
|
|
612
|
+
fetch(request) {
|
|
613
|
+
if (new URL(request.url).pathname === '/health') return Response.json({ ok: true, starter: true });
|
|
614
|
+
return new Response('This Worker has no code yet: its deploy puts it here.', { status: 503 });
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
`;
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* A new Worker's first upload: the starter module, with the change's settings and bindings. A Durable Object binding
|
|
621
|
+
* waits for the deploy, since its class is in the Worker's own code.
|
|
622
|
+
*/
|
|
623
|
+
function starterUpload(after, resolve) {
|
|
624
|
+
const { keep_bindings: _, ...settings } = workerSettings(after, [], resolve);
|
|
625
|
+
const metadata = {
|
|
626
|
+
...settings,
|
|
627
|
+
main_module: STARTER_MODULE,
|
|
628
|
+
bindings: settings.bindings.filter((b) => b.type !== 'durable_object_namespace'),
|
|
629
|
+
compatibility_date: settings.compatibility_date ?? new Date().toISOString().slice(0, 10),
|
|
630
|
+
};
|
|
631
|
+
const form = new FormData();
|
|
632
|
+
form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
|
|
633
|
+
form.append(STARTER_MODULE, new Blob([STARTER_SCRIPT], { type: 'application/javascript+module' }), STARTER_MODULE);
|
|
634
|
+
return form;
|
|
635
|
+
}
|
|
636
|
+
|
|
603
637
|
/** A Worker's settings, as `PATCH …/settings` takes them, from a change's `after` and its live bindings. */
|
|
604
638
|
function workerSettings(after, live, resolve) {
|
|
605
639
|
const byName = new Map(live.map((b) => [b.name, b]));
|
|
@@ -689,8 +723,9 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
|
|
|
689
723
|
const perm = { permission: WORKERS_WRITE.edit };
|
|
690
724
|
if (c.op === 'delete') return call('DELETE', p, { permission: WORKERS_WRITE.delete });
|
|
691
725
|
if (c.op === 'create') {
|
|
692
|
-
// The Worker
|
|
726
|
+
// The Worker, then a starter version that answers /health, with its settings: its deploy replaces the code.
|
|
693
727
|
await call('POST', `${a}/workers/workers`, { permission: WORKERS_WRITE.create, json: { name: c.name } });
|
|
728
|
+
await call('PUT', p, { permission: WORKERS_WRITE.create, form: starterUpload(after, resolve) });
|
|
694
729
|
if (Array.isArray(after.crons) && after.crons.length)
|
|
695
730
|
await call('PUT', `${p}/schedules`, { ...perm, json: after.crons.map((cron) => ({ cron })) });
|
|
696
731
|
return;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflare's tokens for guided setup (BRK-304; docs/specs/IDEA-19-architect.md, "Tokens"): which permissions each
|
|
3
|
+
* environment's write token needs, from the kinds its desired state declares, and the link that opens Cloudflare's
|
|
4
|
+
* create-token page with as many of them filled in as Cloudflare allows. Pure: no store, no network, and it runs in
|
|
5
|
+
* Node as well as the Worker. The board never makes, sees, or keeps a write token: it only says what to make.
|
|
6
|
+
*
|
|
7
|
+
* Cloudflare's template URLs ("API token template URLs", developers.cloudflare.com/fundamentals/api/how-to/
|
|
8
|
+
* account-owned-token-template/, read 8 Oct 2026) prefill an account API token's permissions by key, but only the
|
|
9
|
+
* keys that page lists. Workers roles (Metadata Read-Only, Editor, Admin; BRK-243), Containers, and Notifications have
|
|
10
|
+
* no listed key, and the legacy `workers_scripts` key would give Content Read-Only (code) or Editor on every Worker, so
|
|
11
|
+
* those are never prefilled: the setup names them to add by hand.
|
|
12
|
+
*/
|
|
13
|
+
import { READ_PERMISSIONS, WORKERS_READ } from './infra-cloudflare.js';
|
|
14
|
+
|
|
15
|
+
/** The secret the apply workflow reads the write token from, in each environment's GitHub environment. */
|
|
16
|
+
export const WRITE_SECRET = 'CLOUDFLARE_API_TOKEN';
|
|
17
|
+
|
|
18
|
+
/** Where an account API token is made, with `permissionGroupKeys` and `name` added (the account is picked there). */
|
|
19
|
+
export const TEMPLATE_BASE = 'https://dash.cloudflare.com/?to=/:account/api-tokens';
|
|
20
|
+
|
|
21
|
+
/** The permissions a template URL can prefill, by the name the token page gives them. */
|
|
22
|
+
export const TEMPLATE_KEYS = {
|
|
23
|
+
'Workers KV Storage Read': { key: 'workers_kv_storage', type: 'read' },
|
|
24
|
+
'Workers KV Storage Write': { key: 'workers_kv_storage', type: 'edit' },
|
|
25
|
+
'Workers R2 Storage Read': { key: 'workers_r2', type: 'read' },
|
|
26
|
+
'Workers R2 Storage Write': { key: 'workers_r2', type: 'edit' },
|
|
27
|
+
'D1 Read': { key: 'd1', type: 'read' },
|
|
28
|
+
'D1 Write': { key: 'd1', type: 'edit' },
|
|
29
|
+
'Queues Read': { key: 'queues', type: 'read' },
|
|
30
|
+
'Queues Write': { key: 'queues', type: 'edit' },
|
|
31
|
+
'Account Analytics Read': { key: 'account_analytics', type: 'read' },
|
|
32
|
+
'Zone Read': { key: 'zone', type: 'read' },
|
|
33
|
+
'Workers Routes Read': { key: 'workers_routes', type: 'read' },
|
|
34
|
+
'Workers Routes Write': { key: 'workers_routes', type: 'edit' },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** The permissions that are given on zones, not the account: only the zones the environments use. */
|
|
38
|
+
const ZONE = new Set(['Zone Read', 'Workers Routes Read', 'Workers Routes Write']);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One permission a token needs, where it's given, and why.
|
|
42
|
+
* @typedef {object} SetupPermission
|
|
43
|
+
* @property {string} name as Cloudflare's token page names it
|
|
44
|
+
* @property {string} scope where it's given: `account`, `zones`, `workers` (only the Workers named in `workers`), or
|
|
45
|
+
* `workers-product` (every Worker on the account)
|
|
46
|
+
* @property {string} for what it's for, in a few words
|
|
47
|
+
* @property {string[]} [legacy] older names for the same access
|
|
48
|
+
* @property {string[]} [workers] with scope `workers`, the Workers to choose
|
|
49
|
+
* @property {boolean} [once] only for the first apply that makes a Worker: give it, then take it away
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
/** The board's read token, with where each permission is given. */
|
|
53
|
+
export const readPermissions = () =>
|
|
54
|
+
READ_PERMISSIONS.map(
|
|
55
|
+
(p) =>
|
|
56
|
+
/** @type {SetupPermission} */ ({
|
|
57
|
+
...p,
|
|
58
|
+
scope: p.name === WORKERS_READ ? 'workers-product' : ZONE.has(p.name) ? 'zones' : 'account',
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
/** The Write permission each kind needs beyond Workers Editor, and what it's for. */
|
|
63
|
+
const KIND_WRITES = [
|
|
64
|
+
{ kinds: ['route', 'custom-domain'], name: 'Workers Routes Write', for: 'routes and custom domains, on their zones' },
|
|
65
|
+
{ kinds: ['d1'], name: 'D1 Write', for: 'D1 databases' },
|
|
66
|
+
{ kinds: ['kv'], name: 'Workers KV Storage Write', for: 'KV namespaces' },
|
|
67
|
+
{ kinds: ['r2'], name: 'Workers R2 Storage Write', for: 'R2 buckets' },
|
|
68
|
+
{ kinds: ['queue'], name: 'Queues Write', for: 'queues' },
|
|
69
|
+
{ kinds: ['container'], name: 'Containers Write', for: 'container applications' },
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What one environment's write token needs: the read token's permissions (apply discovers again with it), Workers
|
|
74
|
+
* Editor on its Workers (on the Workers product when it declares custom domains, which have no per-Worker role yet),
|
|
75
|
+
* and only the Write permissions for the kinds it declares. A Worker the desired state names that doesn't run yet
|
|
76
|
+
* needs Workers Admin for the apply that makes it, never as part of the standing token.
|
|
77
|
+
* @param {{ resources?: Array<{ kind: string, name: string }> } | null} desired the environment's desired state, null
|
|
78
|
+
* when it has none yet
|
|
79
|
+
* @param {{ running?: string[] }} [options] the Workers that already run in the environment, by name
|
|
80
|
+
* @returns {SetupPermission[]}
|
|
81
|
+
*/
|
|
82
|
+
export function writePermissions(desired, { running = [] } = {}) {
|
|
83
|
+
const resources = desired?.resources ?? [];
|
|
84
|
+
const kinds = new Set(resources.map((r) => r.kind));
|
|
85
|
+
const workers = [...new Set(resources.filter((r) => r.kind === 'worker').map((r) => r.name))].sort();
|
|
86
|
+
const domains = kinds.has('custom-domain');
|
|
87
|
+
/** @type {SetupPermission[]} */
|
|
88
|
+
const out = readPermissions();
|
|
89
|
+
out.push({
|
|
90
|
+
name: 'Workers Editor',
|
|
91
|
+
legacy: ['Workers Scripts Write'],
|
|
92
|
+
scope: domains || !workers.length ? 'workers-product' : 'workers',
|
|
93
|
+
...(domains || !workers.length ? {} : { workers }),
|
|
94
|
+
for: domains
|
|
95
|
+
? 'changing its Workers and their custom domains (custom domains have no per-Worker role yet)'
|
|
96
|
+
: 'changing its Workers and their Durable Objects',
|
|
97
|
+
});
|
|
98
|
+
for (const w of KIND_WRITES)
|
|
99
|
+
if (w.kinds.some((k) => kinds.has(k)))
|
|
100
|
+
out.push({ name: w.name, scope: ZONE.has(w.name) ? 'zones' : 'account', for: w.for });
|
|
101
|
+
const have = new Set(running);
|
|
102
|
+
const missing = workers.filter((w) => !have.has(w));
|
|
103
|
+
if (missing.length)
|
|
104
|
+
out.push({
|
|
105
|
+
name: 'Workers Admin',
|
|
106
|
+
scope: 'workers-product',
|
|
107
|
+
once: true,
|
|
108
|
+
for: `making ${missing.join(', ')}, which ${missing.length === 1 ? 'doesn’t' : 'don’t'} run yet: give it for the first apply, then take it away`,
|
|
109
|
+
});
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The link that opens Cloudflare's create-token page for an account API token with every permission it can prefill,
|
|
115
|
+
* and the ones to add by hand. Workers Editor scoped to chosen Workers, and Workers Admin, are always by hand.
|
|
116
|
+
* @param {Array<{ name: string }>} permissions
|
|
117
|
+
* @param {string} name the token's name, prefilled
|
|
118
|
+
* @returns {{ url: string, prefilled: string[], byHand: string[] }}
|
|
119
|
+
*/
|
|
120
|
+
export function tokenTemplate(permissions, name) {
|
|
121
|
+
const keys = [];
|
|
122
|
+
const prefilled = [];
|
|
123
|
+
const byHand = [];
|
|
124
|
+
for (const p of permissions) {
|
|
125
|
+
const key = TEMPLATE_KEYS[/** @type {keyof typeof TEMPLATE_KEYS} */ (p.name)];
|
|
126
|
+
if (key) {
|
|
127
|
+
keys.push(key);
|
|
128
|
+
prefilled.push(p.name);
|
|
129
|
+
} else byHand.push(p.name);
|
|
130
|
+
}
|
|
131
|
+
const query = `permissionGroupKeys=${encodeURIComponent(JSON.stringify(keys))}&name=${encodeURIComponent(name)}`;
|
|
132
|
+
return { url: `${TEMPLATE_BASE}&${query}`, prefilled, byHand };
|
|
133
|
+
}
|