@polycore/runner 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/package.json +2 -2
- package/src/cli.ts +26 -10
- package/src/config.ts +146 -38
- package/src/connect.ts +3 -0
- package/src/index.ts +40 -3
package/README.md
CHANGED
|
@@ -93,7 +93,6 @@ environment can carry credentials for several backends:
|
|
|
93
93
|
"firestore": { "projectId": "alpha-prod" }
|
|
94
94
|
}
|
|
95
95
|
},
|
|
96
|
-
"actionsDir": "/absolute/path/to/alpha/polycore-actions",
|
|
97
96
|
"contextFile": "/absolute/path/to/alpha/schema.md"
|
|
98
97
|
},
|
|
99
98
|
"beta": {
|
|
@@ -113,10 +112,16 @@ environment can carry credentials for several backends:
|
|
|
113
112
|
}
|
|
114
113
|
```
|
|
115
114
|
|
|
115
|
+
Actions are discovered by **convention**, not a config field: put a project's
|
|
116
|
+
actions in an `actions/<slug>/` directory beside the config file, or — when the
|
|
117
|
+
runner serves a single project — directly in `actions/`. They are loaded
|
|
118
|
+
alongside the built-in connectors. (There is no `actionsDir` field.)
|
|
119
|
+
|
|
116
120
|
Per-project extras:
|
|
117
121
|
|
|
118
|
-
- `
|
|
119
|
-
|
|
122
|
+
- `defaultEnvironment`: the environment an unqualified ask targets. It is the
|
|
123
|
+
runner's local-CLI default AND is advertised to the control plane as the
|
|
124
|
+
front-door default. Must be one of the project's `environments`.
|
|
120
125
|
- `secrets`: per-project action secret values (falls back to the runner-level
|
|
121
126
|
`secrets` map, then `process.env`). These never reach the control plane.
|
|
122
127
|
- `context` / `contextFile`: an agent-grounding document (e.g. the product's
|
|
@@ -246,8 +251,11 @@ gcloud run deploy polycore-runner \
|
|
|
246
251
|
|
|
247
252
|
When `POLYCORE_PROJECTS` is set and no `--config` file is given, the runner
|
|
248
253
|
builds its config from these vars; a missing/invalid one fails loud at startup.
|
|
249
|
-
Per-project settings that reference local files (`
|
|
254
|
+
Per-project settings that reference local files (`contextFile`,
|
|
250
255
|
`serviceAccountKeyPath`) point at paths baked into or mounted on the container.
|
|
256
|
+
Actions can only be discovered via the `actions/` convention beside a `--config`
|
|
257
|
+
file, so an env-only config serves just the built-in connectors — prefer a
|
|
258
|
+
committed `polycore.json` (loaded with `--config` / `POLYCORE_RUNNER_CONFIG`).
|
|
251
259
|
|
|
252
260
|
## Tests
|
|
253
261
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polycore/runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Polycore runner: executes governed, read-only-constrained connectors against a customer's datastores. Runs in the customer's own infrastructure; holds scoped credentials and connects outbound to the control plane.",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"tsx": "4.21.0",
|
|
31
31
|
"ws": "8.18.3",
|
|
32
32
|
"zod": "4.4.3",
|
|
33
|
-
"@polycore/protocol": "^0.
|
|
33
|
+
"@polycore/protocol": "^0.2.0"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@types/node": "25.6.2",
|
package/src/cli.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createServer } from "node:http";
|
|
2
|
+
import { dirname } from "node:path";
|
|
2
3
|
import process from "node:process";
|
|
3
4
|
|
|
4
5
|
import {
|
|
5
6
|
buildRunnerConfigFromEnv,
|
|
6
7
|
hasEnvConfig,
|
|
7
8
|
loadRunnerConfig,
|
|
9
|
+
overlayEnvSecrets,
|
|
8
10
|
resolveEnvironment,
|
|
9
11
|
resolveProject,
|
|
10
12
|
RUNNER_CONFIG_ENV,
|
|
@@ -19,10 +21,10 @@ import { buildProjectRegistries } from "./index.js";
|
|
|
19
21
|
* before the control-plane connection exists. Logs go to stderr; the JSON
|
|
20
22
|
* result goes to stdout, so output is pipeable.
|
|
21
23
|
*
|
|
22
|
-
* pnpm --filter @polycore/runner runner list --config ./
|
|
24
|
+
* pnpm --filter @polycore/runner runner list --config ./polycore.json
|
|
23
25
|
* pnpm --filter @polycore/runner runner firestore.count \
|
|
24
26
|
* --project alpha --env dev --args '{"collection":"users"}' \
|
|
25
|
-
* --config ./
|
|
27
|
+
* --config ./polycore.json
|
|
26
28
|
*/
|
|
27
29
|
|
|
28
30
|
interface ParsedArgs {
|
|
@@ -82,13 +84,21 @@ function printUsage(): void {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
/**
|
|
85
|
-
* Pick the config source
|
|
86
|
-
*
|
|
87
|
-
* (`
|
|
87
|
+
* Pick the config source, in priority order:
|
|
88
|
+
*
|
|
89
|
+
* 1. An explicit config **file** (flag or `POLYCORE_RUNNER_CONFIG`). This is
|
|
90
|
+
* the committed, code-as-source-of-truth path: the file declares the whole
|
|
91
|
+
* structural surface (projects, environments, actions, context) with
|
|
92
|
+
* portable relative paths, and any deploy-time secrets it omits
|
|
93
|
+
* (`controlPlane` creds, runner-level `secrets`) are overlaid from env, so
|
|
94
|
+
* the production container bakes in the file and injects only secrets.
|
|
95
|
+
* 2. Else a fully env-driven config (`POLYCORE_PROJECTS` present), the legacy
|
|
96
|
+
* path where the whole structure arrives as env JSON.
|
|
88
97
|
*/
|
|
89
|
-
function resolveConfig(
|
|
90
|
-
|
|
91
|
-
|
|
98
|
+
function resolveConfig(configPath: string | undefined): RunnerConfig {
|
|
99
|
+
if (configPath !== undefined) {
|
|
100
|
+
return overlayEnvSecrets(loadRunnerConfig(configPath), process.env);
|
|
101
|
+
}
|
|
92
102
|
if (hasEnvConfig(process.env)) return buildRunnerConfigFromEnv(process.env);
|
|
93
103
|
throw new Error(
|
|
94
104
|
`No runner config. Pass --config <path>, set ${RUNNER_CONFIG_ENV}, ` +
|
|
@@ -133,8 +143,14 @@ async function main(): Promise<void> {
|
|
|
133
143
|
maybeStartHealthServer((message) => process.stderr.write(`${message}\n`));
|
|
134
144
|
}
|
|
135
145
|
|
|
136
|
-
const
|
|
137
|
-
const
|
|
146
|
+
const configPath = flags.get("config") ?? process.env[RUNNER_CONFIG_ENV];
|
|
147
|
+
const config = resolveConfig(configPath);
|
|
148
|
+
// Actions are discovered from the `actions/` convention beside the config
|
|
149
|
+
// file; an env-driven config (no file) serves only the built-in connectors.
|
|
150
|
+
const registries = await buildProjectRegistries(
|
|
151
|
+
config,
|
|
152
|
+
configPath === undefined ? undefined : dirname(configPath),
|
|
153
|
+
);
|
|
138
154
|
|
|
139
155
|
if (command === "list") {
|
|
140
156
|
for (const [slug, registry] of registries) {
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, resolve } from "node:path";
|
|
2
3
|
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
|
|
@@ -51,15 +52,14 @@ export const ProjectConfigSchema = z
|
|
|
51
52
|
.refine((envs) => Object.keys(envs).length > 0, {
|
|
52
53
|
message: "At least one environment must be configured.",
|
|
53
54
|
}),
|
|
54
|
-
/** Local-CLI default for `runner <capability>` runs. The control plane
|
|
55
|
-
* keeps its own per-project default for front-door asks. */
|
|
56
|
-
defaultEnvironment: z.string().min(1).optional(),
|
|
57
55
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* The environment an unqualified ask targets: the runner's local-CLI
|
|
57
|
+
* default AND the value advertised to the control plane, which uses it as
|
|
58
|
+
* the front-door default when a caller names no environment. One source of
|
|
59
|
+
* truth for "the default env of this project"; must be one of the
|
|
60
|
+
* `environments` keys to take effect.
|
|
61
61
|
*/
|
|
62
|
-
|
|
62
|
+
defaultEnvironment: z.string().min(1).optional(),
|
|
63
63
|
/**
|
|
64
64
|
* Secret values for this project's actions, keyed by the names actions
|
|
65
65
|
* declare in `secrets`. Missing keys fall back to the runner-level
|
|
@@ -132,9 +132,78 @@ export function parseRunnerConfig(json: string): RunnerConfig {
|
|
|
132
132
|
return RunnerConfigSchema.parse(data);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* Read and validate a runner config from a JSON file path, then absolute-
|
|
137
|
+
* resolve its file-referencing fields (`contextFile`, each environment's
|
|
138
|
+
* `serviceAccountKeyPath`) against the config file's own directory. This is
|
|
139
|
+
* what lets a committed config use portable, repo-relative paths
|
|
140
|
+
* (`"./context/app.md"`): the customer's config file is the source of truth and
|
|
141
|
+
* the runner does the path bookkeeping. Absolute paths (e.g. a mounted secret
|
|
142
|
+
* at `/secrets/read.json`) pass through unchanged. Actions are discovered by
|
|
143
|
+
* convention from an `actions/` directory beside the config file (see
|
|
144
|
+
* `buildProjectRegistries`), not via a config field.
|
|
145
|
+
*/
|
|
136
146
|
export function loadRunnerConfig(path: string): RunnerConfig {
|
|
137
|
-
|
|
147
|
+
const config = parseRunnerConfig(readFileSync(path, "utf8"));
|
|
148
|
+
return resolveConfigPaths(config, dirname(path));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Absolute-resolve one path field against `baseDir` when it is relative. */
|
|
152
|
+
function resolveConfigPath(value: string, baseDir: string): string {
|
|
153
|
+
return isAbsolute(value) ? value : resolve(baseDir, value);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Absolute-resolve an environment's `serviceAccountKeyPath` against `baseDir`. */
|
|
157
|
+
function resolveEnvironmentPaths(
|
|
158
|
+
env: EnvironmentConfig,
|
|
159
|
+
baseDir: string,
|
|
160
|
+
): EnvironmentConfig {
|
|
161
|
+
const firestore = env.firestore;
|
|
162
|
+
if (firestore?.serviceAccountKeyPath === undefined) return env;
|
|
163
|
+
return {
|
|
164
|
+
...env,
|
|
165
|
+
firestore: {
|
|
166
|
+
...firestore,
|
|
167
|
+
serviceAccountKeyPath: resolveConfigPath(
|
|
168
|
+
firestore.serviceAccountKeyPath,
|
|
169
|
+
baseDir,
|
|
170
|
+
),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Absolute-resolve one project's file-referencing fields against `baseDir`. */
|
|
176
|
+
function resolveProjectPaths(
|
|
177
|
+
project: ProjectConfig,
|
|
178
|
+
baseDir: string,
|
|
179
|
+
): ProjectConfig {
|
|
180
|
+
const environments = Object.fromEntries(
|
|
181
|
+
Object.entries(project.environments).map(([name, env]) => [
|
|
182
|
+
name,
|
|
183
|
+
resolveEnvironmentPaths(env, baseDir),
|
|
184
|
+
]),
|
|
185
|
+
);
|
|
186
|
+
return {
|
|
187
|
+
...project,
|
|
188
|
+
environments,
|
|
189
|
+
...(project.contextFile === undefined
|
|
190
|
+
? {}
|
|
191
|
+
: { contextFile: resolveConfigPath(project.contextFile, baseDir) }),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Absolute-resolve every project's file-referencing fields against `baseDir`. */
|
|
196
|
+
function resolveConfigPaths(
|
|
197
|
+
config: RunnerConfig,
|
|
198
|
+
baseDir: string,
|
|
199
|
+
): RunnerConfig {
|
|
200
|
+
const projects = Object.fromEntries(
|
|
201
|
+
Object.entries(config.projects).map(([slug, project]) => [
|
|
202
|
+
slug,
|
|
203
|
+
resolveProjectPaths(project, baseDir),
|
|
204
|
+
]),
|
|
205
|
+
);
|
|
206
|
+
return { ...config, projects };
|
|
138
207
|
}
|
|
139
208
|
|
|
140
209
|
/** Env var carrying the projects map (JSON) for env-driven config. */
|
|
@@ -169,41 +238,80 @@ export function buildRunnerConfigFromEnv(
|
|
|
169
238
|
`${RUNNER_PROJECTS_ENV} is required to build a config from the environment.`,
|
|
170
239
|
);
|
|
171
240
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
// can load actions in a container without enrollment creds), while a *partial*
|
|
175
|
-
// block stays included so the schema pinpoints the missing field.
|
|
176
|
-
const cpUrl = source["POLYCORE_CONTROL_PLANE_URL"];
|
|
177
|
-
const cpRunnerId = source["POLYCORE_RUNNER_ID"];
|
|
178
|
-
const cpJoinToken = source["POLYCORE_JOIN_TOKEN"];
|
|
179
|
-
const cpSigningSecret = source["POLYCORE_SIGNING_SECRET"];
|
|
180
|
-
const hasAnyControlPlane =
|
|
181
|
-
cpUrl !== undefined ||
|
|
182
|
-
cpRunnerId !== undefined ||
|
|
183
|
-
cpJoinToken !== undefined ||
|
|
184
|
-
cpSigningSecret !== undefined;
|
|
185
|
-
|
|
241
|
+
const controlPlane = controlPlaneFromEnv(source);
|
|
242
|
+
const secrets = secretsFromEnv(source);
|
|
186
243
|
const candidate = {
|
|
187
244
|
projects: parseJsonEnv(projectsRaw, RUNNER_PROJECTS_ENV),
|
|
188
|
-
...(
|
|
189
|
-
|
|
190
|
-
controlPlane: {
|
|
191
|
-
url: cpUrl,
|
|
192
|
-
runnerId: cpRunnerId,
|
|
193
|
-
joinToken: cpJoinToken,
|
|
194
|
-
signingSecret: cpSigningSecret,
|
|
195
|
-
},
|
|
196
|
-
}
|
|
197
|
-
: {}),
|
|
198
|
-
...(source["POLYCORE_SECRETS"] === undefined
|
|
199
|
-
? {}
|
|
200
|
-
: {
|
|
201
|
-
secrets: parseJsonEnv(source["POLYCORE_SECRETS"], "POLYCORE_SECRETS"),
|
|
202
|
-
}),
|
|
245
|
+
...(controlPlane === undefined ? {} : { controlPlane }),
|
|
246
|
+
...(secrets === undefined ? {} : { secrets }),
|
|
203
247
|
};
|
|
204
248
|
return RunnerConfigSchema.parse(candidate);
|
|
205
249
|
}
|
|
206
250
|
|
|
251
|
+
/**
|
|
252
|
+
* Overlay deploy-time secrets from the environment onto a **file-loaded**
|
|
253
|
+
* config: the committed config holds only the code-defined structure
|
|
254
|
+
* (projects, environments, actions, context), while the outbound link's creds
|
|
255
|
+
* (`controlPlane`) and runner-level action `secrets` arrive as env vars from a
|
|
256
|
+
* secret manager and are never written to the repo. Env only *fills* what the
|
|
257
|
+
* file left unset; a value already present in the file always wins.
|
|
258
|
+
*
|
|
259
|
+
* This is the counterpart to {@link loadRunnerConfig} for the production path:
|
|
260
|
+
* bake the structural config into the image, inject secrets as env at runtime.
|
|
261
|
+
*/
|
|
262
|
+
export function overlayEnvSecrets(
|
|
263
|
+
config: RunnerConfig,
|
|
264
|
+
source: NodeJS.ProcessEnv,
|
|
265
|
+
): RunnerConfig {
|
|
266
|
+
const controlPlane = config.controlPlane ?? controlPlaneFromEnv(source);
|
|
267
|
+
const secrets = config.secrets ?? secretsFromEnv(source);
|
|
268
|
+
return {
|
|
269
|
+
...config,
|
|
270
|
+
...(controlPlane === undefined ? {} : { controlPlane }),
|
|
271
|
+
...(secrets === undefined ? {} : { secrets }),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Build the control-plane block from env vars, or `undefined` when none is set
|
|
277
|
+
* (so `runner list` can load actions in a container without enrollment creds).
|
|
278
|
+
* A *partial* block is passed through to the schema so it pinpoints exactly
|
|
279
|
+
* which field is missing rather than silently dropping the block.
|
|
280
|
+
*/
|
|
281
|
+
function controlPlaneFromEnv(
|
|
282
|
+
source: NodeJS.ProcessEnv,
|
|
283
|
+
): ControlPlaneConfig | undefined {
|
|
284
|
+
const url = source["POLYCORE_CONTROL_PLANE_URL"];
|
|
285
|
+
const runnerId = source["POLYCORE_RUNNER_ID"];
|
|
286
|
+
const joinToken = source["POLYCORE_JOIN_TOKEN"];
|
|
287
|
+
const signingSecret = source["POLYCORE_SIGNING_SECRET"];
|
|
288
|
+
if (
|
|
289
|
+
url === undefined &&
|
|
290
|
+
runnerId === undefined &&
|
|
291
|
+
joinToken === undefined &&
|
|
292
|
+
signingSecret === undefined
|
|
293
|
+
) {
|
|
294
|
+
return undefined;
|
|
295
|
+
}
|
|
296
|
+
return ControlPlaneConfigSchema.parse({
|
|
297
|
+
url,
|
|
298
|
+
runnerId,
|
|
299
|
+
joinToken,
|
|
300
|
+
signingSecret,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const runnerSecretsSchema = z.record(z.string(), z.string());
|
|
305
|
+
|
|
306
|
+
/** Runner-level action secrets from `POLYCORE_SECRETS`, or `undefined`. */
|
|
307
|
+
function secretsFromEnv(
|
|
308
|
+
source: NodeJS.ProcessEnv,
|
|
309
|
+
): Record<string, string> | undefined {
|
|
310
|
+
const raw = source["POLYCORE_SECRETS"];
|
|
311
|
+
if (raw === undefined) return undefined;
|
|
312
|
+
return runnerSecretsSchema.parse(parseJsonEnv(raw, "POLYCORE_SECRETS"));
|
|
313
|
+
}
|
|
314
|
+
|
|
207
315
|
function parseJsonEnv(raw: string, name: string): unknown {
|
|
208
316
|
try {
|
|
209
317
|
return JSON.parse(raw) as unknown;
|
package/src/connect.ts
CHANGED
|
@@ -47,6 +47,9 @@ export function describeProjects(
|
|
|
47
47
|
slug,
|
|
48
48
|
environments: Object.keys(project.environments),
|
|
49
49
|
connectors: describeConnectors(registry),
|
|
50
|
+
...(project.defaultEnvironment === undefined
|
|
51
|
+
? {}
|
|
52
|
+
: { defaultEnvironment: project.defaultEnvironment }),
|
|
50
53
|
...(context === undefined ? {} : { context }),
|
|
51
54
|
};
|
|
52
55
|
});
|
package/src/index.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
1
4
|
import type { LoadedAction } from "./action.js";
|
|
2
5
|
import type { ProjectConfig, RunnerConfig } from "./config.js";
|
|
3
6
|
import {
|
|
@@ -30,6 +33,7 @@ export {
|
|
|
30
33
|
FirestoreEnvironmentSchema,
|
|
31
34
|
hasEnvConfig,
|
|
32
35
|
loadRunnerConfig,
|
|
36
|
+
overlayEnvSecrets,
|
|
33
37
|
parseRunnerConfig,
|
|
34
38
|
ProjectConfigSchema,
|
|
35
39
|
resolveEnvironment,
|
|
@@ -102,20 +106,53 @@ export function createRegistry(
|
|
|
102
106
|
return registry;
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
/** The conventional directory customer actions live in, beside the config. */
|
|
110
|
+
export const ACTIONS_DIRNAME = "actions";
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Resolve a project's actions directory by convention, relative to the config
|
|
114
|
+
* file's directory (`baseDir`): a project's actions live in `actions/<slug>/`,
|
|
115
|
+
* and when the runner serves a single project they may live directly in
|
|
116
|
+
* `actions/`. Returns `undefined` when neither exists (a project with no
|
|
117
|
+
* actions, or an env-driven config with no file directory) — the runner then
|
|
118
|
+
* serves only the built-in connectors. There is no `actionsDir` config field:
|
|
119
|
+
* the layout is a fixed convention, exactly like the desktop `src/actions/`.
|
|
120
|
+
*/
|
|
121
|
+
export function resolveActionsDir(
|
|
122
|
+
baseDir: string,
|
|
123
|
+
slug: string,
|
|
124
|
+
projectCount: number,
|
|
125
|
+
): string | undefined {
|
|
126
|
+
const namespaced = join(baseDir, ACTIONS_DIRNAME, slug);
|
|
127
|
+
if (existsSync(namespaced)) return namespaced;
|
|
128
|
+
const shared = join(baseDir, ACTIONS_DIRNAME);
|
|
129
|
+
if (projectCount === 1 && existsSync(shared)) return shared;
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
|
|
105
133
|
/**
|
|
106
134
|
* Build one registry per configured project: built-in connectors filtered
|
|
107
135
|
* by what the project's environments configure, plus the project's own
|
|
108
136
|
* actions with its secrets (project secrets win over runner-level secrets;
|
|
109
137
|
* both fall back to `process.env` at dispatch).
|
|
138
|
+
*
|
|
139
|
+
* `actionsBaseDir` is the config file's directory; actions are discovered from
|
|
140
|
+
* the `actions/` convention under it (see {@link resolveActionsDir}). Omit it
|
|
141
|
+
* for an env-driven config with no file location — the runner then serves only
|
|
142
|
+
* the built-in connectors.
|
|
110
143
|
*/
|
|
111
144
|
export async function buildProjectRegistries(
|
|
112
145
|
config: RunnerConfig,
|
|
146
|
+
actionsBaseDir?: string,
|
|
113
147
|
): Promise<Map<string, ConnectorRegistry>> {
|
|
114
148
|
const registries = new Map<string, ConnectorRegistry>();
|
|
149
|
+
const projectCount = Object.keys(config.projects).length;
|
|
115
150
|
for (const [slug, project] of Object.entries(config.projects)) {
|
|
116
|
-
const
|
|
117
|
-
|
|
118
|
-
|
|
151
|
+
const dir =
|
|
152
|
+
actionsBaseDir === undefined
|
|
153
|
+
? undefined
|
|
154
|
+
: resolveActionsDir(actionsBaseDir, slug, projectCount);
|
|
155
|
+
const actions = dir ? await loadActions(dir) : [];
|
|
119
156
|
registries.set(
|
|
120
157
|
slug,
|
|
121
158
|
createRegistry({
|