@parity/product-lab 0.2.0 → 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 (27) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/adapters/environment/engine/README.md +2 -1
  3. package/adapters/environment/engine/src/index.d.ts +3 -2
  4. package/adapters/environment/engine/src/index.js +3 -2
  5. package/adapters/environment/engine/src/setup/engine-build.d.ts +1 -1
  6. package/adapters/environment/engine/src/setup/engine-build.js +2 -2
  7. package/adapters/environment/engine/src/setup/engine-tooling.d.ts +33 -0
  8. package/adapters/environment/engine/src/setup/engine-tooling.js +59 -0
  9. package/adapters/environment/engine/src/setup/node-workspace.patch +403 -0
  10. package/adapters/environment/engine/src/setup/patch-catalogue.d.ts +18 -1
  11. package/adapters/environment/engine/src/setup/patch-catalogue.js +3 -0
  12. package/adapters/environment/engine/src/setup/patches.d.ts +15 -1
  13. package/adapters/environment/engine/src/setup/patches.js +17 -2
  14. package/adapters/environment/engine/src/setup/prepare-engine.d.ts +1 -1
  15. package/adapters/environment/engine/src/setup/prepare-engine.js +2 -2
  16. package/composition/README.md +6 -4
  17. package/composition/src/engine-setup.js +9 -3
  18. package/composition/src/index.d.ts +2 -1
  19. package/composition/src/index.js +2 -1
  20. package/composition/src/people-dev/onboarding.d.ts +17 -3
  21. package/composition/src/people-dev/onboarding.js +25 -9
  22. package/composition/src/previewnet-genesis.d.ts +62 -0
  23. package/composition/src/previewnet-genesis.js +127 -0
  24. package/core/application/src/runs/failure-rules.js +5 -2
  25. package/entrypoints/cli/src/setup-commands.d.ts +1 -1
  26. package/entrypoints/cli/src/setup-commands.js +3 -1
  27. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @parity/product-lab
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b2abd7c: Add `previewnetGenesis()` to `@parity/product-lab/composition`: one line of a lab's config, `environments: { ...previewnetGenesis() }`, gives it "Previewnet from genesis" (`previewnet-genesis`), Previewnet's People, Asset Hub and Bulletin started from genesis on your machine by Engine, a new network every start. Once it is ready, and after every reset, People's rings are prepared for new people, so a new person can be onboarded on it like on the local copies. `product-lab fetch --environment previewnet-genesis` prepares its Engine and `product-lab doctor` checks it; it keeps its own folder in the cache, so your local copies' Engine is not changed. It binds Engine's default ports (relay 10000 to 10005, People 10010, Asset Hub 10020, Bulletin 10030, dashboard 8090, dub 8092, dub's database 5433, and Engine's other defaults in its config/ports.env: eth-rpc 8545, IPFS 8080, 5001 and 4001, TURN 3478, 3479 and its relay range 49152 to 49407, p2p 30333 to 30336), not the lab's port block: one Engine network per machine at a time; a port option is a planned follow-up.
8
+
9
+ The package now ships Engine's node-workspace patch, so `EngineSource.patch: "node-workspace"` works from an installed package instead of failing with `EnginePatchesNotInstalled`. The Engine setup script takes `--engine-patch node-workspace`. People's ring preparation is available on its own as `PeopleAttesterChain.prepareRings(collections)`, with `PEOPLE_LITE_COLLECTION`.
10
+
11
+ ### Patch Changes
12
+
13
+ - b2abd7c: A failed step is no longer explained by the folders or Node modules in its stack trace. The failure explanation's rules now read only the error's message, not its stack frames, so a check that fails in a checkout or product folder named like `previewnet-genesis` or `truapi`, or with frames in a `websocket.js` file or `node:events`, keeps the explanation its message gives, instead of "Infrastructure, not the product", a chain mismatch or a host refusal. The environment rule's bare words "genesis" and "baseline" are replaced by the environment errors they were meant for: endpoints that share a genesis, a fast baseline that is missing, for another machine or changed, an attached chain or Chopsticks upstream that is another chain, and a People whose genesis is not the network's. An assertion that mentions genesis, such as "account funded at genesis", is explained as the check's failure.
14
+
3
15
  ## 0.2.0
4
16
 
5
17
  ### Minor Changes
@@ -45,7 +45,8 @@ use to reach an Engine network. Imported from the original lab.
45
45
  | `topology/engine-plan.ts` | `engineLaunchPlan`: a resolved relay-backed `EngineTopology` as `ppn` commands (fork, `--fresh-bite`, `--upgrade`, `--runtime`, `--binary`, genesis subsets through `genesis-toml --parachains` then `start --toml`) and `config/ports.env` keys |
46
46
  | `topology/engine-topology.ts` | `resolveEngineTopology`, `engineOptionsSchema`, `engineProfiles` (full-fork, genesis-subset), `engineFlagOptions`, `ENGINE_FLAGS` (`--engine-arg`, `--engine-env`, `--engine-toml`), `ENGINE_KIND`, `DEFAULT_ENGINE_PORTS`: the adapter's own topology options, defaults, flags and issues (refactor step 5) |
47
47
  | `topology/engine-environment-factory.ts` | `EngineEnvironmentFactory`: the adapter's `TopologyAdapterFactory` (kind `engine`, section `engine`), constructed by the composition root when a lab selects `defineLab({ environmentAdapters: { engine: { engineConfig } } })` (`composition/src/adapter-catalogue.ts`), or registered directly with `defineLab({ environmentFactories: [new EngineEnvironmentFactory({ engineConfig })] })` |
48
- | `setup/prepare-engine.ts`, `setup/engine-build.ts`, `setup/patches.ts`, `setup/patch-catalogue.ts` | Stock Engine v0.11.0 preparation and shipped release pin; `preparedEngine` checks the pin, stock record, checkout HEAD, built CLI and dot CLI. The optional repository-only `EngineSource.patch: "node-workspace"` is manifest-checked, applied before build, keyed by digest and reverse-checked before reuse. Retired owned-processes records and requests are refused. Descriptor overlays remain digest-checked through the manifest. |
48
+ | `setup/prepare-engine.ts`, `setup/engine-build.ts`, `setup/patches.ts`, `setup/patch-catalogue.ts`, `setup/node-workspace.patch` | Stock Engine v0.11.0 preparation and shipped release pin; `preparedEngine` checks the pin, stock record, checkout HEAD, built CLI and dot CLI. The optional `EngineSource.patch: "node-workspace"` is read from the copy the package ships beside the catalogue (`setup/node-workspace.patch`, `shippedEnginePatch`, digest-checked against `NODE_WORKSPACE_PATCH`, its manifest entry in the package; since 12 November 2026, so a product prepares it too: `tests/patch-catalogue.test.ts` holds it byte for byte the repository's `patches/previewnet-engine/node-workspace.patch`), applied before build, keyed by digest and reverse-checked before reuse. Retired owned-processes records and requests are refused. Descriptor overlays remain digest-checked through the repository manifest. |
49
+ | `setup/engine-tooling.ts` | An Engine checkout as an environment's tooling in a state directory of its own (12 November 2026, for composition's `previewnetGenesis`): `engineCheckoutTooling({ state, network, patch, environment })` (id `engine:<state>`; `check` is ready only for the build `patch` names; `prepare` runs `prepareEngine` with it, and refuses `--no-engine-patch` for a patched one), `preparedEngineCheckout` (the start's check, its refusal naming the fix) and `engineFetchCommand(environment)` (`product-lab fetch --environment <id>`, through the checkout's CLI script or npx). `tests/engine-checkout-tooling.test.ts` |
49
50
  | `setup/setup-command.ts` | The command every Engine refusal names, for where this adapter runs (`engineSetupCommand(environment?)`): `bun run setup` in product-lab's checkout, `npx --no-install product-lab fetch --environment <id>` in a product, which has no `bun run setup` (`insideProductLabCheckout`, this adapter's own copy of the CLI's check) |
50
51
  | `saved-fork/saved-fork-tooling.ts` | The Engine checkout a saved fork starts from, `<cacheDir>/engine` (16 October 2026): `savedForkEngine` (the start's check, `as-prepared`, its refusal naming `SavedForkSettings.setupCommand`, default `engineSetupCommand`), and `savedForkTooling`, the fork's `EnvironmentProvider.tooling` (id `engine:<cacheDir>/engine`, shared by every saved fork with that cache; kind `engine`): `check` is `savedForkEngine`'s, `prepare` runs `prepareEngine` there for the fork's network with the request's source (`repository`, `ref`, `patched: false` for stock, also the default; `patched: true` is refused) and returns the check of exactly what it asked for. `product-lab fetch` prepares it and `product-lab doctor` reports it in a product. A saved fork can reuse a node-workspace build prepared through `prepareEngine`; its status names that patch and its launch config records `enginePatch`, so the launch-time reverse check is retained. A saved fork started from a stock build writes `enginePatch: "none"` in its own engine.json |
51
52
  | `testing` subpath | Fake Engine dashboard and chain RPCs for contract tests (`codeHashOffset`, `specVersion` change the served runtime) |
@@ -38,8 +38,9 @@ export { defaultEngineRepository, defaultStateDirectory, enginePatch, EngineNotP
38
38
  export { ENGINE_PATCHES, engineBuildIssue, type EnginePatch } from "./setup/engine-build.js";
39
39
  export { CHECKOUT_ENGINE_SETUP, engineSetupCommand } from "./setup/setup-command.js";
40
40
  export { engineBuildWords, savedForkEngine, savedForkEngineState, savedForkSetupCommand, savedForkTooling } from "./saved-fork/saved-fork-tooling.js";
41
- export { ENGINE_PATCHES_REPOSITORY_ONLY, EnginePatchesNotInstalled, patchesDirectory, patchesInstalled, readPatchManifest, resolvePatch, type PatchEntry, } from "./setup/patches.js";
42
- export { DESCRIPTOR_OVERLAYS, LIGHT_TOPOLOGY_OVERLAY, OWNED_PROCESSES_PATCH } from "./setup/patch-catalogue.js";
41
+ export { engineCheckoutTooling, engineFetchCommand, preparedEngineCheckout, type EngineCheckoutSettings } from "./setup/engine-tooling.js";
42
+ export { ENGINE_PATCHES_REPOSITORY_ONLY, EnginePatchesNotInstalled, patchesDirectory, patchesInstalled, readPatchManifest, resolvePatch, shippedEnginePatch, shippedPatchesDirectory, type PatchEntry, } from "./setup/patches.js";
43
+ export { DESCRIPTOR_OVERLAYS, LIGHT_TOPOLOGY_OVERLAY, NODE_WORKSPACE_PATCH, OWNED_PROCESSES_PATCH, SHIPPED_ENGINE_PATCHES } from "./setup/patch-catalogue.js";
43
44
  export { engineLaunchPlan, type EngineCommand, type EngineLaunchPlan, } from "./topology/engine-plan.js";
44
45
  export { DEFAULT_ENGINE_DASHBOARD, enginePortOverrides, engineRun, type EngineIsolationInput, type EngineRun, type EngineRunIsolation, } from "./topology/engine-run.js";
45
46
  export { ENGINE_TIME_UNSUPPORTED, describeEngine, engineEnvironment, type EngineEnvironmentSettings, } from "./topology/engine-provider.js";
@@ -40,8 +40,9 @@ export { defaultEngineRepository, defaultStateDirectory, enginePatch, EngineNotP
40
40
  export { ENGINE_PATCHES, engineBuildIssue } from "./setup/engine-build.js";
41
41
  export { CHECKOUT_ENGINE_SETUP, engineSetupCommand } from "./setup/setup-command.js";
42
42
  export { engineBuildWords, savedForkEngine, savedForkEngineState, savedForkSetupCommand, savedForkTooling } from "./saved-fork/saved-fork-tooling.js";
43
- export { ENGINE_PATCHES_REPOSITORY_ONLY, EnginePatchesNotInstalled, patchesDirectory, patchesInstalled, readPatchManifest, resolvePatch, } from "./setup/patches.js";
44
- export { DESCRIPTOR_OVERLAYS, LIGHT_TOPOLOGY_OVERLAY, OWNED_PROCESSES_PATCH } from "./setup/patch-catalogue.js";
43
+ export { engineCheckoutTooling, engineFetchCommand, preparedEngineCheckout } from "./setup/engine-tooling.js";
44
+ export { ENGINE_PATCHES_REPOSITORY_ONLY, EnginePatchesNotInstalled, patchesDirectory, patchesInstalled, readPatchManifest, resolvePatch, shippedEnginePatch, shippedPatchesDirectory, } from "./setup/patches.js";
45
+ export { DESCRIPTOR_OVERLAYS, LIGHT_TOPOLOGY_OVERLAY, NODE_WORKSPACE_PATCH, OWNED_PROCESSES_PATCH, SHIPPED_ENGINE_PATCHES } from "./setup/patch-catalogue.js";
45
46
  export { engineLaunchPlan, } from "./topology/engine-plan.js";
46
47
  export { DEFAULT_ENGINE_DASHBOARD, enginePortOverrides, engineRun, } from "./topology/engine-run.js";
47
48
  export { ENGINE_TIME_UNSUPPORTED, describeEngine, engineEnvironment, } from "./topology/engine-provider.js";
@@ -3,7 +3,7 @@ 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
- /** Stock and the optional repository fix; owned-processes is recognized only to refuse stale configurations. */
6
+ /** Stock and the optional shipped fix (node-workspace); owned-processes is recognized only to refuse stale configurations. */
7
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";
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: Apache-2.0
3
3
  import { existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { resolvePatch } from "./patches.js";
5
+ import { shippedEnginePatch } from "./patches.js";
6
6
  import { NODE_WORKSPACE_PATCH } from "./patch-catalogue.js";
7
7
  import { engineSetupCommand } from "./setup-command.js";
8
8
  export const NODE_VERIFIER_OPTION = "--node-verifier";
@@ -15,7 +15,7 @@ export function engineBuildIssue(checkout, commands, patch = "none") {
15
15
  if (patch !== "none" && patch !== "node-workspace")
16
16
  return ENGINE_PATCH_RETIRED;
17
17
  if (patch === "node-workspace") {
18
- const resolved = resolvePatch("previewnet-engine", patch);
18
+ const resolved = shippedEnginePatch(patch);
19
19
  if (resolved.sha256 !== NODE_WORKSPACE_PATCH.sha256 || resolved.base !== NODE_WORKSPACE_PATCH.base)
20
20
  return "node-workspace patch differs from its tested catalogue";
21
21
  const check = commands.run({ command: "git", args: ["apply", "--reverse", "--check", resolved.path], cwd: checkout, timeoutMs: 30_000 });
@@ -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,9 +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
- /** Optional repository-only fix; stock preparation remains the default. */
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
+ */
22
27
  export declare const NODE_WORKSPACE_PATCH: {
23
28
  readonly name: "node-workspace";
29
+ readonly file: "node-workspace.patch";
30
+ readonly repository: "https://github.com/paritytech/previewnet-engine.git";
24
31
  readonly base: "e11792b234ca0c39a5fc1d47af491f82fc8e2670";
25
32
  readonly sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757";
26
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
+ };
@@ -15,6 +15,9 @@ export const LIGHT_TOPOLOGY_OVERLAY = { relay: { validators: 4 } };
15
15
  export const DESCRIPTOR_OVERLAYS = { "light-topology": LIGHT_TOPOLOGY_OVERLAY };
16
16
  export const NODE_WORKSPACE_PATCH = {
17
17
  name: "node-workspace",
18
+ file: "node-workspace.patch",
19
+ repository: ENGINE_RELEASE.repository,
18
20
  base: ENGINE_RELEASE.base,
19
21
  sha256: "a4b866a6da4cbb636bd614271c9691280490b00172dae02a8b113ad161055757",
20
22
  };
23
+ export const SHIPPED_ENGINE_PATCHES = { [NODE_WORKSPACE_PATCH.name]: NODE_WORKSPACE_PATCH };