From 8225eac522191db75d1252372bdb507a3bad3afa Mon Sep 17 00:00:00 2001
From: Taras Mankovski
Date: Wed, 2 Sep 2026 20:37:46 -0400
Subject: [PATCH 1/6] =?UTF-8?q?=E2=9C=A8=20Read=20a=20root=20document=20fr?=
=?UTF-8?q?om=20standard=20input=20(#723)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`xmd run -` and `xmd run -- -` read standard input to end of file, admit the
whole result as one root, and run it through the ordinary run profile — so a
command that produces a complete program composes with a run without a file in
between.
The sentinel is fixed grammar: only an invocation that explicitly names `run`
and whose document argument is exactly `-` selects it, read from the parser's
own unconsumed remainder so a `-` another option took as its value is not one.
The shorthand `xmd -`, a `-#Section` reference, another command's `-`, and
`--eval -` keep their existing meanings and read nothing.
Standard input is a private CLI-host dependency: each runtime-named entrypoint
supplies the reader for its own process stdin, and the shared CLI reaches no
host global. What comes back is `retainedSource("", source)` — no new
root-source variant, constructor or digest. The complete input is acquired
before inspection, provider setup, the secret-detection announcement, journal
creation, root admission and execution, inside the run's existing deadline; a
failed read is one fixed sentence, and cancellation stays cancellation.
`packages/test-support/launch.ts` gains an optional `stdin`, written and closed
through a launch path that owns the child, so subprocess tests observe real EOF.
---
README.md | 9 +
architecture.md | 35 ++
packages/cli/src/bun.ts | 2 +
packages/cli/src/cli.ts | 144 ++++-
packages/cli/src/compiled.ts | 2 +
packages/cli/src/deno.ts | 2 +
packages/cli/src/node.ts | 2 +
packages/cli/src/standard-input.ts | 96 +++
packages/cli/tests/cli-help.test.ts | 21 +-
packages/cli/tests/compiled-upgrade.test.ts | 3 +-
packages/cli/tests/inline-cli.test.ts | 58 +-
packages/cli/tests/run-deadline.test.ts | 2 +
packages/cli/tests/stdin-cli.test.ts | 592 +++++++++++++++++++
packages/cli/tests/support/standard-input.ts | 17 +
packages/cli/tests/targets-cli.test.ts | 3 +
packages/test-support/launch.ts | 88 ++-
site/routes/docs/reference.tsx | 26 +-
site/routes/index.tsx | 11 +
specs/executable-mdx-spec.md | 101 +++-
19 files changed, 1176 insertions(+), 38 deletions(-)
create mode 100644 packages/cli/src/standard-input.ts
create mode 100644 packages/cli/tests/stdin-cli.test.ts
create mode 100644 packages/cli/tests/support/standard-input.ts
diff --git a/README.md b/README.md
index 06e2db019..4f58944cf 100644
--- a/README.md
+++ b/README.md
@@ -62,6 +62,15 @@ It answers with the properties this document declares and every target you can
invoke, each named by the reference that selects it and described by what
selecting it does.
+A run takes its root document from a path, from standard input, or from one
+`--eval` value. Writing `-` as the document reads standard input to end of file
+and runs what it read, so a command that produces a complete document composes
+with a run without a file in between:
+
+```bash
+deno task xmd plan "prepare the release" | deno task xmd run -
+```
+
Preparation is common to all of them, so the block above is the document's own
preamble: whichever target you select runs it first, and the checkout is
prepared again before the work you asked for.
diff --git a/architecture.md b/architecture.md
index 7f3df3918..ba6026dce 100644
--- a/architecture.md
+++ b/architecture.md
@@ -2757,6 +2757,40 @@ Forwarding, completion, cancellation and teardown belong to the executable
block's own Effection scope. Cancelling the block stops the child and the tasks
forwarding its output before the block settles.
+## The root document a run is given
+
+An `xmd run` starts from exactly one root document, and it comes from one of
+three places: a path, standard input, or one `--eval` value. Which one is fixed
+grammar — decided from the command form the caller wrote, before anything is
+read — so a run never discovers its source part-way through preparation.
+
+Standard input is the one of the three the shared CLI cannot reach for itself. A
+process's own stdin is host-specific state, so each runtime-named entrypoint
+supplies the operation that reads it, as a value passed into the CLI beside the
+service installer, the upgrade assembly and the repository installer. It is not
+a Context, a contextual Api, a public export, a syntax entry, a component or a
+document capability: nothing an authored document can name reaches it, replaces
+it, or asks it for a second read. The CLI calls it at most once per invocation,
+and only for the one command form that selected it.
+
+What comes back is admitted through the existing supplied-source protocol rather
+than a form of its own. The origin `` and the exact text travel together,
+which is what source positions, diagnostics, the root binding and the durable
+root import all carry — so replay restores the retained source without reading
+anything, and two different programs are two different roots without a second
+identity field. `` is an origin and never a path: no file of that name is
+read or created, and every relative operation still resolves from the contextual
+working directory.
+
+Acquisition is the first thing the run does. The complete input is in hand
+before document inspection, target and property preparation, Agent-stack or
+provider setup, the secret-detection announcement, journal creation, root
+admission and execution — so a host that cannot deliver a whole document reports
+one fixed sentence, exits nonzero and has reached none of them. It happens
+inside the run's existing deadline rather than opening a lifecycle of its own,
+and a waiting read is cancellable: cancellation tears the reader down and stays
+cancellation, never a read failure.
+
## Contextual run configuration
Nothing has a timeout by default. Three contextual values bound three different
@@ -3803,6 +3837,7 @@ Status is measured against main.
| `Expansion` / `getExpansion()` | describes the current logical element expansion | built on main |
| document targets | catalogs a root document's addressable static headings, resolves one selector to one exact target, and projects the document to it before expansion | built on the #412 stack |
| document-aware `xmd run … --help` | describes what one document declares and every target it addresses, each as a full document reference with the description its section states, by inspection alone | built on the #463 stack |
+| standard-input root documents | `xmd run -` and `xmd run -- -` read the whole root document from standard input, once, to end of file, and run it through the ordinary run profile. Fixed grammar selects it — the explicit `run` command form plus a document argument that is exactly `-`, read from the parser's own unconsumed remainder so a `-` another option took as its value is not one — so the shorthand `xmd -`, a `-#Section` reference, another command's `-`, and `--eval -` all keep their existing meanings and read nothing. The reader is a value each runtime-named entrypoint supplies and the shared CLI never reaches a stdin global; what comes back is `retainedSource("", source)`, adding no root-source variant, constructor, digest member or public API. The complete input is acquired before inspection, provider setup, the secret-detection announcement, journal creation, root admission and execution, inside the run's existing deadline; a failed read is one fixed sentence carrying no host error, input or path, and cancellation tears the reader down without becoming one | built on this stack |
| targeted `xmd run` | reads a file argument as a document reference and executes the one exact target its selector resolved to, replacing the selector before execution rereads the file | built on the #412 stack |
| targeted workflow definition | the V1 workflow definition optionally carries the exact canonical document target, which takes part in definition identity and in compatible reuse | built on the #412 stack; the workflow CLI does not supply one yet |
| declared Markdown component | a trusted host declares exact first-party Markdown to one execution as immutable data on an `ExecutionInstallation`: name, origin, source, its SHA-256, the accepted forms, an optional statement of the props and return that must agree with the parsed source, and an optional private component closure. Admission parses the bytes and refuses a mismatched digest or schema, a non-canonical form, a name that is not a component name or is structural, a duplicate, a reserved-registration collision and a private name a registration also claims. Resolution places it in the protected tier with reserved registrations, above the workflow component bundle, repository files and every registered default. Live import and retained history are held to the declared origin, digest and bytes, private names resolve only while canonical core expands the declaring bytes' own body — by the authored occurrence rather than by the name, so an answer kept from a legitimate private import authorizes no later site, no alias, no copy of the definition, no invocation that is over and no later execution — including one that declares no Markdown at all — while a private name written anywhere else resolves to nothing before the bundle, the repository or a registration can answer for it — and `xmd syntax` and document validation describe the declared contract from the same declaration without describing the closure. Closure is per name: only the declared component and its private closure become canonical imports, and every other name in the execution stays the ordinary open import middleware may still answer | built on the #660 stack; no public component uses it yet (#660 PR 2) |
diff --git a/packages/cli/src/bun.ts b/packages/cli/src/bun.ts
index 1592632e6..93d91a633 100644
--- a/packages/cli/src/bun.ts
+++ b/packages/cli/src/bun.ts
@@ -11,6 +11,7 @@ import process from "node:process";
import { API, useHostFiles } from "@executablemd/runtime";
import { compileTempFile } from "@executablemd/core";
import { runXmd, XMD_VERSION } from "./cli.ts";
+import { readInputStream } from "./standard-input.ts";
import type { UpgradeAssembly } from "./upgrade.ts";
import { unassembledMachineSessions } from "./session-coordinator.ts";
import { unsupportedWorkflowHost } from "./workflow.ts";
@@ -70,6 +71,7 @@ await main(function* (args) {
useBunService,
UPGRADE,
unsupportedRepositories,
+ () => readInputStream(process.stdin),
unsupportedWorkflowHost,
unassembledMachineSessions(),
);
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index b73e5e90b..04c1511d0 100755
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -3,6 +3,7 @@
*
* Usage:
* xmd run [options]
+ * xmd run - [options] (the document is read from stdin)
* xmd [options] (run is the default command)
* xmd plan "" [options]
* xmd upgrade [] [--status] [--allow-downgrade] [--allow-prerelease] [--journal ]
@@ -18,6 +19,7 @@
*
* Examples:
* xmd run packages/core/examples/hello-world.md
+ * xmd plan "prepare the release" | xmd run -
* xmd plan "ask me for my age and write the result to a file"
* xmd packages/core/examples/hello-world.md --verbose
* xmd run packages/core/examples/hello-world.md --journal events.jsonl
@@ -138,6 +140,8 @@ import { unsupportedRepositories } from "./run-repositories.ts";
import type { RepositoryInstaller } from "./run-repositories.ts";
import { EVAL_ALIAS, EVAL_OPTION, evalGrammarError, readEvalFlags } from "./eval-source.ts";
import type { EvalFlags } from "./eval-source.ts";
+import { STANDARD_INPUT_FAILURE, STANDARD_INPUT_PATH } from "./standard-input.ts";
+import type { StandardInputReader } from "./standard-input.ts";
import {
parseWorkflowRequest,
runWorkflow,
@@ -244,7 +248,9 @@ const executionFields = {
const runConfig = object({
path: {
- description: "markdown document to execute, optionally `#` and one target selector",
+ description:
+ "markdown document to execute, optionally `#` and one target selector; " +
+ "`xmd run -` reads the document from standard input instead",
...field(z.string().optional(), cli.argument()),
},
// Declared so `xmd run --help` lists it with every other option. The value is
@@ -1566,14 +1572,61 @@ interface PropsPhase {
established?: EstablishedDefinition;
}
+/** The document argument that names standard input, and nothing else. */
+const STANDARD_INPUT_ARGUMENT = "-";
+
+/**
+ * Whether these arguments select the `run` command by naming it.
+ *
+ * Read from argv rather than from a parse, exactly as `workflow` and `plan`
+ * are: the shorthand form resolves to the same parsed command, and the two have
+ * to stay distinguishable for the standard-input sentinel below.
+ */
+function namesRun(args: string[]): boolean {
+ return args[0] === "run";
+}
+
+/**
+ * What is left of `xmd run`'s argv once the sentinel document argument is gone.
+ *
+ * Present only when the caller wrote `xmd run -` or `xmd run -- -`. Configliere
+ * refuses any positional beginning with `-` and defines no end-of-options
+ * separator, so the sentinel is read out of the parser's own remainder — the
+ * tokens it did not consume — rather than out of raw argv, where `-` could be
+ * another option's value (`--journal -`). Removing it lets everything the
+ * caller wrote around it parse as it would around a path.
+ */
+function takeStandardInputArgument(args: string[], remainder: string[]): string[] | undefined {
+ const separated = remainder[0] === "--";
+ const [sentinel] = separated ? remainder.slice(1, 2) : remainder;
+ if (sentinel !== STANDARD_INPUT_ARGUMENT) {
+ return undefined;
+ }
+ const at = args.length - remainder.length;
+ if (args[at] !== remainder[0]) {
+ return undefined;
+ }
+ return [...args.slice(0, at), ...remainder.slice(separated ? 2 : 1)];
+}
+
/**
* Locate the root document, read what it declares, and lift its generated
* options out of argv. A provisional parse finds the path: it stops at
* the first token it does not define, which is exactly where
* document-derived options begin. The inline document was already lifted out
* of argv, so it needs no parse at all.
+ *
+ * Standard input is acquired here, before the document is inspected and
+ * therefore before every later phase of a run. Fixed grammar decides that it is
+ * the source — the command form the caller wrote, plus the sentinel document
+ * argument — and every grammar failure that could make the read pointless is
+ * answered first, so the host reads once or not at all.
*/
-function* preparePropsPhase(args: string[], evalFlags: EvalFlags): Operation {
+function* preparePropsPhase(
+ args: string[],
+ evalFlags: EvalFlags,
+ readStandardInput: StandardInputReader,
+): Operation {
// `xmd plan` declares its own grammar and has no document to inspect: the
// schema its generated options come from is written by an agent that has not
// been asked anything yet. Everything below that reads a document, and every
@@ -1603,8 +1656,17 @@ function* preparePropsPhase(args: string[], evalFlags: EvalFlags): Operation'\``,
+ };
+ }
+
if (supplied !== undefined && typeof documentPath === "string") {
return {
- args,
+ args: fixed,
bindings: [],
error:
`${documentPath} and ${EVAL_OPTION} both supply a root document — a run takes exactly one, ` +
@@ -1640,24 +1712,44 @@ function* preparePropsPhase(args: string[], evalFlags: EvalFlags): Operation` is what positions and diagnostics report, and the exact
+ // text is what the root binding and the durable root import retain.
+ const input = yield* readStandardInput();
+ if (!input.ok) {
+ return { args: fixed, bindings: [], error: STANDARD_INPUT_FAILURE };
+ }
+ root = retainedSource(STANDARD_INPUT_PATH, input.value);
} else if (typeof documentPath === "string") {
const reference = readReference(documentPath);
if (!reference.ok) {
- return { args, bindings: [], error: describeError(reference.error) };
+ return { args: fixed, bindings: [], error: describeError(reference.error) };
}
root = reference.value;
}
if (!root) {
- const stray = findPropsFlag(args);
+ const stray = findPropsFlag(fixed);
if (stray && command && command !== "run") {
return {
- args,
+ args: fixed,
bindings: [],
error:
`unrecognized option for xmd ${command}: ${stray} — document properties are ` +
@@ -1666,18 +1758,18 @@ function* preparePropsPhase(args: string[], evalFlags: EvalFlags): Operation ${stray} …\``,
};
}
- return { args, bindings: [] };
+ return { args: fixed, bindings: [] };
}
try {
const document = yield* inspectDocument(root);
const bindings = buildBindings(document.props);
- const extraction = extractPropsArgs(args, bindings);
+ const extraction = extractPropsArgs(fixed, bindings);
const addressable = root.source === undefined && document.targetInfo.length > 0;
return {
args: extraction.rest,
@@ -1690,7 +1782,7 @@ function* preparePropsPhase(args: string[], evalFlags: EvalFlags): Operation {
- const propsPhase = yield* preparePropsPhase(helpRequest.args, evalFlags);
+ const propsPhase = yield* preparePropsPhase(helpRequest.args, evalFlags, readStandardInput);
if (propsPhase.error) {
console.error(propsPhase.error);
@@ -2233,8 +2334,8 @@ function* dispatch(
// above.
if (!propsPhase.root) {
console.error(
- `xmd run requires a document path or an inline document — ` +
- `\`xmd run \` or \`xmd run ${EVAL_ALIAS} ''\``,
+ "xmd run requires a root document — `xmd run `, `xmd run -`, or " +
+ `\`xmd run ${EVAL_OPTION} ''\``,
);
yield* exit(1);
break;
@@ -2580,6 +2681,11 @@ export function* runXmd(
// that installs nothing, so those runtimes describe the same vocabulary and
// operate none of it.
installRepositories: RepositoryInstaller,
+ // How this host reads a whole document from its own standard input, for the
+ // one command form that asks for one. The shared CLI reaches no stdin global
+ // of its own, and nothing a document can write reaches this: it is a value
+ // the entrypoint supplies, called at most once per invocation.
+ readStandardInput: StandardInputReader,
// Defaults to the host that refuses. A caller driving this without naming a
// workflow host has no run store, and inheriting one by omission is the
// failure mode the whole boundary exists to prevent — so the default is the
@@ -2656,6 +2762,7 @@ export function* runXmd(
installService,
upgrade,
installRepositories,
+ readStandardInput,
workflowHost,
sessions,
);
@@ -2675,6 +2782,7 @@ export function* runXmd(
installService,
upgrade,
installRepositories,
+ readStandardInput,
workflowHost,
sessions,
),
diff --git a/packages/cli/src/compiled.ts b/packages/cli/src/compiled.ts
index ae192e3bb..3a94a6b1e 100644
--- a/packages/cli/src/compiled.ts
+++ b/packages/cli/src/compiled.ts
@@ -10,6 +10,7 @@ import process from "node:process";
import { API, useHostFiles } from "@executablemd/runtime";
import { compileDataUri } from "@executablemd/core";
import { runXmd, XMD_VERSION } from "./cli.ts";
+import { readInputStream } from "./standard-input.ts";
import { compiledUpgradeAssembly } from "./compiled-upgrade.ts";
import { useMachineSessions } from "./session-coordinator.ts";
import { useDenoWorkflowHost } from "./deno-workflow.ts";
@@ -89,6 +90,7 @@ if (isCredentialHelperMode(process.argv.slice(2))) {
useCompiledService,
UPGRADE,
denoRunRepositories(HELPER),
+ () => readInputStream(process.stdin),
() => useDenoWorkflowHost(HELPER),
useMachineSessions(),
);
diff --git a/packages/cli/src/deno.ts b/packages/cli/src/deno.ts
index c12630c99..3bc6b901e 100644
--- a/packages/cli/src/deno.ts
+++ b/packages/cli/src/deno.ts
@@ -13,6 +13,7 @@ import process from "node:process";
import { API, useHostFiles } from "@executablemd/runtime";
import { compileDataUri } from "@executablemd/core";
import { runXmd, XMD_VERSION } from "./cli.ts";
+import { readInputStream } from "./standard-input.ts";
import type { UpgradeAssembly } from "./upgrade.ts";
import { useMachineSessions } from "./session-coordinator.ts";
import { useDenoWorkflowHost } from "./deno-workflow.ts";
@@ -108,6 +109,7 @@ if (isCredentialHelperMode(process.argv.slice(2))) {
useDenoService,
UPGRADE,
denoRunRepositories(HELPER),
+ () => readInputStream(process.stdin),
() => useDenoWorkflowHost(HELPER),
useMachineSessions(),
);
diff --git a/packages/cli/src/node.ts b/packages/cli/src/node.ts
index 927ba2369..4ba420bda 100755
--- a/packages/cli/src/node.ts
+++ b/packages/cli/src/node.ts
@@ -17,6 +17,7 @@ import process from "node:process";
import { API, useHostFiles } from "@executablemd/runtime";
import { compileTempFile } from "@executablemd/core";
import { runXmd, XMD_VERSION } from "./cli.ts";
+import { readInputStream } from "./standard-input.ts";
import type { UpgradeAssembly } from "./upgrade.ts";
import { unassembledMachineSessions } from "./session-coordinator.ts";
import { unsupportedWorkflowHost } from "./workflow.ts";
@@ -78,6 +79,7 @@ await main(function* (args) {
useNodeService,
UPGRADE,
unsupportedRepositories,
+ () => readInputStream(process.stdin),
unsupportedWorkflowHost,
unassembledMachineSessions(),
);
diff --git a/packages/cli/src/standard-input.ts b/packages/cli/src/standard-input.ts
new file mode 100644
index 000000000..ebf5e693a
--- /dev/null
+++ b/packages/cli/src/standard-input.ts
@@ -0,0 +1,96 @@
+/**
+ * The root document a caller pipes in.
+ *
+ * `xmd run -` reads standard input to end of file and admits the whole result
+ * as one root. Which stream that is belongs to the process that is running, so
+ * each runtime-named entrypoint closes this adapter over its own
+ * `process.stdin` and hands `runXmd` the reader (Code Rule 12). It is a value
+ * the host supplies: not a Context, a syntax entry, a component, or anything an
+ * authored document can name or replace.
+ */
+
+import { Err, Ok, withResolvers } from "effection";
+import type { Operation, Result } from "effection";
+
+/** The stable origin a document read from standard input reports. */
+export const STANDARD_INPUT_PATH = "";
+
+/**
+ * Everything a failed read says.
+ *
+ * The host's own error, the bytes that did arrive and the stream they arrived
+ * on are all absent on purpose: none of them is something the caller of a
+ * pipeline can act on, and each would put unbounded foreign text into a
+ * diagnostic.
+ */
+export const STANDARD_INPUT_FAILURE =
+ "xmd run could not read a complete document from standard input";
+
+/** Read the complete root document from wherever this host keeps standard input. */
+export type StandardInputReader = () => Operation>;
+
+/**
+ * The part of a Node-style readable this adapter uses.
+ *
+ * Stated structurally so the shared module names no host global: an entrypoint
+ * passes its own stream in by value.
+ */
+export interface InputStream {
+ on(event: "data", listener: (chunk: Uint8Array) => void): unknown;
+ on(event: "end" | "close", listener: () => void): unknown;
+ on(event: "error", listener: (error: Error) => void): unknown;
+ off(event: "data", listener: (chunk: Uint8Array) => void): unknown;
+ off(event: "end" | "close", listener: () => void): unknown;
+ off(event: "error", listener: (error: Error) => void): unknown;
+ resume(): unknown;
+ pause(): unknown;
+}
+
+/**
+ * The supplied stream's complete UTF-8 text, read once, to end of file.
+ *
+ * The listeners and the flowing state they put the stream into belong to this
+ * operation's scope: cancelling it removes them and stops the read without
+ * manufacturing a failure, so a run cancelled while waiting for bytes reports
+ * cancellation rather than a read that went wrong.
+ *
+ * A stream that closes without ending delivered part of a document and no end
+ * of file, which is a failure rather than a short program.
+ */
+export function* readInputStream(stream: InputStream): Operation> {
+ const settled = withResolvers>();
+ const decoder = new TextDecoder();
+ const chunks: string[] = [];
+ let ended = false;
+
+ const onData = (chunk: Uint8Array) => {
+ chunks.push(decoder.decode(chunk, { stream: true }));
+ };
+ const onEnd = () => {
+ ended = true;
+ settled.resolve(Ok(`${chunks.join("")}${decoder.decode()}`));
+ };
+ const onClose = () => {
+ if (!ended) {
+ settled.resolve(Err(new Error("standard input closed before end of file")));
+ }
+ };
+ const onError = (error: Error) => {
+ settled.resolve(Err(error));
+ };
+
+ try {
+ stream.on("data", onData);
+ stream.on("end", onEnd);
+ stream.on("close", onClose);
+ stream.on("error", onError);
+ stream.resume();
+ return yield* settled.operation;
+ } finally {
+ stream.off("data", onData);
+ stream.off("end", onEnd);
+ stream.off("close", onClose);
+ stream.off("error", onError);
+ stream.pause();
+ }
+}
diff --git a/packages/cli/tests/cli-help.test.ts b/packages/cli/tests/cli-help.test.ts
index a501e7d74..49ac8da42 100644
--- a/packages/cli/tests/cli-help.test.ts
+++ b/packages/cli/tests/cli-help.test.ts
@@ -72,7 +72,10 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false
expect(stdout).toContain("Usage: xmd run [OPTIONS] [path]");
expect(stdout).toContain("markdown document to execute");
expect(stdout).toContain("-e, --eval");
- expect(stdout).toContain("Exactly one root document is required");
+ expect(stdout).toContain(
+ "Exactly one root document is required: a path, standard input through " +
+ "`xmd run -`, or one --eval value.",
+ );
expect(stdout).toContain("--include");
expect(stderr).not.toContain("Invalid input");
});
@@ -151,8 +154,11 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false
const { code, stderr } = yield* runCli(["run"]).join();
expect(code).toBe(1);
// Once `path` became optional the parser stopped raising, so the diagnostic
- // is the CLI's own and names both ways to supply a root document.
- expect(stderr).toContain("requires a document path or an inline document");
+ // is the CLI's own and names all three ways to supply a root document.
+ expect(stderr).toContain(
+ "xmd run requires a root document — `xmd run `, `xmd run -`, or " +
+ "`xmd run --eval ''`",
+ );
});
it("CH6: no command named targets is registered", function* () {
@@ -176,6 +182,15 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false
});
});
+ it("CH15: run help states the standard-input form and the pipeline it serves", function* () {
+ const { stdout } = yield* runCli(["run", "--help"]).expect();
+ expect(stdout).toContain("`xmd run -` reads standard input to end of file");
+ expect(stdout).toContain('xmd plan "prepare the release" | xmd run -');
+ // The argument's own description says it too, so a reader scanning the
+ // option list finds the form without reading to the epilogue.
+ expect(stdout).toContain("`xmd run -` reads the document from standard input instead");
+ });
+
it("CH8: run help teaches the document-reference grammar", function* () {
const { stdout } = yield* runCli(["run", "--help"]).expect();
expect(stdout).toContain("xmd run README.md#Release/Publish");
diff --git a/packages/cli/tests/compiled-upgrade.test.ts b/packages/cli/tests/compiled-upgrade.test.ts
index 6b7664f42..99e8d26ea 100644
--- a/packages/cli/tests/compiled-upgrade.test.ts
+++ b/packages/cli/tests/compiled-upgrade.test.ts
@@ -45,6 +45,7 @@ import { join } from "node:path";
import process from "node:process";
import { runXmd } from "../src/cli.ts";
+import { refusedStandardInput } from "./support/standard-input.ts";
import { unsupportedRepositories } from "../src/run-repositories.ts";
import { compiledUpgradeAssembly, writeAll } from "../src/compiled-upgrade.ts";
import type {
@@ -1534,7 +1535,7 @@ function* commandStatus(args: string[], assembly: UpgradeAssembly): Operation {
},
SOURCE_UPGRADE,
unsupportedRepositories,
+ refusedStandardInput,
);
return { status, stderr, reads, events, serviceInstalled, deadlineReads };
diff --git a/packages/cli/tests/stdin-cli.test.ts b/packages/cli/tests/stdin-cli.test.ts
new file mode 100644
index 000000000..9c0cc55d4
--- /dev/null
+++ b/packages/cli/tests/stdin-cli.test.ts
@@ -0,0 +1,592 @@
+/**
+ * Tier SI — `xmd run -`, the root document read from standard input (#723).
+ *
+ * The public rows shell out through the launcher's real stdin pipe, so what the
+ * CLI observes is the pipeline a caller would build: bytes, then an actual end
+ * of file. The private rows drive `runXmd` in this process, because a read that
+ * fails, a read that is cancelled, and a reader that is never called are all
+ * facts about a call inside the process that no subprocess can show.
+ */
+
+import { describe, it } from "@executablemd/test-support/bdd";
+import { expect } from "@executablemd/test-support/expect";
+import { runCli } from "@executablemd/test-support/launch";
+import {
+ createContext,
+ Err,
+ ensure,
+ Ok,
+ scoped,
+ sleep,
+ spawn,
+ suspend,
+ until,
+ withResolvers,
+} from "effection";
+import type { Operation, Result, Task } from "effection";
+import { ensureDir, exists, readTextFile, rm, writeTextFile } from "@effectionx/fs";
+import { randomUUID } from "node:crypto";
+import { readdir } from "node:fs/promises";
+import * as os from "node:os";
+import * as path from "node:path";
+import { PassThrough } from "node:stream";
+import { API, Service, useHostFiles } from "@executablemd/runtime";
+import { runXmd } from "../src/cli.ts";
+import {
+ readInputStream,
+ STANDARD_INPUT_FAILURE,
+ STANDARD_INPUT_PATH,
+} from "../src/standard-input.ts";
+import type { StandardInputReader } from "../src/standard-input.ts";
+import { unsupportedRepositories } from "../src/run-repositories.ts";
+import { SOURCE_UPGRADE } from "./support/upgrade-assembly.ts";
+
+function* useFixture(
+ files: Record,
+ body: (dir: string) => Operation,
+): Operation {
+ const dir = path.join(os.tmpdir(), `xmd-si-${randomUUID()}`);
+ yield* ensureDir(dir);
+ return yield* scoped(function* () {
+ yield* ensure(() => rm(dir, { recursive: true, force: true }));
+ for (const [name, content] of Object.entries(files)) {
+ yield* writeTextFile(path.join(dir, name), content);
+ }
+ return yield* body(dir);
+ });
+}
+
+function* entries(dir: string): Operation> {
+ return new Set(yield* until(readdir(dir)));
+}
+
+const MARKER_DOCUMENT = "# Piped\n\nSTDIN_MARKER\n";
+
+const PROPS_DOCUMENT = [
+ "---",
+ "props:",
+ " name:",
+ " type: string",
+ " description: Person to greet",
+ "required: [name]",
+ "---",
+ "",
+ "Hello {props.name}",
+].join("\n");
+
+const VALUE_DOCUMENT = [
+ "---",
+ "returns:",
+ " ok: { type: boolean }",
+ "---",
+ "",
+ "rendered body",
+ "",
+ "",
+].join("\n");
+
+/**
+ * An effect, and then a construct the structural preflight refuses.
+ *
+ * The `` is the negative control: reaching it at all would leave a file
+ * behind. The malformed `` is a declaration violation, which core
+ * reports before the body runs — so the whole input has to have been read for
+ * the run to refuse it, and refusing costs the earlier effect.
+ */
+const PREFLIGHT_DOCUMENT = [
+ "---",
+ "returns:",
+ " ok: { type: boolean }",
+ "---",
+ "",
+ 'content',
+ "",
+ "oops",
+ "",
+].join("\n");
+
+/** A document whose third line is a construct only `` may select. */
+const POSITIONED_DOCUMENT = "PREFIX\n\nstray\n";
+
+/** A document that writes one file, so an execution is visible on disk. */
+const EFFECTFUL_DOCUMENT = 'content\n';
+
+describe(
+ "Tier SI — standard-input root documents",
+ { sanitizeOps: false, sanitizeResources: false },
+ () => {
+ it("SI1: a piped document reaches end of file and runs once", function* () {
+ const { code, stdout, stderr } = yield* runCli(["run", "-", "--raw"], {
+ stdin: MARKER_DOCUMENT,
+ }).join();
+
+ expect(code).toBe(0);
+ expect(stderr).toBe("");
+ expect(stdout.split("STDIN_MARKER")).toHaveLength(2);
+ });
+
+ it("SI2: the separator form selects standard input too", function* () {
+ const { code, stdout, stderr } = yield* runCli(["run", "--", "-", "--raw"], {
+ stdin: MARKER_DOCUMENT,
+ }).join();
+
+ expect(code).toBe(0);
+ expect(stderr).toBe("");
+ expect(stdout.split("STDIN_MARKER")).toHaveLength(2);
+ // `-` after the separator is the document argument, never an option or a
+ // path: a run that read it as either would have refused for want of a
+ // root, or looked for a file of that name.
+ expect(stderr).not.toContain("requires a root document");
+ });
+
+ it("SI3: the whole input is read, and preflight costs the earlier effect", function* () {
+ yield* useFixture({}, function* (dir) {
+ const { code, stderr } = yield* runCli(["run", "-", "--raw"], {
+ cwd: dir,
+ stdin: PREFLIGHT_DOCUMENT,
+ }).join();
+
+ expect(code).not.toBe(0);
+ // Only the last construct in the input can produce this, so the read
+ // reached end of file rather than stopping at its first chunk.
+ expect(stderr).toContain(' requires a "value" prop');
+ expect(stderr).toContain(" takes no children");
+ // Complete structural preflight finishes before the first document
+ // effect, so the `` written above it never ran.
+ expect(yield* exists(path.join(dir, "written.txt"))).toBe(false);
+ });
+ });
+
+ it("SI4: empty input is an empty root that emits nothing and does nothing", function* () {
+ yield* useFixture({}, function* (dir) {
+ const before = yield* entries(dir);
+ const { code, stdout, stderr } = yield* runCli(["run", "-", "--raw"], {
+ cwd: dir,
+ stdin: "",
+ }).join();
+
+ expect(code).toBe(0);
+ expect(stdout).toBe("");
+ expect(stderr).toBe("");
+ expect(yield* entries(dir)).toEqual(before);
+ });
+ });
+
+ it("SI5: the root reports and retains the exact supplied source", function* () {
+ const positioned = yield* runCli(["run", "-", "--raw"], {
+ stdin: POSITIONED_DOCUMENT,
+ }).join();
+ expect(positioned.code).toBe(1);
+ expect(positioned.stderr).toContain(`(${STANDARD_INPUT_PATH}:3:1)`);
+
+ yield* useFixture({}, function* (dir) {
+ const trace = path.join(dir, "trace.jsonl");
+ yield* runCli(["run", "--raw", "--journal", trace, "-"], {
+ cwd: dir,
+ stdin: MARKER_DOCUMENT,
+ }).join();
+
+ const written = yield* readTextFile(trace);
+ const root = written
+ .split("\n")
+ .filter((line) => line.length > 0)
+ .map((line) => JSON.parse(line))
+ .find(
+ (event) =>
+ event.type === "yield" &&
+ event.description?.type === "import_component" &&
+ event.description?.name === "__root__",
+ );
+
+ // Exact equality is the claim: the existing closed repository-root
+ // shape, the `` origin, the exact bytes, and no member of its
+ // own — no stdin kind and no digest.
+ expect(root?.result?.value).toEqual({
+ kind: "repository",
+ path: STANDARD_INPUT_PATH,
+ content: MARKER_DOCUMENT,
+ });
+ });
+ });
+
+ it("SI6: relative resolution is the invocation's, and is no file", function* () {
+ yield* useFixture({ "Greeting.md": "Hello from a component\n" }, function* (dir) {
+ const before = yield* entries(dir);
+ const { code, stdout } = yield* runCli(["run", "-", "--raw"], {
+ cwd: dir,
+ stdin: "\n",
+ }).join();
+
+ expect(code).toBe(0);
+ expect(stdout).toContain("Hello from a component");
+ // The origin is an identity, never a path: nothing named it on disk,
+ // and the run left the directory exactly as it found it.
+ expect(yield* exists(path.join(dir, STANDARD_INPUT_PATH))).toBe(false);
+ expect(yield* entries(dir)).toEqual(before);
+ });
+ });
+
+ it("SI7: the ordinary run options apply to a document read from stdin", function* () {
+ const props = yield* runCli(["run", "-", "--raw", "--props-name", "Ada"], {
+ stdin: PROPS_DOCUMENT,
+ }).join();
+ expect(props.code).toBe(0);
+ expect(props.stdout).toContain("Hello Ada");
+
+ // Written after the sentinel, so this also proves the document argument
+ // no longer stops the parser: a dropped `--verbose` would echo nothing.
+ const verbose = yield* runCli(["run", "-", "--raw", "--verbose"], {
+ stdin: MARKER_DOCUMENT,
+ }).join();
+ expect(verbose.code).toBe(0);
+ expect(verbose.stderr).toContain("[yield] import_component:__root__");
+
+ const quiet = yield* runCli(["run", "--raw", "-"], { stdin: MARKER_DOCUMENT }).join();
+ expect(quiet.code).toBe(0);
+ expect(quiet.stderr).toBe("");
+ expect(quiet.stdout).toBe(MARKER_DOCUMENT);
+
+ yield* useFixture({}, function* (dir) {
+ // A malformed duration is fixed grammar, refused before anything is
+ // read — so the document that would have written a file never ran.
+ const malformed = yield* runCli(["run", "--timeout", "nope", "-"], {
+ cwd: dir,
+ stdin: EFFECTFUL_DOCUMENT,
+ }).join();
+ expect(malformed.code).toBe(1);
+ expect(malformed.stderr).toContain("--timeout must be a duration");
+ expect(yield* exists(path.join(dir, "written.txt"))).toBe(false);
+
+ const bounded = yield* runCli(["run", "--timeout", "5min", "--approve-reads", "-"], {
+ cwd: dir,
+ stdin: EFFECTFUL_DOCUMENT,
+ }).join();
+ expect(bounded.code).toBe(0);
+ expect(yield* exists(path.join(dir, "written.txt"))).toBe(true);
+ });
+
+ const permissions = yield* runCli(["run", "--approve-all", "--deny-all", "-"], {
+ stdin: MARKER_DOCUMENT,
+ }).join();
+ expect(permissions.code).toBe(1);
+ expect(permissions.stderr).toContain("mutually exclusive");
+ });
+
+ it("SI11: a value root read from stdin reserves stdout for its result", function* () {
+ const { code, stdout } = yield* runCli(["run", "-"], { stdin: VALUE_DOCUMENT }).join();
+ expect(code).toBe(0);
+ expect(stdout.trim()).toBe('{"ok":true}');
+ expect(stdout).not.toContain("rendered body");
+
+ const verbose = yield* runCli(["run", "-", "--verbose"], { stdin: VALUE_DOCUMENT }).join();
+ expect(verbose.stdout.trim()).toBe('{"ok":true}');
+ expect(verbose.stderr).toContain("rendered body");
+ });
+
+ it("SI10: only a selected stdin root reads, and reading it executes nothing", function* () {
+ yield* useFixture({}, function* (dir) {
+ const trace = path.join(dir, "trace.jsonl");
+
+ const generic = yield* controlledRun(
+ ["run", "--help", "--journal", trace],
+ dir,
+ succeeds(PROPS_DOCUMENT),
+ );
+ expect(generic.status).toBe(0);
+ expect(generic.reader.calls).toBe(0);
+ expect(generic.stdout).toContain("Usage: xmd run");
+ expect(generic.stdout).not.toContain("Properties declared by");
+
+ const selected = yield* controlledRun(
+ ["run", "-", "--help", "--journal", trace],
+ dir,
+ succeeds(PROPS_DOCUMENT),
+ );
+ expect(selected.status).toBe(0);
+ expect(selected.reader.calls).toBe(1);
+ expect(selected.stdout).toContain(`Properties declared by ${STANDARD_INPUT_PATH}`);
+ expect(selected.stdout).toContain("--props-name ");
+ // Help is inspection: no provider was wired in, and the journal it
+ // asked for was never created.
+ expect(selected.serviceInstalled).toBe(false);
+ expect(yield* exists(trace)).toBe(false);
+ });
+
+ // The same boundary through a real pipe, so the help path also reaches a
+ // genuine end of file rather than a value a stand-in already held.
+ const piped = yield* runCli(["run", "-", "--help"], { stdin: PROPS_DOCUMENT }).join();
+ expect(piped.code).toBe(0);
+ expect(piped.stdout).toContain(`Properties declared by ${STANDARD_INPUT_PATH}`);
+ });
+
+ it("SI8: a failed read reports the fixed sentence and reaches nothing after it", function* () {
+ yield* useFixture({ "source.txt": "READ_ME\n" }, function* (dir) {
+ const trace = path.join(dir, "trace.jsonl");
+ // One document that reads and one that writes, so a run that got past
+ // the reader leaves both kinds of trace behind.
+ const document = `\n\n${EFFECTFUL_DOCUMENT}`;
+ const args = ["run", "-", "--raw", "--no-secret-detection", "--journal", trace];
+
+ const failed = yield* controlledRun(args, dir, fails("PRIVATE-READER-DETAIL"));
+
+ expect(failed.status).toBe(1);
+ expect(failed.reader.calls).toBe(1);
+ expect(failed.stderr.trim()).toBe(STANDARD_INPUT_FAILURE);
+ expect(failed.stderr).not.toContain("PRIVATE-READER-DETAIL");
+ // The host's own error is the only thing the diagnostic could have
+ // leaked; the announcement, the provider, the journal, the root and the
+ // authored effect are the phases it must not have reached.
+ expect(failed.stderr).not.toContain("secret detection is disabled");
+ expect(failed.serviceInstalled).toBe(false);
+ expect(failed.reads).toEqual([]);
+ expect(yield* exists(trace)).toBe(false);
+ expect(yield* exists(path.join(dir, "written.txt"))).toBe(false);
+
+ // The positive control: the same argv and the same document, with a
+ // reader that answers — so every assertion above is about the failure
+ // rather than about a run that could never have done any of it.
+ const ok = yield* controlledRun(args, dir, succeeds(document));
+ expect(ok.status).toBe(undefined);
+ expect(ok.stderr).toContain("secret detection is disabled");
+ expect(ok.serviceInstalled).toBe(true);
+ // Suffix rather than the joined path: macOS resolves the temporary
+ // directory through `/private`, so the run's own read is the same file
+ // under a different spelling.
+ expect(ok.reads.filter((read) => read.endsWith("source.txt"))).toHaveLength(1);
+ expect(yield* exists(trace)).toBe(true);
+ expect(yield* exists(path.join(dir, "written.txt"))).toBe(true);
+ });
+ });
+
+ it("SI9: cancelling a waiting read tears it down and reports no failure", function* () {
+ yield* useFixture({}, function* (dir) {
+ const trace = path.join(dir, "trace.jsonl");
+ const waiting = waits();
+
+ const run = yield* cancellableRun(
+ ["run", "-", "--raw", "--journal", trace],
+ dir,
+ waiting.reader,
+ );
+ yield* waiting.started;
+ yield* run.task.halt();
+
+ // Halting waited for the reader's own teardown before it returned.
+ expect(waiting.torndown).toBe(true);
+ expect(run.state.status).toBe(undefined);
+ expect(run.state.stderr).toBe("");
+ expect(run.state.serviceInstalled).toBe(false);
+ expect(yield* exists(trace)).toBe(false);
+ expect(yield* exists(path.join(dir, "written.txt"))).toBe(false);
+ });
+ });
+
+ it("SI1b: the read waits for end of file and joins every chunk", function* () {
+ // A subprocess pipe delivers a small document in one chunk, so nothing a
+ // launcher can arrange separates "read to end of file" from "read the
+ // first chunk". A stream this row owns does: it holds bytes back, and
+ // splits one character across a chunk boundary on the way.
+ const stream = new PassThrough();
+ let settled: Result | undefined;
+ const read = yield* spawn(function* () {
+ settled = yield* readInputStream(stream);
+ });
+ yield* sleep(0);
+
+ stream.write("# One\n");
+ yield* sleep(0);
+ expect(settled).toBe(undefined);
+
+ stream.write(new Uint8Array([0xc3]));
+ stream.write(new Uint8Array([0xa9]));
+ yield* sleep(0);
+ expect(settled).toBe(undefined);
+
+ stream.end();
+ yield* read;
+
+ expect(settled).toEqual(Ok("# One\né"));
+ });
+
+ it("SI9b: the stream adapter's listeners belong to the read's own scope", function* () {
+ const stream = new PassThrough();
+ const read = yield* spawn(() => readInputStream(stream));
+ yield* sleep(0);
+
+ // A real stream, partly delivered: the read is waiting for an end of file
+ // that never comes.
+ stream.write("partial");
+ for (const event of ["data", "end", "close", "error"]) {
+ expect(stream.listenerCount(event)).toBe(1);
+ }
+
+ yield* read.halt();
+
+ for (const event of ["data", "end", "close", "error"]) {
+ expect(stream.listenerCount(event)).toBe(0);
+ }
+ expect(stream.isPaused()).toBe(true);
+ });
+ },
+);
+
+/**
+ * The exit continuation `exit()` reaches for. `main()` installs one under this
+ * name; a suite that drives `runXmd` directly installs its own so a command's
+ * status is a value rather than a process exit.
+ */
+const ExitContext = createContext<(result: { status: number }) => Operation>("exit");
+
+interface ControlledReader {
+ read: StandardInputReader;
+ /** How many times the run asked the host for standard input. */
+ calls: number;
+}
+
+function succeeds(source: string): ControlledReader {
+ const reader: ControlledReader = {
+ calls: 0,
+ // deno-lint-ignore require-yield
+ *read(): Operation> {
+ reader.calls += 1;
+ return Ok(source);
+ },
+ };
+ return reader;
+}
+
+function fails(detail: string): ControlledReader {
+ const reader: ControlledReader = {
+ calls: 0,
+ // deno-lint-ignore require-yield
+ *read(): Operation> {
+ reader.calls += 1;
+ return Err(new Error(detail));
+ },
+ };
+ return reader;
+}
+
+/** A reader that arrives, says so, and then waits for bytes that never come. */
+function waits(): {
+ reader: ControlledReader;
+ started: Operation;
+ readonly torndown: boolean;
+} {
+ const arrived = withResolvers();
+ const state = { torndown: false };
+ const reader: ControlledReader = {
+ calls: 0,
+ *read(): Operation> {
+ reader.calls += 1;
+ yield* ensure(() => {
+ state.torndown = true;
+ });
+ arrived.resolve();
+ yield* suspend();
+ throw new Error("the waiting reader resumed");
+ },
+ };
+ return {
+ reader,
+ started: arrived.operation,
+ get torndown() {
+ return state.torndown;
+ },
+ };
+}
+
+interface ControlledRun {
+ status: number | undefined;
+ stdout: string;
+ stderr: string;
+ /** Whether the host's provider installer ran. */
+ serviceInstalled: boolean;
+ /** Every path the run read for itself. */
+ reads: string[];
+}
+
+/**
+ * Everything one in-process `runXmd` invocation is observed through.
+ *
+ * The state object is handed over before the run finishes, because the
+ * cancellation row halts the task and then reads what it did.
+ */
+function* observedRun(
+ args: string[],
+ cwd: string,
+ reader: ControlledReader,
+ state: ControlledRun,
+): Operation {
+ const logged = console.log;
+ const written = console.error;
+ yield* ensure(() => {
+ console.log = logged;
+ console.error = written;
+ });
+ console.log = (...parts: unknown[]) => {
+ state.stdout += `${parts.map((part) => String(part)).join(" ")}\n`;
+ };
+ console.error = (...parts: unknown[]) => {
+ state.stderr += `${parts.map((part) => String(part)).join(" ")}\n`;
+ };
+
+ yield* ExitContext.set(function* (result) {
+ state.status = result.status;
+ });
+
+ yield* API.Fs.around({
+ *readTextFile([target], next) {
+ state.reads.push(target);
+ return yield* next(target);
+ },
+ });
+
+ yield* API.Env.around({
+ *cwd() {
+ return cwd;
+ },
+ });
+ yield* useHostFiles();
+
+ yield* runXmd(
+ args,
+ function* () {
+ state.serviceInstalled = true;
+ yield* Service.around({
+ *start() {
+ throw new Error("the run started a service");
+ },
+ });
+ },
+ SOURCE_UPGRADE,
+ unsupportedRepositories,
+ reader.read,
+ );
+}
+
+function empty(): ControlledRun {
+ return { status: undefined, stdout: "", stderr: "", serviceInstalled: false, reads: [] };
+}
+
+/** Drive one complete `runXmd` invocation and report what it did. */
+function* controlledRun(
+ args: string[],
+ cwd: string,
+ reader: ControlledReader,
+): Operation {
+ const state = empty();
+ yield* scoped(() => observedRun(args, cwd, reader, state));
+ return { ...state, reader };
+}
+
+/** Start one `runXmd` invocation and hand back the task that is running it. */
+function* cancellableRun(
+ args: string[],
+ cwd: string,
+ reader: ControlledReader,
+): Operation<{ task: Task; state: ControlledRun }> {
+ const state = empty();
+ const task = yield* spawn(() => scoped(() => observedRun(args, cwd, reader, state)));
+ return { task, state };
+}
diff --git a/packages/cli/tests/support/standard-input.ts b/packages/cli/tests/support/standard-input.ts
new file mode 100644
index 000000000..d28442e3e
--- /dev/null
+++ b/packages/cli/tests/support/standard-input.ts
@@ -0,0 +1,17 @@
+/**
+ * The standard-input reader a suite driving `runXmd` in-process stands in for.
+ *
+ * A runtime entrypoint supplies the one operation that reads a whole document
+ * from the process's own stdin, and `runXmd` requires one because reaching a
+ * host global from the shared CLI is exactly what the parameter exists to
+ * prevent. A suite about another command says the honest thing: nothing it runs
+ * asks for standard input, so a reader that was called at all is a defect
+ * rather than an unread value.
+ */
+
+import type { Operation, Result } from "effection";
+import type { StandardInputReader } from "../../src/standard-input.ts";
+
+export const refusedStandardInput: StandardInputReader = function* (): Operation> {
+ throw new Error("this run read standard input");
+};
diff --git a/packages/cli/tests/targets-cli.test.ts b/packages/cli/tests/targets-cli.test.ts
index f2fe1d9d7..d284b6b3b 100644
--- a/packages/cli/tests/targets-cli.test.ts
+++ b/packages/cli/tests/targets-cli.test.ts
@@ -21,6 +21,7 @@ import { API, Service, useHostFiles } from "@executablemd/runtime";
import { runCli } from "@executablemd/test-support/launch";
import { runXmd } from "../src/cli.ts";
import { SOURCE_UPGRADE } from "./support/upgrade-assembly.ts";
+import { refusedStandardInput } from "./support/standard-input.ts";
import { unsupportedRepositories } from "../src/run-repositories.ts";
function* useFixture(
@@ -589,6 +590,7 @@ function* replacingRun(
},
SOURCE_UPGRADE,
unsupportedRepositories,
+ refusedStandardInput,
);
return { status, stderr, serviceInstalled, serviceStarted, documentReads, reads };
@@ -764,6 +766,7 @@ function* helpRun(args: string[], cwd: string): Operation {
},
SOURCE_UPGRADE,
unsupportedRepositories,
+ refusedStandardInput,
);
return { status, stdout, stderr, serviceInstalled };
diff --git a/packages/test-support/launch.ts b/packages/test-support/launch.ts
index 4b606b81e..4ab368b71 100644
--- a/packages/test-support/launch.ts
+++ b/packages/test-support/launch.ts
@@ -1,8 +1,9 @@
import { exec } from "@effectionx/process";
import type { ProcessResult } from "@effectionx/process";
import { timebox } from "@effectionx/timebox";
-import { spawn } from "effection";
-import type { Operation } from "effection";
+import { Err, Ok, spawn, withResolvers } from "effection";
+import type { Operation, Result } from "effection";
+import { spawn as spawnChild } from "node:child_process";
import { loadavg } from "node:os";
import { join } from "node:path";
import process from "node:process";
@@ -82,6 +83,14 @@ export interface CliRunOptions {
inheritEnv?: boolean;
/** Milliseconds before the run is abandoned (default 60s). */
timeout?: number;
+ /**
+ * Exactly this text on the child's standard input, followed by end of file.
+ *
+ * Omitting it leaves the child's stdin as the launcher has always left it. An
+ * empty string is a value like any other: the child observes an immediate end
+ * of file rather than nothing at all.
+ */
+ stdin?: string;
}
/** A bounded run of `xmd`, synchronized like any `@effectionx/process` exec. */
@@ -148,6 +157,10 @@ function* bounded(
// whether the child hung before its first line or after its last.
const partial: PartialOutput = { stdout: "", stderr: "" };
const result = yield* timebox(limit, function* () {
+ const input = options.stdin;
+ if (input !== undefined) {
+ return yield* withInput(launch, options, partial, mode, input);
+ }
const child = yield* exec(launch.command, {
arguments: launch.arguments,
shell: launch.shell,
@@ -180,6 +193,77 @@ function text(bytes: Uint8Array): string {
return new TextDecoder().decode(bytes);
}
+/**
+ * The same bounded run, for a child that has to observe a real end of file.
+ *
+ * `@effectionx/process` publishes stdin as a writable that can send and never
+ * close, so a CLI reading to end of file would wait forever behind it. This
+ * path owns the child instead, writes the supplied text and closes the pipe, so
+ * what the CLI observes is the pipeline a caller would build. Everything else
+ * is the launcher's: the same environment, the same capture into the caller's
+ * accumulators, the same status handling, and a process group killed on the way
+ * out however the run ended.
+ */
+function* withInput(
+ launch: Launch,
+ options: CliRunOptions,
+ partial: PartialOutput,
+ mode: "join" | "expect",
+ input: string,
+): Operation {
+ const settled = withResolvers>();
+ const child = spawnChild(launch.command, launch.arguments ?? [], {
+ detached: true,
+ shell: launch.shell,
+ cwd: options.cwd,
+ env: cliEnv(options),
+ stdio: "pipe",
+ });
+ try {
+ child.stdout?.on("data", (chunk: Uint8Array) => {
+ partial.stdout += text(chunk);
+ });
+ child.stderr?.on("data", (chunk: Uint8Array) => {
+ partial.stderr += text(chunk);
+ });
+ child.on("error", (error: Error) => settled.resolve(Err(error)));
+ // A pipe the child could not use is the launcher's problem and not the
+ // run's: the close below is what the whole path exists for, and a broken
+ // one would otherwise raise on a process that is already reporting why.
+ child.stdin?.on("error", () => {});
+ child.on("close", (code: number | null, signal: string | null) =>
+ settled.resolve(
+ Ok({
+ ...(code === null ? {} : { code }),
+ ...(signal === null ? {} : { signal }),
+ }),
+ ),
+ );
+ child.stdin?.end(input);
+
+ const exit = yield* settled.operation;
+ if (!exit.ok) {
+ throw exit.error;
+ }
+ const status = { ...exit.value, stdout: partial.stdout, stderr: partial.stderr };
+ if (mode === "expect" && status.code !== 0) {
+ throw new Error(
+ `${launch.command} exited ${status.code ?? `on ${status.signal}`}\n` +
+ `${channel("stdout", status.stdout)}\n${channel("stderr", status.stderr)}`,
+ );
+ }
+ return status;
+ } finally {
+ try {
+ if (child.pid !== undefined) {
+ process.kill(-child.pid, "SIGTERM");
+ }
+ } catch {
+ // Already gone, which is the ordinary case once `close` has fired.
+ }
+ }
+}
+
/**
* A timeout is a host observation, not a CLI outcome, so the report carries
* what a diagnosis needs from the host: the machine's load — these deadlines
diff --git a/site/routes/docs/reference.tsx b/site/routes/docs/reference.tsx
index 956b4528a..6d5e86d4c 100644
--- a/site/routes/docs/reference.tsx
+++ b/site/routes/docs/reference.tsx
@@ -11,16 +11,32 @@ export default define.page(function Reference() {
CLI
- {"xmd run [options]\nxmd [options] # run is the default command\nxmd -e '' [options] # an inline document, no file needed"}
+ {"xmd run [options]\nxmd [options] # run is the default command\nxmd run - [options] # the document is read from standard input\nxmd -e '' [options] # an inline document, no file needed"}
+
+ A run takes its root document from exactly one of three inputs: a path,
+ standard input, or one --eval value.
+
+ -
+
-{" "}
+ — read the whole root document from standard input, to end of file.
+ Only the explicit xmd run - spelling does it, so a bare
+ {" "}
+ xmd - and a reference such as -#Section{" "}
+ are not it, and --eval -{" "}
+ is refused rather than read. Printed errors and source positions
+ report the origin as <stdin>, which is an identity
+ rather than a file: nothing of that name is created, and relative
+ imports and every other relative operation resolve from the directory
+ the command was run in.
+
-
--eval, -e{" "}
— execute the given markdown as the root document instead of a path.
- Exactly one of the two is required. Quote it so the shell passes one
- argument; printed errors report the source as{" "}
- <eval>, and relative paths resolve from the current
- directory.
+ Quote it so the shell passes one argument; printed errors report the
+ source as <eval>, and relative paths resolve from
+ the current directory.
-
--journal, -j{" "}
diff --git a/site/routes/index.tsx b/site/routes/index.tsx
index 4f3a8339f..05675afe4 100644
--- a/site/routes/index.tsx
+++ b/site/routes/index.tsx
@@ -938,6 +938,17 @@ export default define.page(function Home({ url }) {
Once the workflow says what you mean, run the program instead of
asking an agent to figure it out again.
+
+
+ A Plan is text, so it composes.{" "}
+ xmd plan{" "}
+ writes the approved program to standard output, and{" "}
+ xmd run -{" "}
+ takes a whole program from standard input — so the two are one
+ command when you want no file in between.
+
+
+
{/* Durability */}
diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md
index 84f6c97ed..3f7613229 100644
--- a/specs/executable-mdx-spec.md
+++ b/specs/executable-mdx-spec.md
@@ -4201,7 +4201,8 @@ rather than restating it. It scans no headings, matches no selector, projects no
body, and defines no error of its own.
**Help describes the document it is given.** `xmd run --help` is generic and
-reads nothing. Each of the three file-backed forms — `xmd run README.md --help`,
+reads nothing — including standard input, which a generic help never asks the
+host for. Each of the three file-backed forms — `xmd run README.md --help`,
`xmd run --help README.md`, and `xmd README.md --help` — inspects that document
once and answers with the ordinary run help and source grammar, then the
document-property section when it has one, then the target catalog when it
@@ -4222,9 +4223,9 @@ Targets in README.md
Each row is `formatDocumentReference(path, target)`, in source order, duplicates
retained, so a duplicate canonical path appears twice with its own description.
A section that states no description is listed all the same, because it is still
-selectable. There is no unqualified whole-document row. A targetless file and an
-inline document have no target section at all — an inline root is not a
-selectable document reference.
+selectable. There is no unqualified whole-document row. A targetless file, an inline document and a
+document read from standard input have no target section at all — neither
+supplied root is a selectable document reference.
A valid selector on the reference is validated and then the whole source
catalog is described, not only the section it named. An unreadable reference, a
@@ -4232,6 +4233,10 @@ missing or unreadable file, a parse or schema failure, and an invalid, unmatched
or ambiguous selector each exit nonzero with the existing diagnostic and
describe nothing.
+`xmd run - --help` is the same response for a supplied root: the document is
+acquired and inspected once, its properties are described under the ``
+origin, and nothing runs.
+
Help is inspection: it expands no body, evaluates no block, imports no body
component, resolves no property value, checks no required property, installs or
attaches no service, creates no journal, and performs no authored effect. No
@@ -4249,6 +4254,50 @@ Only a file-backed run argument is read as a reference. An inline `-e` document
is untargeted, and `xmd test` keeps its own path grammar: a test path containing
a literal `#` or `%` still names that file.
+##### Standard input is one document argument
+
+`xmd run -` and `xmd run -- -` read the root document from standard input:
+the whole stream to end of file, once, admitted as one complete root and then
+run through the ordinary run profile. It is what a program-producing command
+composes with:
+
+```text
+xmd plan "Prepare the release program." | xmd run -
+```
+
+The sentinel is one exact argument on one command form, decided by fixed grammar
+before anything is read. Only an invocation that explicitly names `run` and
+whose document argument is exactly `-` selects it, so a bare `xmd -` — the same
+parsed command, written as the shorthand — does not, `xmd run -#Section` is a
+reference and not the sentinel, `-` on another command keeps that command's
+meaning, and `xmd run --eval -` and `xmd -e -` keep their existing refusal and
+read nothing. The end-of-options separator is honoured, so `-` written after
+`--` is still the document argument rather than an option; everything else
+written around it configures the run as it would around a path, in either
+position.
+
+Acquisition happens before the document is inspected, and therefore before
+target and property preparation, Agent or provider setup, the secret-detection
+announcement, journal creation, root admission and execution. It sits inside the
+run's existing deadline rather than starting a lifecycle of its own. Anything
+the host cannot deliver a whole document through is one fixed sentence:
+
+```text
+xmd run could not read a complete document from standard input
+```
+
+No host error, input content, or substituted path appears in it, and a run that
+reports it has reached none of the phases above. Cancellation while the read is
+waiting is cancellation: the reader is torn down, nothing later is reached, and
+it is never reported as a read failure.
+
+After that the root is ordinary. Empty input is the ordinary empty text root and
+succeeds having emitted and done nothing; complete structural preflight still
+finishes before the first document effect, so a declaration violation later in
+the input costs an effect written above it; and props, output and return
+behavior, journaling, timeouts, permission modes, providers, cancellation and
+presentation are the ones every other root gets.
+
**The selector is replaced by its answer before anything executes.** The command
inspects the document to discover its properties, and the run then reads the
file again. What execution is asked for is the exact canonical target that
@@ -4304,8 +4353,9 @@ children run through the production run host; the tier launcher runs it once
per runtime corpus. Tier CH covers the help surfaces and the absence of
a `targets` command, Tier PC properties and targets in one response, Tier VR
targeted value and `