Run a project from orchd.json

orchd boots a project from a manifest that travels with it — one npm package that runs the same orchd.json on your machine, in a container and in a Lifo box.

A dev server needs to know two things: what to run, and where. Hand a box a command line and that knowledge lives in the caller. Put it in a manifest that ships inside the project and the box can work it out for itself.

That is what orchd.json does. ORCHD provisions per-project workloads across substrates — host processes, Docker containers, Lifo boxes — and one manifest describes all of them.

orchd is an ordinary npm package, not a Lifo-only tool. The same package is the CLI on your machine and the command inside a box:

npx orchd up            # your machine, CI, a container
npm install -g orchd

lifo install orchd      # in a box — same package

It works in both places because Lifo discovers commands by the lifo field in a package's package.json, not by its name. Embedding Lifo yourself? Import the box command directly:

import orchdCommand from 'orchd/lifo';
sandbox.commands.register('orchd', orchdCommand);
orchd list                            # workloads in the manifest
orchd resolve --workload mobile --port 8082
orchd run     --workload mobile --port 8082

Boot the whole project

On a host, up supervises the workloads in the foreground the way docker compose up does — output interleaved under a per-workload prefix, and Ctrl-C stops the set. If one workload exits, the rest come down with it, because a half-booted project is not a useful state to be left in:

$ npx orchd up
orchd: api -> PORT=3000 npm run dev (cwd /work/demo/api)
orchd: web -> API_URL=http://localhost:3000 PORT=3001 npm run dev (cwd /work/demo/web)
api | api dev server on 3000
web | web dev server on 3001
^C
orchd: stopping…

Inside a box the same command backgrounds instead:

$ orchd up
orchd: api -> PORT=3000 node index.js (cwd /home/user/app/api)
orchd: mobile -> EXPO_PUBLIC_API_URL=http://localhost:3000 browser-metro . --port 8081 (cwd /home/user/app)
orchd: 2 workload(s) started; `jobs` to list them

Ports are assigned first, so the workloads can find each other. Each takes its declared "port", or one counting up from --port-base. A workload then refers to a sibling by name:

{ "name": "mobile", "env": { "EXPO_PUBLIC_API_URL": "${url:api}" } }

${url:<name>} and ${port:<name>} are what let one manifest work in both worlds: on a host those siblings are separate subdomains, in a box they are ports on localhost.

In a box, workloads start as background jobs, so up hands you back the prompt with everything running — jobs lists them. It has to: a shell runs one foreground command at a time, and a dev server never exits, so running them in the foreground would boot only the first. On a host there is no such constraint, so up keeps the children and the terminal.

The manifest

{
  "name": "rapidnative",
  "workloads": [
    { "name": "db", "kind": "tinbase" },
    {
      "name": "mobile",
      "kind": "node",
      "dir": "mobile",
      "install": ["npm", "install"],
      "run": ["npx", "expo", "start", "--web", "--port", "$PORT"],
      "profiles": {
        "lifo": { "run": ["browser-metro", ".", "--port", "$PORT"] }
      }
    }
  ]
}

orchd looks for ./orchd.json, then /orchd.json — so a project restored at the root of a box is found without being told where it is.

Profiles: same project, different runner

Which command is correct depends on what is executing it. Real Metro wants expo start --web and reads node_modules. A Lifo box usually wants browser-metro, which bundles through a hosted pre-bundler and reads no node_modules at all — so the same project can boot from a snapshot that never installed dependencies.

One manifest, one override block — and no profile applies unless the runner asks for one. The base run is what your machine executes; the in-box command asks for lifo, so a box gets browser-metro without the manifest ever mentioning Lifo at the top level:

$ npx orchd resolve --workload mobile --port 8082      # on a host
npx expo start --web --port 8082

$ orchd resolve --workload mobile --port 8082          # in a box
browser-metro . --port 8082

$ npx orchd resolve --workload mobile --port 8082 --profile lifo
browser-metro . --port 8082

That asymmetry is deliberate: profiles.lifo describes what a box should do and must not leak onto a host that can run the real thing.

run, install and dir replace wholesale when a profile overrides them — overriding run means exactly that. env merges key-wise, so a profile can add one variable without restating the rest.

$PORT and ${PORT} are substituted in argv. A workload with "port_env": "PORT" receives its port as an environment variable instead, which suits servers that read process.env.PORT.

Supervising from the host

resolve --json answers identically on both runners — it is the machine interface, so a supervising host gets { cwd, argv, env, install } and can run a workload with its own logging and signals.

Inside a box, when something outside supervises the workloads instead, resolve on the inside and run from the outside, so the host keeps its own streaming and abort handling:

const spec = JSON.parse(
  await sandbox.commands.run('orchd resolve -w mobile -p 8082 --json'),
);
// { workload, cwd, argv, env, install }
// `orchd resolve --all --json` returns one of these per workload.

const ac = new AbortController();
sandbox.shell.execute(spec.argv.join(' '), {
  cwd: spec.cwd,
  env: { ...sandbox.env, ...spec.env },
  signal: ac.signal,
});
await sandbox.waitForPort(8082);

On a Node host, publish it with exposePort; in the browser, reach it with sandbox.fetch() or a preview.

orchd run does the same thing in one step, but it goes through ctx.executeCapture, which buffers output and takes no AbortSignal — so a dev server started that way stays silent until it exits and is not torn down when the command is aborted. Use it interactively and for one-shot workloads; use resolve when something is supervising.

With snapshots

Because the manifest travels with the files, a snapshot or a plain project tarball is enough to boot a project end to end. importVfsSnapshot accepts an ordinary gzipped tar — no lifo-snapshot.json required — so an archive produced elsewhere restores just as well:

await importVfsSnapshot(sandbox.kernel.vfs, tarballBytes);
const spec = JSON.parse(await sandbox.commands.run('orchd resolve -w mobile -p 8082 --json'));

A 24 KB source-only tarball of an Expo app — no node_modules — restores and serves a bundle through browser-metro this way.

Note

browser-metro drops its first argument, so browser-metro --port 8082 binds the default 8081 instead. Pass the project directory first — browser-metro . --port 8082 — as the profile above does.