@bakers-build/conductor 0.0.0-stage → 0.1.0-next.42
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/LICENSE +21 -0
- package/README.md +4 -2
- package/dist/src/bakers-project-context.d.ts +12 -0
- package/dist/src/bakers-project-context.js +59 -0
- package/dist/src/cli/config-plan.d.ts +35 -0
- package/dist/src/cli/config-plan.js +1264 -0
- package/dist/src/cli/config-schema.d.ts +137 -0
- package/dist/src/cli/config-schema.js +239 -0
- package/dist/src/cli/config-source-edit.d.ts +17 -0
- package/dist/src/cli/config-source-edit.js +95 -0
- package/dist/src/cli/config-templates.d.ts +43 -0
- package/dist/src/cli/config-templates.js +130 -0
- package/dist/src/cli/config-validation.d.ts +12 -0
- package/dist/src/cli/config-validation.js +353 -0
- package/dist/src/cli/migrate-addon-names.d.ts +82 -0
- package/dist/src/cli/migrate-addon-names.js +334 -0
- package/dist/src/cli/project-context.d.ts +21 -0
- package/dist/src/cli/project-context.js +105 -0
- package/dist/src/commands/conductor.d.ts +91 -0
- package/dist/src/commands/conductor.js +843 -0
- package/dist/src/config.d.ts +109 -0
- package/dist/src/config.js +712 -0
- package/dist/src/contract.d.ts +13 -0
- package/dist/src/contract.js +19 -0
- package/dist/src/core/action-queue.d.ts +31 -0
- package/dist/src/core/action-queue.js +69 -0
- package/dist/src/core/attempt.d.ts +25 -0
- package/dist/src/core/attempt.js +41 -0
- package/dist/src/core/conductor.d.ts +236 -0
- package/dist/src/core/conductor.js +1293 -0
- package/dist/src/core/host-key.d.ts +19 -0
- package/dist/src/core/host-key.js +27 -0
- package/dist/src/core/inbox-runtime.d.ts +48 -0
- package/dist/src/core/inbox-runtime.js +180 -0
- package/dist/src/core/mr-changes.d.ts +18 -0
- package/dist/src/core/mr-changes.js +47 -0
- package/dist/src/index.d.ts +8 -0
- package/dist/src/index.js +27 -0
- package/dist/src/plugins/agent-args.d.ts +43 -0
- package/dist/src/plugins/agent-args.js +221 -0
- package/dist/src/plugins/agent.d.ts +121 -0
- package/dist/src/plugins/agent.js +12 -0
- package/dist/src/plugins/executor.d.ts +182 -0
- package/dist/src/plugins/executor.js +1 -0
- package/dist/src/plugins/message.d.ts +61 -0
- package/dist/src/plugins/message.js +47 -0
- package/dist/src/plugins/plugins.d.ts +96 -0
- package/dist/src/plugins/plugins.js +120 -0
- package/dist/src/plugins/preset.d.ts +61 -0
- package/dist/src/plugins/preset.js +23 -0
- package/dist/src/plugins/process-host.d.ts +7 -0
- package/dist/src/plugins/process-host.js +1 -0
- package/dist/src/plugins/remote.d.ts +420 -0
- package/dist/src/plugins/remote.js +1 -0
- package/dist/src/plugins/workspace.d.ts +69 -0
- package/dist/src/plugins/workspace.js +1 -0
- package/dist/src/preset.d.ts +2 -0
- package/dist/src/preset.js +8 -0
- package/dist/src/types.d.ts +132 -0
- package/dist/src/types.js +6 -0
- package/package.json +38 -4
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A single, discrete, testable upgrade method `vN → vN+1`, registered in order —
|
|
3
|
+
* exactly like a Drupal `hook_update_N` chain. `bakers config upgrade` resolves a config's
|
|
4
|
+
* current version and applies each method in sequence up to CURRENT (it does not
|
|
5
|
+
* blindly overwrite), so a method can PRESERVE operator-set fields across a bump.
|
|
6
|
+
*/
|
|
7
|
+
export interface ConnectionConfigMigration {
|
|
8
|
+
/** The schema version this method upgrades FROM. */
|
|
9
|
+
from: number;
|
|
10
|
+
/** The schema version this method upgrades TO (=== from + 1). */
|
|
11
|
+
to: number;
|
|
12
|
+
/** What shape change this method performs (human-readable). */
|
|
13
|
+
description: string;
|
|
14
|
+
/**
|
|
15
|
+
* Upgrade the config text from `from` to `to`, returning the new file text
|
|
16
|
+
* WITHOUT the version header — the runner re-stamps `@bakers-schema-version to`.
|
|
17
|
+
*/
|
|
18
|
+
apply(currentText: string): string;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* BB-226: the package name whose `plugins[]` entry contributes the `md`
|
|
22
|
+
* renderer (plus the authoritative JSON:API schema) to a connection config. The
|
|
23
|
+
* AGGREGATOR is named, not `@dropsh/plugin-markdown` directly: a direct
|
|
24
|
+
* `@dropsh/*` descriptor is unresolvable from `loadBbConfig`'s bases
|
|
25
|
+
* (config dir / host module / cwd), whereas `@bakers-build/addon-essentials` is a
|
|
26
|
+
* real dependency of both the host and the engine and therefore resolves in the
|
|
27
|
+
* monorepo AND from a global `@bakers-build/bakers` install. No `export:` is needed —
|
|
28
|
+
* the package default-exports its factory.
|
|
29
|
+
*/
|
|
30
|
+
export declare const RENDERER_PLUGIN_PACKAGE = "@bakers-build/addon-essentials";
|
|
31
|
+
/** The exact `plugins[]` element text the v1→v2 migration and the scaffold emit. */
|
|
32
|
+
export declare const RENDERER_PLUGIN_ENTRY = "{ plugin: '@bakers-build/addon-essentials' },";
|
|
33
|
+
/**
|
|
34
|
+
* BB-226 (v1 → v2): insert the renderer aggregator as the FIRST `plugins[]`
|
|
35
|
+
* element, so `bakers dropsh --format md` works instead of failing with
|
|
36
|
+
* `E_CONFIG: Unknown format 'md'. Available: json`.
|
|
37
|
+
*
|
|
38
|
+
* A pure TEXT transform — the upgrade runner must never execute a possibly
|
|
39
|
+
* side-effecting config module. Idempotent: text that already names the package
|
|
40
|
+
* in CODE is returned unchanged (whole-line `//` comments are stripped for that
|
|
41
|
+
* probe, so the template's own explanatory header does not read as a hit), and a
|
|
42
|
+
* config with no `plugins: [` array literal (an `addons: []`-shaped or
|
|
43
|
+
* hand-reshaped file) is left alone rather than guessed at.
|
|
44
|
+
*/
|
|
45
|
+
export declare function addRendererPluginEntry(text: string): string;
|
|
46
|
+
/** The dropsh plugin package whose entries carry a connection's OAuth identity. */
|
|
47
|
+
export declare const OAUTH2_PLUGIN_PACKAGE = "@dropsh/plugin-oauth2";
|
|
48
|
+
/**
|
|
49
|
+
* The id of the ONE oauth2 provider a bakers.build connection declares (BB-391 D1).
|
|
50
|
+
*
|
|
51
|
+
* `bakers`, not `session`: the entry names the CONNECTION, not a capability. Until
|
|
52
|
+
* v3 a config declared two providers that differed only by scope — `session`
|
|
53
|
+
* (`bb:session`) and `pm` (`bakers:project_manager`) — and every write had to
|
|
54
|
+
* pick one. What that choice cost is what BB-391 removes; the id therefore
|
|
55
|
+
* names the connection, and the surviving scope carries the capability.
|
|
56
|
+
*/
|
|
57
|
+
export declare const OAUTH2_PROVIDER_ID = "bakers";
|
|
58
|
+
/**
|
|
59
|
+
* The ONE OAuth2 scope a bakers.build connection requests (BB-391 D1, as revised).
|
|
60
|
+
*
|
|
61
|
+
* There is exactly one, and there is not zero. `bb:session`'s role carries
|
|
62
|
+
* the ceiling of what a machine identity may do.
|
|
63
|
+
*
|
|
64
|
+
* Zero was the original design, and a live control plane refuted it:
|
|
65
|
+
* `simple_oauth`'s Oauth2AccessPolicy rewrites a token account's permissions
|
|
66
|
+
* with `overwrite: TRUE`, and for a `client_credentials` token — every bakers.build
|
|
67
|
+
* machine identity — the set becomes EXACTLY the scope's permissions, with
|
|
68
|
+
* neither user_role nor OG-rank permissions surviving. A request naming no scope
|
|
69
|
+
* against a consumer holding no default scopes does not even mint a token.
|
|
70
|
+
*/
|
|
71
|
+
export declare const OAUTH2_SCOPE = "bb:session";
|
|
72
|
+
/**
|
|
73
|
+
* BB-391 (v2 → v3): collapse a connection's oauth2 entries into ONE `bakers`
|
|
74
|
+
* provider on ONE scope — the whole of the one-identity change, as a pure text
|
|
75
|
+
* transform.
|
|
76
|
+
*
|
|
77
|
+
* A MIGRATION rather than a cutover (spec D3): operators have hand-edited these
|
|
78
|
+
* files — a pinned literal `baseUrl`, a per-install `workspace_id`, an extra
|
|
79
|
+
* `addons[]` entry — so every value outside the two elements being merged is
|
|
80
|
+
* carried over verbatim, and the runner never EXECUTES a possibly
|
|
81
|
+
* side-effecting config module.
|
|
82
|
+
*
|
|
83
|
+
* The FIRST oauth2 element survives (it is the one v2 marked `default: true`);
|
|
84
|
+
* the rest are removed line-and-all. A config with no oauth2 element is left
|
|
85
|
+
* exactly as it is — that is the one silent no-op, and it is silent because
|
|
86
|
+
* there is nothing to collapse. An element that IS there but cannot be given the
|
|
87
|
+
* `bakers` identity throws (see {@link ensureProviderId}) rather than passing
|
|
88
|
+
* through unchanged.
|
|
89
|
+
*/
|
|
90
|
+
export declare function collapseOauth2Providers(text: string): string;
|
|
91
|
+
/**
|
|
92
|
+
* The ordered, contiguous migration chain. v1 was the pre-BB-226 shape (auth
|
|
93
|
+
* plugins only); v2 adds the markdown renderer entry; v3 collapses the two
|
|
94
|
+
* differently-scoped oauth2 providers into ONE identity on ONE scope (BB-391)
|
|
95
|
+
* — one, not zero: see {@link OAUTH2_SCOPE} for why the scope survives.
|
|
96
|
+
* Nothing below v1 is a versioned config (v0 = seed / hand-authored / untouched).
|
|
97
|
+
* `BB_CONFIG_SCHEMA_VERSION` derives from the chain's last `to` — never
|
|
98
|
+
* hardcode it.
|
|
99
|
+
*/
|
|
100
|
+
export declare const CONNECTION_MIGRATIONS: ConnectionConfigMigration[];
|
|
101
|
+
/** The current connection-config schema version — single source of truth. */
|
|
102
|
+
export declare const BB_CONFIG_SCHEMA_VERSION: number;
|
|
103
|
+
/** Parse the `@bakers-schema-version` marker from config text; `0` when absent. */
|
|
104
|
+
export declare function schemaVersionOf(text: string): number;
|
|
105
|
+
/**
|
|
106
|
+
* Read the schema version from a connection config FILE by regex (sync, never
|
|
107
|
+
* executes the module). `0` when the file is absent, unreadable, or carries no
|
|
108
|
+
* marker (legacy / hand-authored).
|
|
109
|
+
*/
|
|
110
|
+
export declare function readConfigSchemaVersion(path: string): number;
|
|
111
|
+
/** Strip a leading `// @bakers-schema-version <N>` header line, if present. */
|
|
112
|
+
export declare function stripVersionHeader(text: string): string;
|
|
113
|
+
/** Prepend (replacing any existing) a `// @bakers-schema-version <N>` header. */
|
|
114
|
+
export declare function stampVersion(text: string, version: number): string;
|
|
115
|
+
/**
|
|
116
|
+
* Re-stamp the in-body `schema_version: <N>` field, which the generated config
|
|
117
|
+
* carries alongside the header marker for programmatic reads. The header is the
|
|
118
|
+
* upgrade decision's source of truth and is stamped by the runner; this keeps the
|
|
119
|
+
* FIELD from going stale when a migration bumps the version (BB-226 — the first
|
|
120
|
+
* migration is where the two could first disagree). No-op when the field is
|
|
121
|
+
* absent (a hand-authored config need not carry it).
|
|
122
|
+
*/
|
|
123
|
+
export declare function stampSchemaVersionField(text: string, version: number): string;
|
|
124
|
+
/**
|
|
125
|
+
* Validate a migration chain is contiguous: each `to === from + 1`, the first
|
|
126
|
+
* migrates from v1, no gaps, and the chain ends at `current` (default CURRENT).
|
|
127
|
+
* An empty chain is valid iff `current === 1`. Throws with an actionable message.
|
|
128
|
+
*/
|
|
129
|
+
export declare function assertContiguous(chain: ConnectionConfigMigration[], current?: number): void;
|
|
130
|
+
/**
|
|
131
|
+
* Walk the migration chain from `from` up to `to`, applying each method's
|
|
132
|
+
* `apply` in sequence. Input/output text is WITHOUT the version header — the
|
|
133
|
+
* caller stamps CURRENT after. After each step the in-body `schema_version:`
|
|
134
|
+
* field is re-stamped to that step's `to`, so no individual method has to know
|
|
135
|
+
* (or hardcode) its own number twice. `chain` is injectable for tests.
|
|
136
|
+
*/
|
|
137
|
+
export declare function applyMigrations(text: string, from: number, to: number, chain?: ConnectionConfigMigration[]): string;
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { ConfigSourceEditor } from './config-source-edit.js';
|
|
3
|
+
// BB-216: schema versioning for the generated CONNECTION config
|
|
4
|
+
// (`bakers.config.js`, project + home). Decoupled from the npm package semver —
|
|
5
|
+
// the schema version bumps only when the config SHAPE changes, not every
|
|
6
|
+
// release. Every generated connection config carries a machine-readable header
|
|
7
|
+
// comment `// @bakers-schema-version <N>` (the source of truth for the upgrade
|
|
8
|
+
// decision, read by regex so `bakers config upgrade` NEVER executes a possibly
|
|
9
|
+
// side-effecting config module) and a `schema_version: <N>` field for
|
|
10
|
+
// programmatic reads.
|
|
11
|
+
/** The header marker prepended to every generated connection config. */
|
|
12
|
+
const MARKER_RE = /@bakers-schema-version\s+(\d+)/;
|
|
13
|
+
const HEADER_LINE_RE = /^\/\/ @bakers-schema-version \d+\n/;
|
|
14
|
+
/**
|
|
15
|
+
* BB-226: the package name whose `plugins[]` entry contributes the `md`
|
|
16
|
+
* renderer (plus the authoritative JSON:API schema) to a connection config. The
|
|
17
|
+
* AGGREGATOR is named, not `@dropsh/plugin-markdown` directly: a direct
|
|
18
|
+
* `@dropsh/*` descriptor is unresolvable from `loadBbConfig`'s bases
|
|
19
|
+
* (config dir / host module / cwd), whereas `@bakers-build/addon-essentials` is a
|
|
20
|
+
* real dependency of both the host and the engine and therefore resolves in the
|
|
21
|
+
* monorepo AND from a global `@bakers-build/bakers` install. No `export:` is needed —
|
|
22
|
+
* the package default-exports its factory.
|
|
23
|
+
*/
|
|
24
|
+
export const RENDERER_PLUGIN_PACKAGE = '@bakers-build/addon-essentials';
|
|
25
|
+
/** The exact `plugins[]` element text the v1→v2 migration and the scaffold emit. */
|
|
26
|
+
export const RENDERER_PLUGIN_ENTRY = `{ plugin: '${RENDERER_PLUGIN_PACKAGE}' },`;
|
|
27
|
+
/**
|
|
28
|
+
* BB-226 (v1 → v2): insert the renderer aggregator as the FIRST `plugins[]`
|
|
29
|
+
* element, so `bakers dropsh --format md` works instead of failing with
|
|
30
|
+
* `E_CONFIG: Unknown format 'md'. Available: json`.
|
|
31
|
+
*
|
|
32
|
+
* A pure TEXT transform — the upgrade runner must never execute a possibly
|
|
33
|
+
* side-effecting config module. Idempotent: text that already names the package
|
|
34
|
+
* in CODE is returned unchanged (whole-line `//` comments are stripped for that
|
|
35
|
+
* probe, so the template's own explanatory header does not read as a hit), and a
|
|
36
|
+
* config with no `plugins: [` array literal (an `addons: []`-shaped or
|
|
37
|
+
* hand-reshaped file) is left alone rather than guessed at.
|
|
38
|
+
*/
|
|
39
|
+
export function addRendererPluginEntry(text) {
|
|
40
|
+
const e = new ConfigSourceEditor(text);
|
|
41
|
+
const array = e.property('plugins');
|
|
42
|
+
if (array?.nodeType !== 'ArrayExpression')
|
|
43
|
+
return text;
|
|
44
|
+
if ([...e.source.properties].some(([key, p]) => /^plugins\[\d+\]\.plugin$/.test(key) &&
|
|
45
|
+
p.value === RENDERER_PLUGIN_PACKAGE))
|
|
46
|
+
return text;
|
|
47
|
+
e.replace(array.valueStart + 1, array.valueStart + 1, `\n ${RENDERER_PLUGIN_ENTRY}`);
|
|
48
|
+
return e.text;
|
|
49
|
+
}
|
|
50
|
+
/** The dropsh plugin package whose entries carry a connection's OAuth identity. */
|
|
51
|
+
export const OAUTH2_PLUGIN_PACKAGE = '@dropsh/plugin-oauth2';
|
|
52
|
+
/**
|
|
53
|
+
* The id of the ONE oauth2 provider a bakers.build connection declares (BB-391 D1).
|
|
54
|
+
*
|
|
55
|
+
* `bakers`, not `session`: the entry names the CONNECTION, not a capability. Until
|
|
56
|
+
* v3 a config declared two providers that differed only by scope — `session`
|
|
57
|
+
* (`bb:session`) and `pm` (`bakers:project_manager`) — and every write had to
|
|
58
|
+
* pick one. What that choice cost is what BB-391 removes; the id therefore
|
|
59
|
+
* names the connection, and the surviving scope carries the capability.
|
|
60
|
+
*/
|
|
61
|
+
export const OAUTH2_PROVIDER_ID = 'bakers';
|
|
62
|
+
/**
|
|
63
|
+
* The ONE OAuth2 scope a bakers.build connection requests (BB-391 D1, as revised).
|
|
64
|
+
*
|
|
65
|
+
* There is exactly one, and there is not zero. `bb:session`'s role carries
|
|
66
|
+
* the ceiling of what a machine identity may do.
|
|
67
|
+
*
|
|
68
|
+
* Zero was the original design, and a live control plane refuted it:
|
|
69
|
+
* `simple_oauth`'s Oauth2AccessPolicy rewrites a token account's permissions
|
|
70
|
+
* with `overwrite: TRUE`, and for a `client_credentials` token — every bakers.build
|
|
71
|
+
* machine identity — the set becomes EXACTLY the scope's permissions, with
|
|
72
|
+
* neither user_role nor OG-rank permissions surviving. A request naming no scope
|
|
73
|
+
* against a consumer holding no default scopes does not even mint a token.
|
|
74
|
+
*/
|
|
75
|
+
export const OAUTH2_SCOPE = 'bb:session';
|
|
76
|
+
/**
|
|
77
|
+
* BB-391 (v2 → v3): collapse a connection's oauth2 entries into ONE `bakers`
|
|
78
|
+
* provider on ONE scope — the whole of the one-identity change, as a pure text
|
|
79
|
+
* transform.
|
|
80
|
+
*
|
|
81
|
+
* A MIGRATION rather than a cutover (spec D3): operators have hand-edited these
|
|
82
|
+
* files — a pinned literal `baseUrl`, a per-install `workspace_id`, an extra
|
|
83
|
+
* `addons[]` entry — so every value outside the two elements being merged is
|
|
84
|
+
* carried over verbatim, and the runner never EXECUTES a possibly
|
|
85
|
+
* side-effecting config module.
|
|
86
|
+
*
|
|
87
|
+
* The FIRST oauth2 element survives (it is the one v2 marked `default: true`);
|
|
88
|
+
* the rest are removed line-and-all. A config with no oauth2 element is left
|
|
89
|
+
* exactly as it is — that is the one silent no-op, and it is silent because
|
|
90
|
+
* there is nothing to collapse. An element that IS there but cannot be given the
|
|
91
|
+
* `bakers` identity throws (see {@link ensureProviderId}) rather than passing
|
|
92
|
+
* through unchanged.
|
|
93
|
+
*/
|
|
94
|
+
export function collapseOauth2Providers(text) {
|
|
95
|
+
const e = new ConfigSourceEditor(text);
|
|
96
|
+
const entries = [...e.source.properties]
|
|
97
|
+
.filter(([key, p]) => /^plugins\[\d+\]\.plugin$/.test(key) &&
|
|
98
|
+
p.value === OAUTH2_PLUGIN_PACKAGE)
|
|
99
|
+
.map(([key]) => key.replace(/\.plugin$/, ''));
|
|
100
|
+
const first = entries[0];
|
|
101
|
+
if (!first)
|
|
102
|
+
return text;
|
|
103
|
+
if (e.property(`${first}.with`)?.nodeType !== 'ObjectExpression')
|
|
104
|
+
throw new Error('Cannot migrate oauth2 without a with object.');
|
|
105
|
+
for (const duplicate of entries.slice(1).reverse()) {
|
|
106
|
+
for (const key of ['client_id', 'client_secret', 'token_url', 'type']) {
|
|
107
|
+
if (e.expression(`${first}.with.${key}`) !==
|
|
108
|
+
e.expression(`${duplicate}.with.${key}`))
|
|
109
|
+
throw new Error('OAuth providers have conflicting connection inputs.');
|
|
110
|
+
}
|
|
111
|
+
e.remove(duplicate);
|
|
112
|
+
}
|
|
113
|
+
for (const [key, value] of [
|
|
114
|
+
['id', "'bakers'"],
|
|
115
|
+
['scope', "'bb:session'"],
|
|
116
|
+
['default', 'true'],
|
|
117
|
+
]) {
|
|
118
|
+
if (e.expression(`${first}.with.${key}`) !== value)
|
|
119
|
+
e.set(`${first}.with.${key}`, value);
|
|
120
|
+
}
|
|
121
|
+
return e.text;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The ordered, contiguous migration chain. v1 was the pre-BB-226 shape (auth
|
|
125
|
+
* plugins only); v2 adds the markdown renderer entry; v3 collapses the two
|
|
126
|
+
* differently-scoped oauth2 providers into ONE identity on ONE scope (BB-391)
|
|
127
|
+
* — one, not zero: see {@link OAUTH2_SCOPE} for why the scope survives.
|
|
128
|
+
* Nothing below v1 is a versioned config (v0 = seed / hand-authored / untouched).
|
|
129
|
+
* `BB_CONFIG_SCHEMA_VERSION` derives from the chain's last `to` — never
|
|
130
|
+
* hardcode it.
|
|
131
|
+
*/
|
|
132
|
+
export const CONNECTION_MIGRATIONS = [
|
|
133
|
+
{
|
|
134
|
+
from: 1,
|
|
135
|
+
to: 2,
|
|
136
|
+
description: `add the ${RENDERER_PLUGIN_PACKAGE} renderer entry to plugins[]`,
|
|
137
|
+
apply: addRendererPluginEntry,
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
from: 2,
|
|
141
|
+
to: 3,
|
|
142
|
+
description: `collapse the two ${OAUTH2_PLUGIN_PACKAGE} entries into one ` +
|
|
143
|
+
`'${OAUTH2_PROVIDER_ID}' provider on scope '${OAUTH2_SCOPE}'`,
|
|
144
|
+
apply: collapseOauth2Providers,
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
from: 3,
|
|
148
|
+
to: 4,
|
|
149
|
+
description: 'connection owns credentials and workspace independently of machine context',
|
|
150
|
+
apply: (text) => {
|
|
151
|
+
const source = new ConfigSourceEditor(text);
|
|
152
|
+
if (/const (baseUrl|clientId|clientSecret|workspaceId) = [^;]*machine\.(base_url|client_id|client_secret|workspace_id)/.test(text) ||
|
|
153
|
+
[...source.source.properties.keys()].some((key) => /\bmachine\.(base_url|client_id|client_secret|workspace_id)\b/.test(source.expression(key) ?? '')))
|
|
154
|
+
throw new Error('Connection ownership requires scoped cross-file planning.');
|
|
155
|
+
return text;
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
/** The current connection-config schema version — single source of truth. */
|
|
160
|
+
export const BB_CONFIG_SCHEMA_VERSION = CONNECTION_MIGRATIONS.at(-1)?.to ?? 1;
|
|
161
|
+
/** Parse the `@bakers-schema-version` marker from config text; `0` when absent. */
|
|
162
|
+
export function schemaVersionOf(text) {
|
|
163
|
+
const m = MARKER_RE.exec(text);
|
|
164
|
+
return m ? Number.parseInt(m[1], 10) : 0;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Read the schema version from a connection config FILE by regex (sync, never
|
|
168
|
+
* executes the module). `0` when the file is absent, unreadable, or carries no
|
|
169
|
+
* marker (legacy / hand-authored).
|
|
170
|
+
*/
|
|
171
|
+
export function readConfigSchemaVersion(path) {
|
|
172
|
+
try {
|
|
173
|
+
return schemaVersionOf(readFileSync(path, 'utf8'));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/** Strip a leading `// @bakers-schema-version <N>` header line, if present. */
|
|
180
|
+
export function stripVersionHeader(text) {
|
|
181
|
+
return text.replace(HEADER_LINE_RE, '');
|
|
182
|
+
}
|
|
183
|
+
/** Prepend (replacing any existing) a `// @bakers-schema-version <N>` header. */
|
|
184
|
+
export function stampVersion(text, version) {
|
|
185
|
+
return `// @bakers-schema-version ${version}\n${stripVersionHeader(text)}`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Re-stamp the in-body `schema_version: <N>` field, which the generated config
|
|
189
|
+
* carries alongside the header marker for programmatic reads. The header is the
|
|
190
|
+
* upgrade decision's source of truth and is stamped by the runner; this keeps the
|
|
191
|
+
* FIELD from going stale when a migration bumps the version (BB-226 — the first
|
|
192
|
+
* migration is where the two could first disagree). No-op when the field is
|
|
193
|
+
* absent (a hand-authored config need not carry it).
|
|
194
|
+
*/
|
|
195
|
+
export function stampSchemaVersionField(text, version) {
|
|
196
|
+
return text.replace(/^([ \t]*)schema_version:\s*\d+\s*,/m, `$1schema_version: ${version},`);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Validate a migration chain is contiguous: each `to === from + 1`, the first
|
|
200
|
+
* migrates from v1, no gaps, and the chain ends at `current` (default CURRENT).
|
|
201
|
+
* An empty chain is valid iff `current === 1`. Throws with an actionable message.
|
|
202
|
+
*/
|
|
203
|
+
export function assertContiguous(chain, current = BB_CONFIG_SCHEMA_VERSION) {
|
|
204
|
+
let prev = 1;
|
|
205
|
+
chain.forEach((m, i) => {
|
|
206
|
+
if (m.to !== m.from + 1) {
|
|
207
|
+
throw new Error(`migration[${i}]: to (${m.to}) must equal from+1 (${m.from + 1})`);
|
|
208
|
+
}
|
|
209
|
+
const expectedFrom = i === 0 ? 1 : prev;
|
|
210
|
+
if (m.from !== expectedFrom) {
|
|
211
|
+
throw new Error(`migration[${i}]: from (${m.from}) must equal ${expectedFrom} (no gaps; chain starts at v1)`);
|
|
212
|
+
}
|
|
213
|
+
prev = m.to;
|
|
214
|
+
});
|
|
215
|
+
const end = chain.at(-1)?.to ?? 1;
|
|
216
|
+
if (end !== current) {
|
|
217
|
+
throw new Error(`migration chain ends at v${end}, expected CURRENT v${current}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Walk the migration chain from `from` up to `to`, applying each method's
|
|
222
|
+
* `apply` in sequence. Input/output text is WITHOUT the version header — the
|
|
223
|
+
* caller stamps CURRENT after. After each step the in-body `schema_version:`
|
|
224
|
+
* field is re-stamped to that step's `to`, so no individual method has to know
|
|
225
|
+
* (or hardcode) its own number twice. `chain` is injectable for tests.
|
|
226
|
+
*/
|
|
227
|
+
export function applyMigrations(text, from, to, chain = CONNECTION_MIGRATIONS) {
|
|
228
|
+
let cur = from;
|
|
229
|
+
let out = text;
|
|
230
|
+
while (cur < to) {
|
|
231
|
+
const step = chain.find((m) => m.from === cur);
|
|
232
|
+
if (step === undefined) {
|
|
233
|
+
throw new Error(`no migration from v${cur} (chain incomplete)`);
|
|
234
|
+
}
|
|
235
|
+
out = stampSchemaVersionField(step.apply(out), step.to);
|
|
236
|
+
cur = step.to;
|
|
237
|
+
}
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ConfigSourceProperty } from '@bakers-build/core';
|
|
2
|
+
/** Small span editor. Every operation re-parses so offsets never become stale. */
|
|
3
|
+
export declare class ConfigSourceEditor {
|
|
4
|
+
text: string;
|
|
5
|
+
constructor(text: string);
|
|
6
|
+
get source(): import("@bakers-build/core").ConfigSourceInspection;
|
|
7
|
+
property(key: string): ConfigSourceProperty | undefined;
|
|
8
|
+
expression(key: string): string | undefined;
|
|
9
|
+
replace(start: number, end: number, value: string): void;
|
|
10
|
+
set(key: string, value: string): void;
|
|
11
|
+
remove(key: string): void;
|
|
12
|
+
rename(key: string, next: string): void;
|
|
13
|
+
stamp(version: number): void;
|
|
14
|
+
}
|
|
15
|
+
export declare function sameValue(a: ConfigSourceProperty, b: ConfigSourceProperty): boolean;
|
|
16
|
+
/** Hide all computed source and every credential-bearing statement in previews. */
|
|
17
|
+
export declare function redactConfigSource(text: string): string;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { inspectConfigSource, } from '@bakers-build/core';
|
|
2
|
+
/** Small span editor. Every operation re-parses so offsets never become stale. */
|
|
3
|
+
export class ConfigSourceEditor {
|
|
4
|
+
text;
|
|
5
|
+
constructor(text) {
|
|
6
|
+
this.text = text;
|
|
7
|
+
}
|
|
8
|
+
get source() {
|
|
9
|
+
return inspectConfigSource(this.text);
|
|
10
|
+
}
|
|
11
|
+
property(key) {
|
|
12
|
+
return this.source.properties.get(key);
|
|
13
|
+
}
|
|
14
|
+
expression(key) {
|
|
15
|
+
const p = this.property(key);
|
|
16
|
+
return p ? this.text.slice(p.valueStart, p.valueEnd) : undefined;
|
|
17
|
+
}
|
|
18
|
+
replace(start, end, value) {
|
|
19
|
+
this.text = this.text.slice(0, start) + value + this.text.slice(end);
|
|
20
|
+
}
|
|
21
|
+
set(key, value) {
|
|
22
|
+
const existing = this.property(key);
|
|
23
|
+
if (existing) {
|
|
24
|
+
this.replace(existing.valueStart, existing.valueEnd, value);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const dot = key.lastIndexOf('.');
|
|
28
|
+
const parentKey = dot < 0 ? null : key.slice(0, dot);
|
|
29
|
+
const name = dot < 0 ? key : key.slice(dot + 1);
|
|
30
|
+
const source = this.source;
|
|
31
|
+
const parent = parentKey ? source.properties.get(parentKey) : undefined;
|
|
32
|
+
if (parentKey && !parent) {
|
|
33
|
+
this.set(parentKey, `{ ${/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name)}: ${value} }`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const start = parent ? parent.valueStart : source.object?.start;
|
|
37
|
+
if (start === undefined ||
|
|
38
|
+
(parent && parent.nodeType !== 'ObjectExpression'))
|
|
39
|
+
throw new Error('Unsupported insertion');
|
|
40
|
+
this.replace(start + 1, start + 1, `\n ${/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name)}: ${value},`);
|
|
41
|
+
}
|
|
42
|
+
remove(key) {
|
|
43
|
+
const p = this.property(key);
|
|
44
|
+
if (!p)
|
|
45
|
+
return;
|
|
46
|
+
let end = p.end;
|
|
47
|
+
while (/\s/.test(this.text[end] ?? '') && end < this.text.length)
|
|
48
|
+
end++;
|
|
49
|
+
if (this.text[end] === ',')
|
|
50
|
+
this.replace(p.start, end + 1, '');
|
|
51
|
+
else {
|
|
52
|
+
let start = p.start - 1;
|
|
53
|
+
while (start >= 0 && /\s/.test(this.text[start] ?? ''))
|
|
54
|
+
start--;
|
|
55
|
+
this.replace(this.text[start] === ',' ? start : p.start, p.end, '');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
rename(key, next) {
|
|
59
|
+
const p = this.property(key);
|
|
60
|
+
if (!p)
|
|
61
|
+
return;
|
|
62
|
+
const colon = this.text.indexOf(':', p.start);
|
|
63
|
+
if (colon < 0 || colon >= p.valueStart)
|
|
64
|
+
throw new Error('Unsupported key');
|
|
65
|
+
this.replace(p.start, colon, next);
|
|
66
|
+
}
|
|
67
|
+
stamp(version) {
|
|
68
|
+
this.set('schema_version', String(version));
|
|
69
|
+
this.text = this.text
|
|
70
|
+
.replace(/^\s*\/\/ @bakers-schema-version \d+\s*$/gm, '')
|
|
71
|
+
.replace(/^\n/, '');
|
|
72
|
+
this.text = `// @bakers-schema-version ${version}\n${this.text}`;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function sameValue(a, b) {
|
|
76
|
+
return (a.staticallyDetermined &&
|
|
77
|
+
b.staticallyDetermined &&
|
|
78
|
+
a.value !== undefined &&
|
|
79
|
+
a.value === b.value);
|
|
80
|
+
}
|
|
81
|
+
/** Hide all computed source and every credential-bearing statement in previews. */
|
|
82
|
+
export function redactConfigSource(text) {
|
|
83
|
+
const editor = new ConfigSourceEditor(text);
|
|
84
|
+
const source = editor.source;
|
|
85
|
+
if (source.syntax !== 'valid' || !source.object)
|
|
86
|
+
return '[SOURCE REDACTED]';
|
|
87
|
+
const spans = source.propertyOccurrences.filter((p) => /secret|token|password|credential|client_id|authorization|url/i.test(p.key) ||
|
|
88
|
+
(!p.staticallyDetermined &&
|
|
89
|
+
!['ObjectExpression', 'ArrayExpression'].includes(p.nodeType)));
|
|
90
|
+
const outer = spans.filter((p) => !spans.some((q) => q !== p && q.valueStart <= p.valueStart && q.valueEnd >= p.valueEnd));
|
|
91
|
+
for (const p of outer.sort((a, b) => b.valueStart - a.valueStart))
|
|
92
|
+
editor.replace(p.valueStart, p.valueEnd, "'[REDACTED]'");
|
|
93
|
+
const clean = editor.source;
|
|
94
|
+
return `[Module preamble redacted]\nexport default ${editor.text.slice(clean.object?.start, clean.object?.end)};`;
|
|
95
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export declare const DEFAULT_CONDUCTOR_AGENT_ADDONS = "[{ use: '@bakers-build/addon-herdr' }, { use: '@bakers-build/addon-claude', alias: 'opus' }]";
|
|
2
|
+
/**
|
|
3
|
+
* A dynamic-import expression for a user-global config file, as source text.
|
|
4
|
+
*
|
|
5
|
+
* The result goes into a COMMITTED file, so it must name no path from the
|
|
6
|
+
* machine that generated it: an absolute path resolves nowhere on the next
|
|
7
|
+
* checkout, and loadMachine() swallows that failure, leaving conductor_id
|
|
8
|
+
* undefined with nothing erroring. BB_HOME is the relocation knob; HOME
|
|
9
|
+
* stays the fallback so the generated text works on a machine that sets
|
|
10
|
+
* neither.
|
|
11
|
+
*/
|
|
12
|
+
export declare function configImportExpression(name: string): string;
|
|
13
|
+
export type ConnectionAuthType = 'oauth2_client_credentials' | 'oauth2_device_code';
|
|
14
|
+
/** Current connection template inputs never come from machine context. */
|
|
15
|
+
export interface ConnectionInputs {
|
|
16
|
+
baseUrl: string;
|
|
17
|
+
clientId: string;
|
|
18
|
+
/** Absent for `oauth2_device_code`: a public client holds no secret. */
|
|
19
|
+
secret?: string;
|
|
20
|
+
workspaceId?: string;
|
|
21
|
+
authType: ConnectionAuthType;
|
|
22
|
+
}
|
|
23
|
+
export declare function renderGlobalConnection(inputs: ConnectionInputs): string;
|
|
24
|
+
export declare function renderHostMachine(inputs: {
|
|
25
|
+
machineId: string;
|
|
26
|
+
userId: string;
|
|
27
|
+
maxParallel: number;
|
|
28
|
+
}): string;
|
|
29
|
+
export declare function renderCommittedConfig(inputs: {
|
|
30
|
+
project: string;
|
|
31
|
+
bakersWorkspace?: string;
|
|
32
|
+
/**
|
|
33
|
+
* The operator's own answer for each lifecycle hook — never invented here.
|
|
34
|
+
* `bakers-cli` names no provisioning tool: an absent or empty command omits
|
|
35
|
+
* that hook's key entirely, and an all-absent `hooks` omits the whole block
|
|
36
|
+
* rather than writing an empty one, because an empty `hooks: {}` would
|
|
37
|
+
* state a decision no one made.
|
|
38
|
+
*/
|
|
39
|
+
hooks?: {
|
|
40
|
+
after_create?: string;
|
|
41
|
+
after_done?: string;
|
|
42
|
+
};
|
|
43
|
+
}): string;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
export const DEFAULT_CONDUCTOR_AGENT_ADDONS = "[{ use: '@bakers-build/addon-herdr' }, { use: '@bakers-build/addon-claude', alias: 'opus' }]";
|
|
2
|
+
/**
|
|
3
|
+
* A dynamic-import expression for a user-global config file, as source text.
|
|
4
|
+
*
|
|
5
|
+
* The result goes into a COMMITTED file, so it must name no path from the
|
|
6
|
+
* machine that generated it: an absolute path resolves nowhere on the next
|
|
7
|
+
* checkout, and loadMachine() swallows that failure, leaving conductor_id
|
|
8
|
+
* undefined with nothing erroring. BB_HOME is the relocation knob; HOME
|
|
9
|
+
* stays the fallback so the generated text works on a machine that sets
|
|
10
|
+
* neither.
|
|
11
|
+
*/
|
|
12
|
+
export function configImportExpression(name) {
|
|
13
|
+
return `\`\${process.env.BB_HOME ?? \`\${process.env.HOME}/.bakers\`}/${name}\``;
|
|
14
|
+
}
|
|
15
|
+
export function renderGlobalConnection(inputs) {
|
|
16
|
+
// The device-code grant has no client_secret in @dropsh/plugin-oauth2's
|
|
17
|
+
// config union, so the key is omitted rather than rendered undefined
|
|
18
|
+
// (BB-496), and it carries device_authorization_url instead.
|
|
19
|
+
const baseUrl = inputs.baseUrl.replace(/\/$/, '');
|
|
20
|
+
// The client-credentials branch renders byte-identical to the pre-BB-496
|
|
21
|
+
// template (a single-quoted `type:` literal): only the device-code branch,
|
|
22
|
+
// which is new, uses JSON.stringify for its type value.
|
|
23
|
+
const type = inputs.authType === 'oauth2_device_code'
|
|
24
|
+
? JSON.stringify(inputs.authType)
|
|
25
|
+
: "'oauth2_client_credentials'";
|
|
26
|
+
const credential = inputs.authType === 'oauth2_device_code'
|
|
27
|
+
? `client_id: ${JSON.stringify(inputs.clientId)},
|
|
28
|
+
device_authorization_url: ${JSON.stringify(`${baseUrl}/oauth/device_authorization`)},`
|
|
29
|
+
: `client_id: ${JSON.stringify(inputs.clientId)}, client_secret: ${JSON.stringify(inputs.secret ?? '')},`;
|
|
30
|
+
return `// @bakers-schema-version 4
|
|
31
|
+
// Global bakers.build connection: credentials and optional legacy workspace. Keep this file private.
|
|
32
|
+
export default {
|
|
33
|
+
schema_version: 4,
|
|
34
|
+
site: { base_url: ${JSON.stringify(inputs.baseUrl)}, jsonapi_prefix: '/jsonapi' },
|
|
35
|
+
${inputs.workspaceId ? ` workspace_id: ${JSON.stringify(inputs.workspaceId)},\n` : ''}
|
|
36
|
+
plugins: [
|
|
37
|
+
{ plugin: '@bakers-build/addon-essentials' },
|
|
38
|
+
{ plugin: '@dropsh/plugin-oauth2', export: 'oauth2Plugin', with: {
|
|
39
|
+
id: 'bakers', default: true, type: ${type}, scope: 'bb:session',
|
|
40
|
+
${credential}
|
|
41
|
+
token_url: ${JSON.stringify(`${baseUrl}/oauth/token`)},
|
|
42
|
+
} },
|
|
43
|
+
],
|
|
44
|
+
};
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
export function renderHostMachine(inputs) {
|
|
48
|
+
return `// @bakers-schema-version 2
|
|
49
|
+
// Local host identity and capacity. Server credentials belong to bakers.config.js.
|
|
50
|
+
export default {
|
|
51
|
+
schema_version: 2,
|
|
52
|
+
machine_id: ${JSON.stringify(inputs.machineId)},
|
|
53
|
+
user_id: ${JSON.stringify(inputs.userId)},
|
|
54
|
+
max_parallel: ${inputs.maxParallel},
|
|
55
|
+
};
|
|
56
|
+
`;
|
|
57
|
+
}
|
|
58
|
+
export function renderCommittedConfig(inputs) {
|
|
59
|
+
return `// Canonical bakers.build conductor engine configuration — committed.
|
|
60
|
+
// Host identity comes from machine.config.js; the optional local override
|
|
61
|
+
// composes this project's conductor identity. Connections belong to bakers.config.js.
|
|
62
|
+
//
|
|
63
|
+
// IMPORT-FREE (BB-78): the plugin slots are \`{ plugin, with }\` descriptors
|
|
64
|
+
// naming the REAL published package, not \`import\`ed constructors.
|
|
65
|
+
// loadConductorConfig resolves each name ESLint-style (config dir → cwd →
|
|
66
|
+
// conductor install). Every addon package default-exports its single factory, so
|
|
67
|
+
// no slot needs an \`export:\` — EXCEPT the merged herdr workspace
|
|
68
|
+
// (\`@bakers-build/addon-herdr\` default-exports the executor, so the workspace names
|
|
69
|
+
// \`export: 'herdrWorkspace'\` — BB-139). BB-224 replaced the old
|
|
70
|
+
// \`{ plugin: '@bakers-build/bakers/plugins', export: 'drupalRemote' }\` host-barrel
|
|
71
|
+
// descriptor with the real addon package \`@bakers-build/addon-remote-drupal\`.
|
|
72
|
+
|
|
73
|
+
// The user-global machine context: host identity and capacity, shared
|
|
74
|
+
// by every project on this machine. Never committed.
|
|
75
|
+
async function loadMachine() {
|
|
76
|
+
try {
|
|
77
|
+
return (await import(${configImportExpression('machine.config.js')})).default ?? {};
|
|
78
|
+
} catch {}
|
|
79
|
+
return {};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// OPTIONAL per-project override — create conductor.config.local.js beside this
|
|
83
|
+
// file to override machine-local values (conductor_id, model, …).
|
|
84
|
+
async function loadLocal() {
|
|
85
|
+
try { return (await import('./conductor.config.local.js')).default ?? {}; } catch {}
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const machine = await loadMachine();
|
|
90
|
+
const local = await loadLocal();
|
|
91
|
+
const project = ${JSON.stringify(inputs.project)};
|
|
92
|
+
const composedConductorId =
|
|
93
|
+
machine.user_id && machine.machine_id
|
|
94
|
+
? \`\${machine.user_id}-\${machine.machine_id}-\${project}\`
|
|
95
|
+
: undefined;
|
|
96
|
+
|
|
97
|
+
export default {
|
|
98
|
+
bakers: { project: ${JSON.stringify(inputs.project)}${inputs.bakersWorkspace ? `, workspace: ${JSON.stringify(inputs.bakersWorkspace)}` : ''} },
|
|
99
|
+
// Registration key (BB-353 renamed from machine_id). Host capacity lives on
|
|
100
|
+
// machine.config.js as max_parallel — do not set max_parallel here.
|
|
101
|
+
conductor_id: local.conductor_id ?? composedConductorId,
|
|
102
|
+
// Set to false to keep this conductor's transcripts out of the log store
|
|
103
|
+
// (e.g. large, uninteresting, or subject to a local policy). Default true;
|
|
104
|
+
// metrics are unaffected either way.
|
|
105
|
+
capture_logs: true,
|
|
106
|
+
${inputs.hooks?.after_create || inputs.hooks?.after_done
|
|
107
|
+
? ` // Lifecycle hooks are executor-owned (BB-84): they live at the config top
|
|
108
|
+
// level — NOT on a plugin descriptor's \`with.hooks\`.
|
|
109
|
+
hooks: { ${[
|
|
110
|
+
inputs.hooks.after_create
|
|
111
|
+
? `after_create: ${JSON.stringify(inputs.hooks.after_create)}`
|
|
112
|
+
: '',
|
|
113
|
+
inputs.hooks.after_done
|
|
114
|
+
? `after_done: ${JSON.stringify(inputs.hooks.after_done)}`
|
|
115
|
+
: '',
|
|
116
|
+
]
|
|
117
|
+
.filter(Boolean)
|
|
118
|
+
.join(', ')} },\n`
|
|
119
|
+
: ''} remote: { plugin: '@bakers-build/addon-remote-drupal' },
|
|
120
|
+
executor: { plugin: '@bakers-build/addon-herdr' },
|
|
121
|
+
addons: ${DEFAULT_CONDUCTOR_AGENT_ADDONS},
|
|
122
|
+
// BB-139: the herdr workspace ships in @bakers-build/addon-herdr, which
|
|
123
|
+
// default-exports the EXECUTOR — so the workspace slot names export: 'herdrWorkspace'.
|
|
124
|
+
workspace: {
|
|
125
|
+
plugin: '@bakers-build/addon-herdr',
|
|
126
|
+
export: 'herdrWorkspace',
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
`;
|
|
130
|
+
}
|