@parity/product-lab 0.1.2 → 0.3.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.
Files changed (37) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/adapters/environment/engine/README.md +22 -3
  3. package/adapters/environment/engine/src/engine/engine-config.d.ts +1 -0
  4. package/adapters/environment/engine/src/engine/fork-bundle.d.ts +1 -1
  5. package/adapters/environment/engine/src/engine/fork-bundle.js +2 -2
  6. package/adapters/environment/engine/src/engine/fork-refresh.d.ts +4 -7
  7. package/adapters/environment/engine/src/engine/fork-refresh.js +52 -23
  8. package/adapters/environment/engine/src/engine/published-bundle.d.ts +19 -15
  9. package/adapters/environment/engine/src/engine/published-bundle.js +44 -51
  10. package/adapters/environment/engine/src/index.d.ts +3 -2
  11. package/adapters/environment/engine/src/index.js +3 -2
  12. package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.d.ts +1 -1
  13. package/adapters/environment/engine/src/saved-fork/saved-fork-tooling.js +5 -2
  14. package/adapters/environment/engine/src/saved-fork/saved-fork.js +1 -1
  15. package/adapters/environment/engine/src/setup/engine-build.d.ts +4 -4
  16. package/adapters/environment/engine/src/setup/engine-build.js +14 -4
  17. package/adapters/environment/engine/src/setup/engine-tooling.d.ts +33 -0
  18. package/adapters/environment/engine/src/setup/engine-tooling.js +59 -0
  19. package/adapters/environment/engine/src/setup/node-workspace.patch +403 -0
  20. package/adapters/environment/engine/src/setup/patch-catalogue.d.ts +23 -0
  21. package/adapters/environment/engine/src/setup/patch-catalogue.js +8 -0
  22. package/adapters/environment/engine/src/setup/patches.d.ts +15 -1
  23. package/adapters/environment/engine/src/setup/patches.js +17 -2
  24. package/adapters/environment/engine/src/setup/prepare-engine.d.ts +6 -5
  25. package/adapters/environment/engine/src/setup/prepare-engine.js +27 -10
  26. package/composition/README.md +6 -4
  27. package/composition/src/engine-setup.js +9 -3
  28. package/composition/src/index.d.ts +2 -1
  29. package/composition/src/index.js +2 -1
  30. package/composition/src/people-dev/onboarding.d.ts +17 -3
  31. package/composition/src/people-dev/onboarding.js +25 -9
  32. package/composition/src/previewnet-genesis.d.ts +62 -0
  33. package/composition/src/previewnet-genesis.js +127 -0
  34. package/core/application/src/runs/failure-rules.js +5 -2
  35. package/entrypoints/cli/src/setup-commands.d.ts +1 -1
  36. package/entrypoints/cli/src/setup-commands.js +3 -1
  37. package/package.json +1 -1
@@ -231,7 +231,7 @@ export function savedForkEnvironment(fork, settings) {
231
231
  dashboard: `http://127.0.0.1:${ports.DASHBOARD_PORT}`, extraPath: engine.extraPath,
232
232
  startupTimeoutMs: SAVED_FORK_STARTUP_MS, ports, nodePorts: { from: block.base + 900, to: block.base + 949 },
233
233
  template: true, isolate: true, expose: false, ...(fork.topology ? { forkTopology: fork.topology } : {}),
234
- ...(recordedPatch(prepared.build) === "none" ? { enginePatch: "none" } : {}),
234
+ enginePatch: recordedPatch(prepared.build),
235
235
  ...(settings.txpoolLog ? { txpoolLog: settings.txpoolLog } : {}), ...(settings.bulletinAuthoring ? { bulletinAuthoring: settings.bulletinAuthoring } : {}),
236
236
  seed: networkSeedDirectory(settings.seedsDir, { network: NETWORK, checkout: engine.checkout, ...(fork.topology ? { topology: fork.topology } : {}) }),
237
237
  });
@@ -3,11 +3,11 @@ import type { SyncCommandRunner } from "@parity/product-lab/ports/system";
3
3
  export declare const NODE_VERIFIER_OPTION = "--node-verifier";
4
4
  export type NodeVerifier = "metric" | "none";
5
5
  export declare function nodeVerifierArgs(verifier: NodeVerifier | undefined): string[];
6
- /** The old value is recognized only to refuse stale configurations with a repair command. */
7
- export declare const ENGINE_PATCHES: readonly ["owned-processes", "none"];
6
+ /** Stock and the optional shipped fix (node-workspace); owned-processes is recognized only to refuse stale configurations. */
7
+ export declare const ENGINE_PATCHES: readonly ["owned-processes", "none", "node-workspace"];
8
8
  export type EnginePatch = (typeof ENGINE_PATCHES)[number];
9
9
  export declare const ENGINE_PATCH_RETIRED = "the owned-processes Engine patch is retired; prepare stock Engine v0.11.0 instead";
10
10
  /** The build must exist. The prepared checkout's pin and record are checked by preparedEngine. */
11
- export declare function engineBuildIssue(checkout: string, _commands: SyncCommandRunner, patch?: EnginePatch): string | undefined;
12
- /** Before every start, refuse retired patched configurations and incomplete builds. */
11
+ export declare function engineBuildIssue(checkout: string, commands: SyncCommandRunner, patch?: EnginePatch): string | undefined;
12
+ /** Before every start, verify the optional patch and refuse retired configurations and incomplete builds. */
13
13
  export declare function requireEngineBuild(checkout: string, commands: SyncCommandRunner, patch?: EnginePatch): void;
@@ -2,19 +2,29 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { shippedEnginePatch } from "./patches.js";
6
+ import { NODE_WORKSPACE_PATCH } from "./patch-catalogue.js";
5
7
  import { engineSetupCommand } from "./setup-command.js";
6
8
  export const NODE_VERIFIER_OPTION = "--node-verifier";
7
9
  export function nodeVerifierArgs(verifier) {
8
10
  return verifier ? [NODE_VERIFIER_OPTION, verifier] : [];
9
11
  }
10
- export const ENGINE_PATCHES = ["owned-processes", "none"];
12
+ export const ENGINE_PATCHES = ["owned-processes", "none", "node-workspace"];
11
13
  export const ENGINE_PATCH_RETIRED = "the owned-processes Engine patch is retired; prepare stock Engine v0.11.0 instead";
12
- export function engineBuildIssue(checkout, _commands, patch = "none") {
13
- if (patch !== "none")
14
+ export function engineBuildIssue(checkout, commands, patch = "none") {
15
+ if (patch !== "none" && patch !== "node-workspace")
14
16
  return ENGINE_PATCH_RETIRED;
17
+ if (patch === "node-workspace") {
18
+ const resolved = shippedEnginePatch(patch);
19
+ if (resolved.sha256 !== NODE_WORKSPACE_PATCH.sha256 || resolved.base !== NODE_WORKSPACE_PATCH.base)
20
+ return "node-workspace patch differs from its tested catalogue";
21
+ const check = commands.run({ command: "git", args: ["apply", "--reverse", "--check", resolved.path], cwd: checkout, timeoutMs: 30_000 });
22
+ if (check.code !== 0)
23
+ return "node-workspace patch is missing or changed in the Engine checkout";
24
+ }
15
25
  for (const file of ["packages/cli/dist/commands/start.js", "packages/cli/dist/cli.js"])
16
26
  if (!existsSync(join(checkout, file)))
17
- return `the stock Engine checkout at ${checkout} is not built (no ${file})`;
27
+ return `the ${patch === "none" ? "stock" : "patched"} Engine checkout at ${checkout} is not built (no ${file})`;
18
28
  return undefined;
19
29
  }
20
30
  export function requireEngineBuild(checkout, commands, patch = "none") {
@@ -0,0 +1,33 @@
1
+ import type { EnvironmentTooling } from "@parity/product-lab/ports/environment";
2
+ import type { SyncCommandRunner } from "@parity/product-lab/ports/system";
3
+ import type { EnginePatch } from "./engine-build.js";
4
+ import { type PreparedEngine, type PreparedEnginePatch } from "./prepare-engine.js";
5
+ /** What an Engine checkout tooling prepares, and where. */
6
+ export interface EngineCheckoutSettings {
7
+ /** The state directory it is prepared in (engine-build.json, engine.json and `tooling/<key>/`): its own. */
8
+ readonly state: string;
9
+ /** Engine's network whose binaries its fetch downloads. */
10
+ readonly network: "previewnet" | "paseo-next-v2";
11
+ /** The build an environment starts from: stock (`none`) or with the shipped node-workspace patch. */
12
+ readonly patch: Exclude<EnginePatch, "owned-processes">;
13
+ /** The lab's id of the environment that starts from it, for the command that prepares it. */
14
+ readonly environment: string;
15
+ /** The command that prepares it, as messages name it. Default: `engineFetchCommand(environment)`. */
16
+ readonly setupCommand?: string;
17
+ }
18
+ /**
19
+ * The command that prepares an environment's own tooling: `product-lab fetch --environment <id>`, through the checkout's
20
+ * CLI script there (`bun run setup` prepares only the copies' stock Engine), through npx in a product.
21
+ */
22
+ export declare function engineFetchCommand(environment: string, checkout?: boolean): string;
23
+ /**
24
+ * The prepared checkout in `settings.state`, as `patch` names it (default `settings.patch`), or `EngineNotPrepared`
25
+ * naming the fix. `ref`: also check that commit (default the tested release; `as-prepared` takes the recorded one).
26
+ */
27
+ export declare function preparedEngineCheckout(settings: EngineCheckoutSettings, commands: SyncCommandRunner, patch?: PreparedEnginePatch, ref?: string): PreparedEngine;
28
+ /**
29
+ * The Engine checkout `settings` names, as an environment's tooling: always the build `settings.patch` names, which is
30
+ * the one its environment starts from (`preparedEngineCheckout(settings, commands, settings.patch)`). Another repository
31
+ * or ref may be asked for; another build may not (`--no-engine-patch` for a patched one is refused, naming why).
32
+ */
33
+ export declare function engineCheckoutTooling(settings: EngineCheckoutSettings): EnvironmentTooling;
@@ -0,0 +1,59 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { defaultEngineRepository, EngineNotPrepared, prepareEngine, preparedEngine, testedEngineRef } from "./prepare-engine.js";
4
+ import { engineBuildWords } from "../saved-fork/saved-fork-tooling.js";
5
+ import { insideProductLabCheckout } from "./setup-command.js";
6
+ export function engineFetchCommand(environment, checkout = insideProductLabCheckout()) {
7
+ return checkout ? `bun run product-lab fetch --config <lab.config.ts> --environment ${environment}` : `npx --no-install product-lab fetch --environment ${environment}`;
8
+ }
9
+ const fixOf = (settings) => settings.setupCommand ?? engineFetchCommand(settings.environment);
10
+ export function preparedEngineCheckout(settings, commands, patch = settings.patch, ref) {
11
+ return preparedEngine(settings.state, { stateDirectory: ".", fix: fixOf(settings), commands, patch, ...(ref ? { ref } : {}) });
12
+ }
13
+ function syncCommands(system) {
14
+ if (!system.syncCommands)
15
+ throw new Error("The Engine checkout is checked through the lab's sync command adapter (SystemPorts.syncCommands), which this lab lacks");
16
+ return system.syncCommands;
17
+ }
18
+ export function engineCheckoutTooling(settings) {
19
+ const status = (commands, patch, ref) => {
20
+ try {
21
+ return { ready: true, detail: engineBuildWords(preparedEngineCheckout(settings, commands, patch, ref)) };
22
+ }
23
+ catch (error) {
24
+ if (!(error instanceof EngineNotPrepared))
25
+ throw error;
26
+ return { ready: false, detail: error.reason, fix: fixOf(settings) };
27
+ }
28
+ };
29
+ return {
30
+ id: `engine:${settings.state}`,
31
+ kind: "engine",
32
+ label: `Engine checkout${settings.patch === "none" ? "" : ` (${settings.patch} patch)`}`,
33
+ check: (system) => status(syncCommands(system), settings.patch),
34
+ async prepare(request) {
35
+ const commands = syncCommands(request.system);
36
+ const source = request.source ?? {};
37
+ const patch = settings.patch;
38
+ if (source.patched === false && patch !== "none")
39
+ throw new Error(`The ${settings.environment} environment starts from Engine with the ${patch} patch, so it cannot be prepared without it (--no-engine-patch)`);
40
+ const ref = source.ref ?? testedEngineRef;
41
+ const pinned = /^[0-9a-f]{40}$/i.test(ref);
42
+ const already = pinned ? status(commands, patch, ref) : undefined;
43
+ if (already?.ready) {
44
+ request.log?.(`Engine checkout: already prepared (${already.detail})`);
45
+ return { ...already, prepared: false };
46
+ }
47
+ request.log?.(`Engine ${ref.slice(0, 12)} from ${source.repository ?? defaultEngineRepository}, ${patch === "none" ? "stock (no lab patch)" : `with the ${patch} patch`}, into ${settings.state}: ` +
48
+ "clone, pnpm install and build, Engine's fetch of the network's binaries, the pinned dot CLI (the first time: a few minutes and about 900 MB)");
49
+ const homebrewPrefix = request.system.environment?.get("HOMEBREW_PREFIX");
50
+ prepareEngine(settings.state, ref, settings.network, commands, ".", { platform: process.platform, ...(homebrewPrefix ? { homebrewPrefix } : {}) }, {
51
+ ...(source.repository ? { repository: source.repository } : {}),
52
+ patch,
53
+ ...(request.output ? { output: request.output } : {}),
54
+ });
55
+ const after = status(commands, patch, pinned ? ref : undefined);
56
+ return { ...after, prepared: after.ready };
57
+ },
58
+ };
59
+ }
@@ -0,0 +1,403 @@
1
+ diff --git a/packages/cli/src/lib/spawn-env.ts b/packages/cli/src/lib/spawn-env.ts
2
+ index 90205fb..ae56253 100644
3
+ --- a/packages/cli/src/lib/spawn-env.ts
4
+ +++ b/packages/cli/src/lib/spawn-env.ts
5
+ @@ -1,169 +1,178 @@
6
+ // The spawn environment `ppn start` states, in one place.
7
+ //
8
+ // zombienet forwards no environment to custom processes, so everything the dashboard,
9
+ // eth-rpc, the dub stack and the storage provider cannot derive has to be *stated*.
10
+ // `ppn start` states it three times: config/ports.local.env (read by custom processes),
11
+ // zombie-cli's child environment (which the configs' {{BIN}} and {{SCRIPTS}} are filled from),
12
+ // and each custom process's own `env` in the config it spawns (customProcessEnv), the only
13
+ // environment zombie-cli hands them. A deployment that spawns zombie-cli directly never runs
14
+ // `ppn start`, so it has to restate the same facts itself.
15
+ //
16
+ // This module is the canonical statement, and the contract a deployment implements: it is
17
+ // exported as `@parity/ppn/spawn-env` so the restating side can assert against the key lists
18
+ // rather than keeping its own copy in step by hand.
19
+
20
+ import path from 'node:path';
21
+
22
+ /** Facts config/ports.local.env states for zombienet's custom processes. */
23
+ export interface LocalEnvFacts {
24
+ /** The network this run resolved to, so every service resolves the same one. */
25
+ network: string;
26
+ /** The data dir this run writes to — per-mode (data/ vs data-fork/). */
27
+ dataDir: string;
28
+ ephemeral: boolean;
29
+ /** Where the identity postgres cluster keeps its data. */
30
+ identityDataDir: string;
31
+ }
32
+
33
+ /** The gitignored file `ppn start` writes; every service overlays it on top of ports.env. */
34
+ export function localEnvContent(f: LocalEnvFacts): string {
35
+ return (
36
+ '# Generated by `ppn start` — gitignored, do not edit.\n' +
37
+ `WEB3_STORAGE_PROVIDER_STORAGE_MODE=${f.ephemeral ? 'inmemory' : 'disk'}\n` +
38
+ `DUB_POSTGRES_DATA_DIR=${f.identityDataDir}\n` +
39
+ `PPN_NETWORK=${f.network}\n` +
40
+ // An ephemeral run keeps its chains in a temp dir, so PPN_DATA_DIR below holds an earlier
41
+ // run's files, which the services that read the data dir must not take for this one's.
42
+ `PPN_EPHEMERAL=${f.ephemeral ? '1' : '0'}\n` +
43
+ // zombienet forwards no env to custom processes, and the data dir is per-mode
44
+ // (data/ vs data-fork/): without this the dashboard reads the *other* mode's
45
+ // spawn stamp and logs — a fork reporting itself as yesterday's genesis.
46
+ `PPN_DATA_DIR=${f.dataDir}\n`
47
+ );
48
+ }
49
+
50
+ /** Facts `ppn start` puts into zombie-cli's environment (inherited by node-level procs). */
51
+ export interface ChildEnvFacts {
52
+ binDir: string;
53
+ /** The workspace this run resolved to, so the launchers do not resolve it again. */
54
+ workspace: string;
55
+ scriptsDir: string;
56
+ dataDir: string;
57
+ ephemeral: boolean;
58
+ }
59
+
60
+ export function childEnv(f: ChildEnvFacts): Record<string, string> {
61
+ return {
62
+ BIN: f.binDir,
63
+ // zombienet forwards node-level env but gives custom processes no HOME, which is the
64
+ // one thing the shell fallback would need — so the resolved workspace travels here.
65
+ PPN_HOME: f.workspace,
66
+ SCRIPTS: f.scriptsDir,
67
+ DATA_DIR: f.dataDir,
68
+ EPHEMERAL: f.ephemeral ? '1' : '',
69
+ };
70
+ }
71
+
72
+ /**
73
+ * Facts every custom process is handed in its own `env` (custom-process-env): zombie-cli starts
74
+ * nodes and custom processes with an empty environment but TZ, LANG, PATH and what the TOML
75
+ * gives each one, so neither zombie-cli's environment (childEnv) nor the shell's reaches them.
76
+ * Without PPN_HOME a launcher falls back to the checkout it sits in, or ~/.ppn
77
+ * (scripts/lib/workspace.sh), and reads that workspace's ports.local.env, binaries and data
78
+ * instead of this run's.
79
+ */
80
+ export function customProcessEnv(f: { workspace: string; network: string; dataDir: string }): Record<string, string> {
81
+ return { PPN_HOME: f.workspace, PPN_NETWORK: f.network, PPN_DATA_DIR: f.dataDir };
82
+ }
83
+
84
+ const SECTION = /^\s*\[\[?\s*([\w.-]+)\s*\]\]?\s*(#.*)?$/;
85
+
86
+ /**
87
+ * `toml` with `env` added to every `[[custom_processes]]` block, before any entry the block
88
+ * already has; an entry the block names itself is kept, not repeated. Other sections are left
89
+ * alone. Line-based, for the configs this repository generates and any hand-written one in the
90
+ * same shape: one key per line, `env = [` opening its array on the key's own line.
91
+ */
92
+ export function withCustomProcessEnv(toml: string, env: Record<string, string>): string {
93
+ + return withProcessEnv(toml, env, ['custom_processes']);
94
+ +}
95
+ +
96
+ +/** Node processes also start with a stripped environment; preserve each node's explicit overrides. */
97
+ +export function withNodeProcessEnv(toml: string, env: Record<string, string>): string {
98
+ + return withProcessEnv(toml, env, ['relaychain.nodes', 'parachains.collators']);
99
+ +}
100
+ +
101
+ +function withProcessEnv(toml: string, env: Record<string, string>, sections: string[]): string {
102
+ const lines = toml.split('\n');
103
+ const out: string[] = [];
104
+ const entry = (name: string, value: string) => `{ name = ${JSON.stringify(name)}, value = ${JSON.stringify(value)} }`;
105
+ let i = 0;
106
+ while (i < lines.length) {
107
+ const header = SECTION.exec(lines[i]);
108
+ out.push(lines[i++]);
109
+ - if (!header || header[1] !== 'custom_processes' || !lines[i - 1].trim().startsWith('[[')) continue;
110
+ + if (!header || !sections.includes(header[1]) || !lines[i - 1].trim().startsWith('[[')) continue;
111
+ // The block runs to the next section header.
112
+ const start = i;
113
+ while (i < lines.length && !SECTION.test(lines[i])) i++;
114
+ const block = lines.slice(start, i);
115
+ const named = new Set([...block.join('\n').matchAll(/\{\s*name\s*=\s*"([^"]+)"/g)].map((m) => m[1]));
116
+ const missing = Object.entries(env).filter(([name]) => !named.has(name)).map(([n, v]) => entry(n, v));
117
+ const at = block.findIndex((line) => /^\s*env\s*=\s*\[/.test(line));
118
+ if (missing.length === 0) {
119
+ out.push(...block);
120
+ } else if (at === -1) {
121
+ - // No env yet: after the block's last key, before the blank lines that separate it.
122
+ + // No env yet: after the last key, before trailing comments for the next section.
123
+ let last = block.length;
124
+ - while (last > 0 && block[last - 1].trim() === '') last--;
125
+ + while (last > 0 && /^\s*(?:#.*)?$/.test(block[last - 1])) last--;
126
+ out.push(...block.slice(0, last), `env = [${missing.join(', ')}]`, ...block.slice(last));
127
+ } else {
128
+ const open = block[at].indexOf('[') + 1;
129
+ const rest = block[at].slice(open).trim();
130
+ if (rest === '') {
131
+ // Multi-line array: one entry per line, as the generators write them.
132
+ out.push(...block.slice(0, at + 1), ...missing.map((e) => ` ${e},`), ...block.slice(at + 1));
133
+ } else {
134
+ const joined = rest.startsWith(']') ? missing.join(', ') : `${missing.join(', ')}, `;
135
+ out.push(...block.slice(0, at), `${block[at].slice(0, open)}${joined}${rest}`, ...block.slice(at + 1));
136
+ }
137
+ }
138
+ }
139
+ return out.join('\n');
140
+ }
141
+
142
+ /** Where `ppn start` writes the config it spawns: beside the one it was made from, so relative paths still resolve. */
143
+ export function spawnTomlFor(source: string): string {
144
+ return path.join(path.dirname(source), `${path.basename(source, '.toml')}.spawn.toml`);
145
+ }
146
+
147
+ const DUMMY_LOCAL: LocalEnvFacts = { network: 'x', dataDir: 'x', ephemeral: false, identityDataDir: 'x' };
148
+ const DUMMY_CHILD: ChildEnvFacts = { binDir: 'x', workspace: 'x', scriptsDir: 'x', dataDir: 'x', ephemeral: false };
149
+
150
+ /** Keys `ppn start` writes into config/ports.local.env, derived from the real writer. */
151
+ export function localEnvKeys(): string[] {
152
+ return [...localEnvContent(DUMMY_LOCAL).matchAll(/^([A-Z_][A-Z0-9_]*)=/gm)].map((m) => m[1]);
153
+ }
154
+
155
+ /** Keys `ppn start` puts into zombie-cli's environment, derived from the real builder. */
156
+ export function childEnvKeys(): string[] {
157
+ return Object.keys(childEnv(DUMMY_CHILD));
158
+ }
159
+
160
+ /** Every key a spawn receives through `ppn start`, deduplicated. */
161
+ export function spawnEnvKeys(): string[] {
162
+ return [...new Set([...localEnvKeys(), ...childEnvKeys()])];
163
+ }
164
+
165
+ /**
166
+ * Keys the server path deliberately does not supply. Each needs a reason, and the reason has
167
+ * to be about the *server*, not about effort.
168
+ */
169
+ export const WAIVED: Record<string, string> = {
170
+ // A server persists by definition; ephemeral is a laptop mode. Nothing reads it there.
171
+ EPHEMERAL: 'ephemeral is a local-only mode; a deployment always persists',
172
+
173
+ // KNOWN GAP, deliberately recorded rather than silently waived.
174
+ //
175
+ // `ppn start` sets it so every service resolves the network the user asked for.
176
+ // On a server nothing sets it, and currentNetworkName() (network-config/src/networks.ts)
177
+ // falls back to previewnet. Staging and prod run previewnet, so this is *accidentally*
178
+ // correct today and breaks nothing — but the first non-previewnet deployment would have
179
+ // every service resolve previewnet's chains, ports and bin directory instead.
180
+ //
181
+ // Not fixed here because the fix belongs with `ppn start --server` (see docs/DASHBOARD.md,
182
+ // "Why a server does not run `ppn start`"), which makes one path the sole author of all of
183
+ // this. Until then it is written down, and the conformance test keeps it from being
184
+ // forgotten.
185
+ PPN_NETWORK: 'KNOWN GAP: falls back to previewnet, which is what servers run today',
186
+ };
187
+ diff --git a/scripts/lib/workspace.sh b/scripts/lib/workspace.sh
188
+ index acb15c9..2ae2002 100755
189
+ --- a/scripts/lib/workspace.sh
190
+ +++ b/scripts/lib/workspace.sh
191
+ @@ -1,47 +1,67 @@
192
+ #!/usr/bin/env bash
193
+ # Where the workspace is — the one rule, shared by every launcher. Mirrors workspaceRoot()
194
+ # in @parity/ppn-network-config: $PPN_HOME, else the nearest ancestor of the cwd with
195
+ # networks/, else a checkout this script sits in, else the per-user home. In a checkout all
196
+ # of these agree; installed from npm the script lives in node_modules, whose bin/ does not
197
+ # exist — five launchers each grew their own partial copy of this logic and each broke a
198
+ # different way, which is why it now lives here once.
199
+ #
200
+ # zombienet runs custom processes with a stripped environment (no HOME), so the last resort
201
+ # derives the home from the owner of this file — the user whose `ppn fetch` filled ~/.ppn.
202
+ #
203
+ # Usage: source "$(dirname "${BASH_SOURCE[0]}")/lib/workspace.sh" (adjust depth)
204
+ # Sets: PPN_WS the workspace root
205
+ # PPN_WS_BIN its bin/ (callers append /<network> for non-previewnet binaries)
206
+
207
+ ppn_workspace_root() {
208
+ if [[ -n "${PPN_HOME:-}" ]]; then
209
+ echo "$PPN_HOME"; return
210
+ fi
211
+ + # A native node can be launched outside `ppn start` with no environment at all.
212
+ + # zombienet's absolute base path is <workspace>/{data,data-fork}/<node>/data.
213
+ + # Only callers explicitly passing node args use this fallback; stop at the relay-side `--`.
214
+ + local arg base="" previous="" candidate layout
215
+ + for arg in "$@"; do
216
+ + [[ "$arg" == "--" ]] && break
217
+ + case "$previous" in --base-path|-d) base="$arg" ;; esac
218
+ + case "$arg" in --base-path=*) base="${arg#--base-path=}" ;; esac
219
+ + [[ -n "$base" ]] && break
220
+ + previous="$arg"
221
+ + done
222
+ + if [[ "$base" == /*/data ]]; then
223
+ + candidate="${base%/data}"
224
+ + candidate="${candidate%/*}"
225
+ + layout="${candidate##*/}"
226
+ + candidate="${candidate%/*}"
227
+ + if [[ -n "$candidate" && ( "$layout" == "data" || "$layout" == "data-fork" ) ]] && [[ -d "$candidate/bin" ]]; then
228
+ + echo "$candidate"; return
229
+ + fi
230
+ + fi
231
+ local dir="$PWD"
232
+ for _ in 1 2 3 4 5 6 7 8; do
233
+ [[ -d "$dir/networks" ]] && { echo "$dir"; return; }
234
+ [[ "$dir" == "/" ]] && break
235
+ dir="$(dirname "$dir")"
236
+ done
237
+ # A checkout: this helper sits at <root>/scripts/lib/workspace.sh, and a checkout has a
238
+ # fetched bin/. The npm package ships scripts/ too but never a bin/, so this rung
239
+ # distinguishes them.
240
+ local own; own="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
241
+ [[ -d "$own/bin" ]] && { echo "$own"; return; }
242
+ # The per-user home.
243
+ if [[ -n "${XDG_DATA_HOME:-}" ]]; then
244
+ echo "$XDG_DATA_HOME/ppn"; return
245
+ fi
246
+ if [[ -n "${HOME:-}" ]]; then
247
+ echo "$HOME/.ppn"; return
248
+ fi
249
+ local owner
250
+ owner="$(stat -c '%U' "${BASH_SOURCE[0]}" 2>/dev/null)" \
251
+ || owner="$(stat -f '%Su' "${BASH_SOURCE[0]}" 2>/dev/null)" \
252
+ || owner=""
253
+ [[ "$owner" =~ ^[A-Za-z0-9._-]+$ ]] && { echo "$(eval echo "~$owner")/.ppn"; return; }
254
+ echo ""
255
+ }
256
+
257
+ PPN_WS="$(ppn_workspace_root)"
258
+ PPN_WS_BIN="$PPN_WS/bin"
259
+ diff --git a/scripts/omni-node.sh b/scripts/omni-node.sh
260
+ index b3cd73d..3bce0d9 100755
261
+ --- a/scripts/omni-node.sh
262
+ +++ b/scripts/omni-node.sh
263
+ @@ -1,112 +1,115 @@
264
+ #!/usr/bin/env bash
265
+ # Wrapper around polkadot-omni-node that forces the libp2p network backend on the
266
+ # collator's *relay-chain* side.
267
+ #
268
+ # Why this exists: a collator runs a relay-chain node in the same process, configured by
269
+ # the args after the `--` separator. zombienet owns those args and passes no
270
+ # `--network-backend`, so the relay side takes the default — litep2p — whose websocket
271
+ # listener dies a few seconds after startup with:
272
+ #
273
+ # litep2p::websocket: [Relaychain] Websocket listener terminated error=Kind(InvalidInput)
274
+ # sc_service::task_manager: [Relaychain] Essential task `network-worker` failed.
275
+ #
276
+ # `network-worker` is essential, so the whole collator exits, usually right after it has
277
+ # authored its first block. Verified on macOS arm64 with 1.24.0-2f2eeb2b81d: with the
278
+ # default backend three or four collators die on every start; with libp2p, none do.
279
+ #
280
+ # This is consistent rather than exceptional — PPN already passes --network-backend=libp2p
281
+ # to every relay validator (see CHAIN_ARGS.relay in @ppn/network-config). The
282
+ # relay side of a collator is the one relay node that was still getting the default.
283
+ #
284
+ # The flag is appended, which puts it in the relay-chain arg list, and only on a real node
285
+ # run: zombienet also invokes this binary for spec and key generation, where it would be
286
+ # rejected as an unexpected argument.
287
+ #
288
+ # Which binary is exec'd, and from where, comes from the environment (set per collator
289
+ # in the generated fork TOML — zombienet forwards node-level env):
290
+ # PPN_BIN_DIR bin directory; non-previewnet networks keep theirs in bin/<network>
291
+ # PPN_COLLATOR_BINARY e.g. polkadot-parachain (networks/<name>.json parachain `command`)
292
+ # The libp2p fix applies to any cumulus collator, whichever binary it is.
293
+ set -euo pipefail
294
+
295
+ # Where the binaries are is a property of the *workspace*, never of this script's location.
296
+ # `ppn fetch` writes them to the workspace's bin/ ($PPN_HOME, or the checkout); installed from
297
+ # npm this script lives in node_modules/@parity/ppn/scripts, whose ../bin does not exist at all
298
+ # — under `set -e` the old `cd .../../bin` ended the script before the fallback below could run.
299
+ # So: the explicit env first, then the workspace, and only then this script's own tree, which is
300
+ # the right answer in a checkout and a harmless miss anywhere else.
301
+ source "$(dirname "${BASH_SOURCE[0]}")/lib/workspace.sh"
302
+ +# Only the node launcher opts into base-path recovery; service args are never scanned.
303
+ +PPN_WS="$(ppn_workspace_root "$@")"
304
+ +PPN_WS_BIN="$PPN_WS/bin"
305
+ REPO_BIN="$PPN_WS_BIN"
306
+ # `ppn start` exports BIN as the directory this run resolved to — bin/ for previewnet,
307
+ # bin/<network> for everything else — and the probe below inherits it even though it gets
308
+ # none of the node-level env.
309
+ BIN_DIR="${PPN_BIN_DIR:-${BIN:-$REPO_BIN}}"
310
+ BINARY="${PPN_COLLATOR_BINARY:-polkadot-omni-node}"
311
+ BIN="$BIN_DIR/$BINARY"
312
+
313
+ # Fall back to bin/<network>/ when the resolved path has nothing in it.
314
+ #
315
+ # Before spawning anything, zombienet probes each node command to learn which arguments it
316
+ # accepts — and that probe runs *without* the node-level env, so PPN_BIN_DIR is unset and the
317
+ # lookup above lands on plain bin/. previewnet keeps its binaries there, so it works; every other
318
+ # network keeps them in bin/<network>/, and the probe fails with a bare
319
+ #
320
+ # NodeAvailableArgsError("", ".../scripts/omni-node.sh")
321
+ #
322
+ # which says nothing about a missing binary. It only shows up on a machine that has fetched one
323
+ # network and not previewnet — a fresh CI runner — so a developer whose bin/ still holds a
324
+ # previewnet fetch will never see it.
325
+ #
326
+ # The probe cannot know the *name* either: Kusama's collator is polkadot-parachain and it
327
+ # still probes as the default, which is how a completed bite died at spawn with the error
328
+ # above naming bin/polkadot-omni-node. A network's directory holds one collator, so
329
+ # whichever of the two is in there is the one this network runs.
330
+ if [[ ! -x "$BIN" ]]; then
331
+ dirs=("$BIN_DIR")
332
+ if [[ -n "$REPO_BIN" ]]; then
333
+ [[ -n "${PPN_NETWORK:-}" ]] && dirs+=("$REPO_BIN/$PPN_NETWORK")
334
+ dirs+=("$REPO_BIN" "$REPO_BIN"/*/)
335
+ fi
336
+ for dir in "${dirs[@]}"; do
337
+ for name in "$BINARY" polkadot-omni-node polkadot-parachain; do
338
+ if [[ -x "${dir%/}/$name" ]]; then
339
+ BIN="${dir%/}/$name"
340
+ break 2
341
+ fi
342
+ done
343
+ done
344
+ fi
345
+
346
+ # On a chain whose Aura is ed25519 (Polkadot's Asset Hub), zombienet's keystore has no key the
347
+ # collator can author with: it writes `aura` as sr25519 and files the ed25519 key under `gran`.
348
+ # Same seed, so inserting it under `aura` is all that is missing — without it the collator syncs,
349
+ # reports no error, and never proposes a block.
350
+ #
351
+ # Only on a real node run: the arg probe passes neither, and `key insert` on a half-set-up node
352
+ # would fail the probe rather than the spawn.
353
+ if [[ "${PPN_COLLATOR_AURA:-}" == "ed25519" ]]; then
354
+ base=""; chain=""
355
+ prev=""
356
+ for arg in "$@"; do
357
+ case "$prev" in
358
+ --base-path|-d) base="$arg" ;;
359
+ --chain) chain="$arg" ;;
360
+ esac
361
+ prev="$arg"
362
+ done
363
+ if [[ -n "$base" && -n "$chain" ]]; then
364
+ name="$(basename "$base")"
365
+ [[ "$name" == "data" ]] && name="$(basename "$(dirname "$base")")"
366
+ "$BIN" key insert --key-type aura --scheme ed25519 \
367
+ --suri "//$name" --chain "$chain" --base-path "$base" \
368
+ || echo "omni-node.sh: could not insert the ed25519 aura key for $name" >&2
369
+ fi
370
+ fi
371
+
372
+ for arg in "$@"; do
373
+ if [[ "$arg" == "--" ]]; then
374
+ exec "$BIN" "$@" --network-backend=libp2p --discover-local --allow-private-ip
375
+ fi
376
+ done
377
+
378
+ exec "$BIN" "$@"
379
+ diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts
380
+ index 3758d00..1f79646 100644
381
+ --- a/packages/cli/src/commands/start.ts
382
+ +++ b/packages/cli/src/commands/start.ts
383
+ @@ -24,7 +24,7 @@ import {
384
+ type NetworkDef,
385
+ } from '@parity/ppn-network-config';
386
+ import { readSpawnStamp, writeSpawnStamp, SPAWN_FILE } from '../lib/spawn-stamp.js';
387
+ -import { localEnvContent, childEnv, customProcessEnv, spawnTomlFor, withCustomProcessEnv } from '../lib/spawn-env.js';
388
+ +import { localEnvContent, childEnv, customProcessEnv, spawnTomlFor, withCustomProcessEnv, withNodeProcessEnv } from '../lib/spawn-env.js';
389
+ import { forkBundleName } from '../lib/fork-bundle-name.js';
390
+ import {
391
+ classifyGroup,
392
+ @@ -550,7 +550,10 @@ export async function start(args: string[], opts: StartOptions = {}): Promise<vo
393
+ const tomlFile = spawnTomlFor(sourceToml);
394
+ fs.writeFileSync(
395
+ tomlFile,
396
+ - withCustomProcessEnv(fs.readFileSync(sourceToml, 'utf-8'), customProcessEnv({ workspace: WS, network: name, dataDir }))
397
+ + withNodeProcessEnv(
398
+ + withCustomProcessEnv(fs.readFileSync(sourceToml, 'utf-8'), customProcessEnv({ workspace: WS, network: name, dataDir })),
399
+ + { PPN_HOME: WS, PPN_BIN_DIR: binDir, PPN_NETWORK: name }
400
+ + )
401
+ );
402
+
403
+ // Decided after ensureDeps, which is where a --fresh-bite produces the bundle the data is
@@ -18,3 +18,26 @@ export declare const LIGHT_TOPOLOGY_OVERLAY: {
18
18
  };
19
19
  /** The descriptor overlays a fork layout names (`ForkLayout.descriptors`), by manifest entry. */
20
20
  export declare const DESCRIPTOR_OVERLAYS: Readonly<Record<string, unknown>>;
21
+ /**
22
+ * The native-node workspace fix (patches/previewnet-engine/manifest.json, `node-workspace`): optional, stock Engine stays
23
+ * the default. Its file ships beside this catalogue (`file`, in this folder), byte for byte the repository's
24
+ * patches/previewnet-engine/node-workspace.patch, so an installed package prepares the same patched Engine as the
25
+ * checkout (`shippedEnginePatch`, patches.ts). This entry is its manifest entry in the package.
26
+ */
27
+ export declare const NODE_WORKSPACE_PATCH: {
28
+ readonly name: "node-workspace";
29
+ readonly file: "node-workspace.patch";
30
+ readonly repository: "https://github.com/paritytech/previewnet-engine.git";
31
+ readonly base: "e11792b234ca0c39a5fc1d47af491f82fc8e2670";
32
+ readonly sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757";
33
+ };
34
+ /** The Engine patches the package ships, by name (`EngineSource.patch`). */
35
+ export declare const SHIPPED_ENGINE_PATCHES: {
36
+ readonly "node-workspace": {
37
+ readonly name: "node-workspace";
38
+ readonly file: "node-workspace.patch";
39
+ readonly repository: "https://github.com/paritytech/previewnet-engine.git";
40
+ readonly base: "e11792b234ca0c39a5fc1d47af491f82fc8e2670";
41
+ readonly sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757";
42
+ };
43
+ };
@@ -13,3 +13,11 @@ export const OWNED_PROCESSES_PATCH = {
13
13
  };
14
14
  export const LIGHT_TOPOLOGY_OVERLAY = { relay: { validators: 4 } };
15
15
  export const DESCRIPTOR_OVERLAYS = { "light-topology": LIGHT_TOPOLOGY_OVERLAY };
16
+ export const NODE_WORKSPACE_PATCH = {
17
+ name: "node-workspace",
18
+ file: "node-workspace.patch",
19
+ repository: ENGINE_RELEASE.repository,
20
+ base: ENGINE_RELEASE.base,
21
+ sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757",
22
+ };
23
+ export const SHIPPED_ENGINE_PATCHES = { [NODE_WORKSPACE_PATCH.name]: NODE_WORKSPACE_PATCH };
@@ -9,7 +9,7 @@ declare const patchEntry: z.ZodObject<{
9
9
  export type PatchEntry = z.infer<typeof patchEntry>;
10
10
  /** Repository-level `patches/` directory, shared by every provider. */
11
11
  export declare const patchesDirectory: string;
12
- /** Why a patched Engine cannot be prepared or checked here: this installation carries no Engine patches. */
12
+ /** Why a repository patch's manifest cannot be read here: this installation carries no patches/ folder. */
13
13
  export declare const ENGINE_PATCHES_REPOSITORY_ONLY: string;
14
14
  /** A patch or manifest was asked for where patches/<upstream>/ does not exist (an installed package). */
15
15
  export declare class EnginePatchesNotInstalled extends Error {
@@ -34,4 +34,18 @@ export declare function resolvePatch(upstream: string, name: string): {
34
34
  base: string;
35
35
  repository: string;
36
36
  };
37
+ /** The Engine patch the package ships (adapters/environment/engine/src/setup/), read here and digest-checked. */
38
+ export declare const shippedPatchesDirectory: string;
39
+ /**
40
+ * One Engine patch the package ships (`SHIPPED_ENGINE_PATCHES`), from its file beside the catalogue, refused when the
41
+ * file differs from the catalogue's digest. The same file in product-lab's checkout and in an installed package;
42
+ * `directory` is for tests (a changed copy is refused).
43
+ */
44
+ export declare function shippedEnginePatch(name: string, directory?: string): {
45
+ name: string;
46
+ path: string;
47
+ sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757";
48
+ base: "e11792b234ca0c39a5fc1d47af491f82fc8e2670";
49
+ repository: "https://github.com/paritytech/previewnet-engine.git";
50
+ };
37
51
  export {};