@pepys/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,36 @@
1
- # Temporary Holding Version
1
+ <div align="center">
2
+ <h1>@pepys/cli</h1>
2
3
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
4
+ <p>
5
+ <a href="https://www.npmjs.com/package/@pepys/cli"><img src="https://img.shields.io/npm/v/@pepys/cli" alt="npm version"></a>
6
+ <a href="../../LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
7
+ </p>
8
+
9
+ <p>
10
+ <a href="../../docs/README.md">Documentation</a>
11
+ </p>
12
+ </div>
13
+
14
+ The `pepys` command line, for Bun.
15
+
16
+ `pepys build` checks a deployment or a plug-in, and writes the glue of each consumer: its boundary
17
+ types and its SQL schema. With `--out`, it also compiles each consumer with scriptc and writes
18
+ `consumers.json`.
19
+
20
+ ## Install
21
+
22
+ ```sh
23
+ bun add -d @pepys/cli
24
+ bunx pepys build src/plugin.ts
25
+ ```
26
+
27
+ ## Features
28
+
29
+ - `pepys build <file>`: check the deployment or plug-in that the file exports, and write its glue.
30
+ - `pepys build <file> --out <directory>`: also compile each consumer, which needs zig and
31
+ wasm-tools.
32
+ - `pepys build <file> --check`: exit 1 when a glue file is stale.
33
+
34
+ ## Documentation
35
+
36
+ The [pepys docs](../../docs/README.md) hold the design of `pepys build`.
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@pepys/cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "The pepys command line: pepys build checks a deployment or a plug-in, writes the glue of each consumer, and compiles each consumer with scriptc",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/zgeoff/pepys.git",
9
+ "directory": "packages/cli"
10
+ },
11
+ "bin": {
12
+ "pepys": "./src/bin.ts"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "!src/**/*.test.ts",
17
+ "!src/**/test-utils"
18
+ ],
19
+ "type": "module",
20
+ "exports": "./src/index.ts",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "dependencies": {
25
+ "@pepys/kit": "0.1.0",
26
+ "@pepys/schema": "0.1.0",
27
+ "@scriptc/runtime-wasm32-wasi": "0.4.5",
28
+ "@zgeoff/std": "0.4.3",
29
+ "scriptc": "0.4.5",
30
+ "ts-pattern": "5.9.2"
31
+ },
32
+ "devDependencies": {
33
+ "tiny-invariant": "1.3.3",
34
+ "zod": "4.6.7"
35
+ }
36
+ }
package/src/bin.ts ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { runCLI } from './run-cli.ts';
3
+
4
+ process.exitCode = await runCLI(process.argv.slice(2));
@@ -0,0 +1,50 @@
1
+ import type { VersionHash } from '@pepys/kit';
2
+ import type { ConsumerBuildManifest } from '@pepys/schema';
3
+ import invariant from 'tiny-invariant';
4
+ import { buildConsumerManifest } from './build-consumer-manifest.ts';
5
+ import { buildVersionHash } from './build-version-hash.ts';
6
+ import type { CheckedTarget } from './check-build-target.ts';
7
+
8
+ export interface BuiltModule {
9
+ name: string;
10
+
11
+ // the SHA-256 of the module's bytes
12
+ module: VersionHash;
13
+
14
+ // the module file, relative to the folder of consumers.json
15
+ path: string;
16
+ }
17
+
18
+ export interface BuildManifestParameters {
19
+ target: CheckedTarget;
20
+ modules: readonly BuiltModule[];
21
+ }
22
+
23
+ // `consumers.json`: one whole entry per consumer of the target, in the target's order.
24
+ export function buildConsumerBuildManifest(
25
+ parameters: BuildManifestParameters,
26
+ ): ConsumerBuildManifest {
27
+ const modules = new Map(parameters.modules.map((built) => [built.name, built]));
28
+
29
+ return {
30
+ kit: 1,
31
+ deployment: parameters.target.deployment,
32
+ consumers: parameters.target.checked.consumers.map((consumer) => {
33
+ const built = modules.get(consumer.name);
34
+
35
+ invariant(built !== undefined, `the build compiled no module for ${consumer.name}`);
36
+
37
+ const manifest = buildConsumerManifest(consumer);
38
+ const pins = {};
39
+
40
+ return {
41
+ name: consumer.name,
42
+ module: built.module,
43
+ path: built.path,
44
+ pins,
45
+ version: buildVersionHash({ module: built.module, pins, manifest }),
46
+ manifest,
47
+ };
48
+ }),
49
+ };
50
+ }
@@ -0,0 +1,50 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import type { ConsumerDeclaration, FactFamily, SettingValue } from '@pepys/kit';
3
+ import { generateBoundaryGlue, generateSqliteSchema } from '@pepys/kit/generators';
4
+
5
+ export interface GlueFile {
6
+ // an absolute path
7
+ path: string;
8
+ content: string;
9
+ }
10
+
11
+ export interface ConsumerGlueParameters {
12
+ consumer: ConsumerDeclaration;
13
+
14
+ // the families of the deployment
15
+ families: readonly FactFamily[];
16
+
17
+ // the checked settings of the consumer's plug-in, or undefined when it declares none
18
+ settings: Readonly<Record<string, SettingValue>> | undefined;
19
+ }
20
+
21
+ // The glue of a consumer sits in the `generated` folder beside the folder of its entry file:
22
+ // `<consumer>-boundary.ts` and `<consumer>.sql`.
23
+ export function buildConsumerGlue(parameters: ConsumerGlueParameters): GlueFile[] {
24
+ const consumer = parameters.consumer;
25
+ const stem = consumer.name.replaceAll('_', '-');
26
+
27
+ const folder = new URL('../generated/', consumer.entry);
28
+
29
+ return [
30
+ {
31
+ path: fileURLToPath(new URL(`${stem}-boundary.ts`, folder)),
32
+ content: withHeader(
33
+ '//',
34
+ generateBoundaryGlue({
35
+ consumer,
36
+ families: parameters.families,
37
+ settings: parameters.settings,
38
+ }),
39
+ ),
40
+ },
41
+ {
42
+ path: fileURLToPath(new URL(`${stem}.sql`, folder)),
43
+ content: withHeader('--', generateSqliteSchema(consumer)),
44
+ },
45
+ ];
46
+ }
47
+
48
+ function withHeader(comment: '//' | '--', body: string): string {
49
+ return `${comment} Generated by \`pepys build\`. Do not edit.\n\n${body}`;
50
+ }
@@ -0,0 +1,52 @@
1
+ import type { ConsumerDeclaration, ConsumerInput, TableDeclaration } from '@pepys/kit';
2
+ import { generateSqliteSchema } from '@pepys/kit/generators';
3
+ import type { ConsumerManifest, ManifestTable } from '@pepys/schema';
4
+ import { match } from 'ts-pattern';
5
+ import { buildModuleSymbols } from './build-module-symbols.ts';
6
+
7
+ // What the host knows of one consumer. Each field is copied by name, so a field that a later kit
8
+ // adds to a declaration never reaches the file unseen.
9
+ export function buildConsumerManifest(consumer: ConsumerDeclaration): ConsumerManifest {
10
+ const symbols = buildModuleSymbols(consumer);
11
+
12
+ return {
13
+ name: consumer.name,
14
+ input: toManifestInput(consumer.input),
15
+ exports: symbols.exports,
16
+ imports: symbols.imports,
17
+ tables: Object.fromEntries(
18
+ Object.entries(consumer.tables).map(([name, table]) => [name, toManifestTable(table)]),
19
+ ),
20
+ links: consumer.links.map((link) => ({
21
+ name: link.name,
22
+ from: [...link.from],
23
+ to: [...link.to],
24
+ hub: link.hub,
25
+ })),
26
+ schema: generateSqliteSchema(consumer),
27
+ };
28
+ }
29
+
30
+ function toManifestInput(input: ConsumerInput): ConsumerInput {
31
+ return match(input)
32
+ .with({ mode: 'fact' }, (fact) => ({ mode: fact.mode, streams: [...fact.streams] }))
33
+ .with({ mode: 'scope' }, (scope) => ({
34
+ mode: scope.mode,
35
+ scopeKind: scope.scopeKind,
36
+ streams: [...scope.streams],
37
+ }))
38
+ .exhaustive();
39
+ }
40
+
41
+ function toManifestTable(table: TableDeclaration): ManifestTable {
42
+ return {
43
+ columns: Object.fromEntries(
44
+ Object.entries(table.columns).map(([name, column]) => [
45
+ name,
46
+ { kind: column.kind, nullable: column.nullable === true },
47
+ ]),
48
+ ),
49
+ primaryKey: [...table.primaryKey],
50
+ indexes: table.indexes.map((index) => [...index.columns]),
51
+ };
52
+ }
@@ -0,0 +1,41 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+ import { toPascalCase } from '@pepys/kit/generators';
3
+ import type { ConsumerManifest, ManifestRead } from '@pepys/schema';
4
+ import { match } from 'ts-pattern';
5
+
6
+ export interface ModuleSymbols {
7
+ // the function that the entry file exports for the host to call
8
+ entryExport: 'applyFact' | 'applyScope';
9
+ exports: ConsumerManifest['exports'];
10
+ imports: ManifestRead[];
11
+ }
12
+
13
+ // The one source of the module's symbols: the scriptc profile compiles them, and the manifest tells
14
+ // the host to call and serve them. The read symbols are the names the generated glue declares.
15
+ export function buildModuleSymbols(consumer: ConsumerDeclaration): ModuleSymbols {
16
+ const prefix = `${consumer.name}_`;
17
+
18
+ return match(consumer.input.mode)
19
+ .with('fact', () => ({
20
+ entryExport: 'applyFact' as const,
21
+ exports: { init: `${prefix}init`, apply: `${prefix}apply_fact` },
22
+ imports: consumer.reads.map((table) => ({
23
+ kind: 'row' as const,
24
+ table,
25
+ length: `read${toPascalCase(table)}Length`,
26
+ into: `read${toPascalCase(table)}Into`,
27
+ })),
28
+ }))
29
+ .with('scope', () => ({
30
+ entryExport: 'applyScope' as const,
31
+ exports: { init: `${prefix}init`, apply: `${prefix}apply_scope` },
32
+ imports: [
33
+ {
34
+ kind: 'scope_facts' as const,
35
+ length: 'readScopeFactsLength',
36
+ into: 'readScopeFactsInto',
37
+ },
38
+ ],
39
+ }))
40
+ .exhaustive();
41
+ }
@@ -0,0 +1,62 @@
1
+ import type { ConsumerDeclaration } from '@pepys/kit';
2
+ import { toPascalCase } from '@pepys/kit/generators';
3
+ import { buildModuleSymbols } from './build-module-symbols.ts';
4
+ import { consumerFences } from './consumer-fences.ts';
5
+
6
+ interface ScriptcProfileParameters {
7
+ consumer: ConsumerDeclaration;
8
+
9
+ // scriptc reads the entry relative to the folder of the profile file
10
+ entry: string;
11
+ }
12
+
13
+ // The scriptc library profile of one consumer: the entry, the symbols of its exports and of the
14
+ // host functions it imports, and the determinism fences.
15
+ export function buildScriptcProfile(parameters: ScriptcProfileParameters): object {
16
+ const name = parameters.consumer.name;
17
+ const prefix = `${name}_`;
18
+ const pascalName = toPascalCase(name);
19
+ const symbols = buildModuleSymbols(parameters.consumer);
20
+
21
+ const callbacks = symbols.imports.flatMap((read) => [
22
+ { name: read.length, params: ['string'], returns: 'f64' },
23
+ { name: read.into, params: ['string', 'bytes'], returns: 'void' },
24
+ ]);
25
+
26
+ return {
27
+ profile_format: 1,
28
+ name,
29
+ entry: parameters.entry,
30
+ emission: 'llvm',
31
+ abi: {
32
+ prefix,
33
+ init_symbol: symbols.exports.init,
34
+ sink_register_symbol: `${prefix}sink`,
35
+
36
+ // scriptc refuses a register symbol with no callbacks to register (SC4001)
37
+ ...(callbacks.length === 0 ? {} : { callback_register_symbol: `${prefix}callback` }),
38
+ },
39
+ callbacks,
40
+ exports: [
41
+ {
42
+ export: symbols.entryExport,
43
+ symbol: symbols.exports.apply,
44
+ params: ['string'],
45
+ returns: 'string',
46
+ },
47
+ ],
48
+ sidecar: {
49
+ wire_version: 1,
50
+ abi_version: 1,
51
+ snapshot_format: 1,
52
+ build_id_symbol: `${prefix}build_id`,
53
+ abi_version_symbol: `${prefix}abi_version`,
54
+ model: `${pascalName}Model`,
55
+
56
+ // scriptc names the sidecar's message type field msg
57
+ // oxlint-disable-next-line zgeoff/no-abbreviated-names
58
+ msg: `${pascalName}Message`,
59
+ },
60
+ determinism: { fences: consumerFences },
61
+ };
62
+ }
@@ -0,0 +1,29 @@
1
+ import type { VersionHash } from '@pepys/kit';
2
+ import type { ConsumerVersion } from '@pepys/schema';
3
+ import { toTagged } from '@zgeoff/std';
4
+
5
+ // The SHA-256 of the module's hash, the pins and the manifest, as JSON with sorted keys. A changed
6
+ // index or link type with the same module bytes is therefore a new version.
7
+ export function buildVersionHash(
8
+ parts: Pick<ConsumerVersion, 'module' | 'pins' | 'manifest'>,
9
+ ): VersionHash {
10
+ const text = JSON.stringify(toSortedValue(parts));
11
+
12
+ return toTagged<VersionHash>(new Bun.CryptoHasher('sha256').update(text).digest('hex'));
13
+ }
14
+
15
+ function toSortedValue(value: unknown): unknown {
16
+ if (Array.isArray(value)) {
17
+ return value.map((item: unknown) => toSortedValue(item));
18
+ }
19
+
20
+ if (typeof value === 'object' && value !== null) {
21
+ return Object.fromEntries(
22
+ Object.entries(value)
23
+ .toSorted(([first], [second]) => (first < second ? -1 : 1))
24
+ .map(([key, item]: [string, unknown]) => [key, toSortedValue(item)]),
25
+ );
26
+ }
27
+
28
+ return value;
29
+ }
@@ -0,0 +1,76 @@
1
+ import { checkDeployment } from '@pepys/kit';
2
+ import type { CheckedDeployment, DeploymentFailure, Plugin, Result } from '@pepys/kit';
3
+ import type { BuildTarget } from './pick-build-target.ts';
4
+
5
+ export interface CheckedTarget {
6
+ // the name of the deployment, or null for a plug-in built alone
7
+ deployment: string | null;
8
+ checked: CheckedDeployment;
9
+ plugins: readonly Plugin[];
10
+ }
11
+
12
+ // These checks name a part that another plug-in can bring, so a plug-in built alone cannot fail
13
+ // them.
14
+ const needsOtherPlugins: ReadonlySet<DeploymentFailure['kind']> = new Set([
15
+ 'unknown-entity-kind',
16
+ 'missing-consumer',
17
+ 'missing-stream',
18
+ ]);
19
+
20
+ export function checkBuildTarget(
21
+ target: BuildTarget,
22
+ ): Result<CheckedTarget, readonly DeploymentFailure[]> {
23
+ if (target.kind === 'deployment') {
24
+ const checked = checkDeployment(target.deployment);
25
+
26
+ return checked.ok
27
+ ? {
28
+ ok: true,
29
+ value: {
30
+ deployment: target.deployment.name,
31
+ checked: checked.value,
32
+ plugins: target.deployment.plugins,
33
+ },
34
+ }
35
+ : checked;
36
+ }
37
+
38
+ const plugin = target.plugin;
39
+ const checked = checkDeployment({ name: plugin.name, plugins: [plugin] });
40
+
41
+ if (checked.ok) {
42
+ return { ok: true, value: { deployment: null, checked: checked.value, plugins: [plugin] } };
43
+ }
44
+
45
+ const failures = checked.failure.filter((failure) => !needsOtherPlugins.has(failure.kind));
46
+
47
+ if (failures.length > 0) {
48
+ return { ok: false, failure: failures };
49
+ }
50
+
51
+ // The plug-in is sound alone. Checked with no parts, it still gives its settings.
52
+ const settings = checkDeployment({
53
+ name: plugin.name,
54
+ plugins: [
55
+ { ...plugin, sources: [], consumers: [], queries: [], linkTypes: [], entityKinds: [] },
56
+ ],
57
+ });
58
+
59
+ return settings.ok
60
+ ? {
61
+ ok: true,
62
+ value: {
63
+ deployment: null,
64
+ checked: {
65
+ ...settings.value,
66
+ sources: plugin.sources,
67
+ consumers: plugin.consumers,
68
+ queries: plugin.queries,
69
+ linkTypes: plugin.linkTypes,
70
+ entityKinds: plugin.entityKinds,
71
+ },
72
+ plugins: [plugin],
73
+ },
74
+ }
75
+ : settings;
76
+ }
@@ -0,0 +1,7 @@
1
+ const forbiddenImport = /\(import "[^"]+" "(?:random_get|poll_oneoff|sock_[a-z_]+|path_open)"/gu;
2
+
3
+ // A consumer reads the clock the host binds to a constant and nothing else outside its own memory:
4
+ // no random source, no polling, no socket and no file.
5
+ export function collectForbiddenImports(text: string): string[] {
6
+ return [...text.matchAll(forbiddenImport)].map((match) => match[0].replace('(import ', ''));
7
+ }
@@ -0,0 +1,36 @@
1
+ import { fileURLToPath } from 'node:url';
2
+ import { buildConsumerGlue } from './build-consumer-glue.ts';
3
+ import type { GlueFile } from './build-consumer-glue.ts';
4
+ import type { CheckedTarget } from './check-build-target.ts';
5
+
6
+ export interface GlueParameters {
7
+ target: CheckedTarget;
8
+
9
+ // The folder of the package that the command runs in. The glue of a consumer whose entry file is
10
+ // outside it, or under a node_modules folder, is not written here.
11
+ packageRoot: string;
12
+ }
13
+
14
+ // The glue that the package commits: that of each consumer whose entry file is the package's own.
15
+ export function collectGlueFiles(parameters: GlueParameters): GlueFile[] {
16
+ const families = parameters.target.checked.sources.flatMap((source) => source.families);
17
+ const root = `${parameters.packageRoot.replace(/\/+$/u, '')}/`;
18
+
19
+ return parameters.target.plugins.flatMap((plugin) =>
20
+ plugin.consumers
21
+ .filter((consumer) => isOwnConsumer(consumer.entry, root))
22
+ .flatMap((consumer) =>
23
+ buildConsumerGlue({
24
+ consumer,
25
+ families,
26
+ settings: parameters.target.checked.settings[plugin.name],
27
+ }),
28
+ ),
29
+ );
30
+ }
31
+
32
+ function isOwnConsumer(entry: URL, root: string): boolean {
33
+ const path = fileURLToPath(entry);
34
+
35
+ return path.startsWith(root) && !path.includes('/node_modules/');
36
+ }
@@ -0,0 +1,150 @@
1
+ import { cp, mkdir, rm } from 'node:fs/promises';
2
+ import { dirname, join, relative, sep } from 'node:path';
3
+ import { fileURLToPath, pathToFileURL } from 'node:url';
4
+ import type {
5
+ ConsumerDeclaration,
6
+ FactFamily,
7
+ Result,
8
+ SettingValue,
9
+ VersionHash,
10
+ } from '@pepys/kit';
11
+ import { toTagged } from '@zgeoff/std';
12
+ import type { BuiltModule } from './build-consumer-build-manifest.ts';
13
+ import { buildConsumerGlue } from './build-consumer-glue.ts';
14
+ import { buildScriptcProfile } from './build-scriptc-profile.ts';
15
+ import { collectForbiddenImports } from './collect-forbidden-imports.ts';
16
+ import { readPackageRoot } from './read-package-root.ts';
17
+
18
+ export interface BuildTools {
19
+ scriptc: string;
20
+ zig: string;
21
+ wasmTools: string;
22
+ }
23
+
24
+ export interface CompileParameters {
25
+ consumer: ConsumerDeclaration;
26
+ families: readonly FactFamily[];
27
+ settings: Readonly<Record<string, SettingValue>> | undefined;
28
+ out: string;
29
+ tools: BuildTools;
30
+ }
31
+
32
+ // Compiles a copy of the package's `src` at `<out>/.work/<consumer>`, with this build's glue in it.
33
+ // scriptc builds the entry's absolute path into the module, so a fixed folder gives the same module
34
+ // hash on every build of the same source.
35
+ export async function compileConsumer(
36
+ parameters: CompileParameters,
37
+ ): Promise<Result<BuiltModule, string>> {
38
+ const consumer = parameters.consumer;
39
+ const entryPath = fileURLToPath(consumer.entry);
40
+
41
+ const packageRoot = await readPackageRoot(dirname(entryPath));
42
+
43
+ if (packageRoot === undefined) {
44
+ return { ok: false, failure: `the entry ${entryPath} is in no package` };
45
+ }
46
+
47
+ const entryInSource = relative(join(packageRoot, 'src'), entryPath);
48
+
49
+ if (entryInSource.startsWith(`..${sep}`)) {
50
+ return {
51
+ ok: false,
52
+ failure: `the entry ${entryPath} is outside the src folder of its package`,
53
+ };
54
+ }
55
+
56
+ const work = join(parameters.out, '.work', consumer.name);
57
+
58
+ await rm(work, { recursive: true, force: true });
59
+ await cp(join(packageRoot, 'src'), join(work, 'src'), { recursive: true });
60
+
61
+ const glue = buildConsumerGlue({
62
+ consumer: { ...consumer, entry: pathToFileURL(join(work, 'src', entryInSource)) },
63
+ families: parameters.families,
64
+ settings: parameters.settings,
65
+ });
66
+
67
+ for (const file of glue) {
68
+ await mkdir(dirname(file.path), { recursive: true });
69
+
70
+ await Bun.write(file.path, file.content);
71
+ }
72
+
73
+ const profile = buildScriptcProfile({ consumer, entry: `src/${entryInSource}` });
74
+
75
+ await Bun.write(join(work, 'profile.json'), JSON.stringify(profile));
76
+
77
+ const modulePath = join(work, 'module.wasm');
78
+
79
+ const compiled = await runScriptc({ work, modulePath, tools: parameters.tools });
80
+
81
+ if (!compiled.ok) {
82
+ return { ok: false, failure: `scriptc refused ${consumer.name}:\n${compiled.failure}` };
83
+ }
84
+
85
+ const contract: unknown = await Bun.file(`${modulePath}.contract.json`).json();
86
+
87
+ if (!isAttestedDeterministic(contract)) {
88
+ return { ok: false, failure: `scriptc does not attest ${consumer.name} deterministic` };
89
+ }
90
+
91
+ const text = await Bun.$`${parameters.tools.wasmTools} print ${modulePath}`.quiet().text();
92
+
93
+ const forbidden = collectForbiddenImports(text);
94
+
95
+ if (forbidden.length > 0) {
96
+ return { ok: false, failure: `${consumer.name} imports ${forbidden.join(', ')}` };
97
+ }
98
+
99
+ const bytes = await Bun.file(modulePath).bytes();
100
+
101
+ const module = toTagged<VersionHash>(new Bun.CryptoHasher('sha256').update(bytes).digest('hex'));
102
+ const path = `${consumer.name}/${module}.wasm`;
103
+
104
+ await mkdir(join(parameters.out, consumer.name), { recursive: true });
105
+
106
+ await Bun.write(join(parameters.out, path), bytes);
107
+
108
+ return { ok: true, value: { name: consumer.name, module, path } };
109
+ }
110
+
111
+ interface ScriptcRun {
112
+ work: string;
113
+ modulePath: string;
114
+ tools: BuildTools;
115
+ }
116
+
117
+ async function runScriptc(run: ScriptcRun): Promise<Result<undefined, string>> {
118
+ const child = Bun.spawn(
119
+ [run.tools.scriptc, 'build', '--lib', '--profile', 'profile.json', '-o', run.modulePath],
120
+ {
121
+ cwd: run.work,
122
+ env: {
123
+ ...process.env,
124
+ SCRIPTC_TARGET: 'wasm32-wasi',
125
+ PATH: `${dirname(run.tools.zig)}:${process.env['PATH'] ?? ''}`,
126
+ },
127
+ stdout: 'pipe',
128
+ stderr: 'pipe',
129
+ },
130
+ );
131
+
132
+ const [exitCode, stdout, stderr] = await Promise.all([
133
+ child.exited,
134
+ new Response(child.stdout).text(),
135
+ new Response(child.stderr).text(),
136
+ ]);
137
+
138
+ return exitCode === 0
139
+ ? { ok: true, value: undefined }
140
+ : { ok: false, failure: `${stdout}${stderr}` };
141
+ }
142
+
143
+ function isAttestedDeterministic(contract: unknown): boolean {
144
+ return (
145
+ typeof contract === 'object' &&
146
+ contract !== null &&
147
+ 'deterministic' in contract &&
148
+ contract.deterministic === true
149
+ );
150
+ }
@@ -0,0 +1,34 @@
1
+ // The determinism fences of the scriptc library profile: the Date, performance, process, random,
2
+ // filesystem and OS APIs that a consumer may never call.
3
+ export const consumerFences = [
4
+ { prefix: 'stdlib.date.' },
5
+ { prefix: 'node-builtin.perf_hooks.performance.' },
6
+ { prefix: 'node-builtin.process.' },
7
+ { prefix: 'node-builtin.crypto.random' },
8
+ { prefix: 'node-builtin.fs.' },
9
+ { id: 'stdlib.math.random' },
10
+ { id: 'stdlib.file' },
11
+ { id: 'node-builtin.tls.getCACertificates' },
12
+ { id: 'node-builtin.tls.rootCertificates' },
13
+ { id: 'node-builtin.tls.setDefaultCACertificates' },
14
+ { id: 'node-builtin.os.arch' },
15
+ { id: 'node-builtin.os.availableParallelism' },
16
+ { id: 'node-builtin.os.constants' },
17
+ { id: 'node-builtin.os.cpus' },
18
+ { id: 'node-builtin.os.endianness' },
19
+ { id: 'node-builtin.os.freemem' },
20
+ { id: 'node-builtin.os.getPriority' },
21
+ { id: 'node-builtin.os.homedir' },
22
+ { id: 'node-builtin.os.hostname' },
23
+ { id: 'node-builtin.os.loadavg' },
24
+ { id: 'node-builtin.os.machine' },
25
+ { id: 'node-builtin.os.networkInterfaces' },
26
+ { id: 'node-builtin.os.platform' },
27
+ { id: 'node-builtin.os.release' },
28
+ { id: 'node-builtin.os.tmpdir' },
29
+ { id: 'node-builtin.os.totalmem' },
30
+ { id: 'node-builtin.os.type' },
31
+ { id: 'node-builtin.os.uptime' },
32
+ { id: 'node-builtin.os.userInfo' },
33
+ { id: 'node-builtin.os.version' },
34
+ ];
@@ -0,0 +1,81 @@
1
+ import type { DeploymentFailure } from '@pepys/kit';
2
+ import { match } from 'ts-pattern';
3
+ import type { BuildOutcome } from './run-build.ts';
4
+
5
+ export interface FormattedOutcome {
6
+ exitCode: 0 | 1;
7
+ text: string;
8
+ }
9
+
10
+ const toolVariables = { zig: 'PEPYS_ZIG_BIN', 'wasm-tools': 'PEPYS_WASM_TOOLS_BIN' } as const;
11
+
12
+ export function formatBuildOutcome(outcome: BuildOutcome): FormattedOutcome {
13
+ return match(outcome)
14
+ .with({ kind: 'built' }, (built) => ({
15
+ exitCode: 0 as const,
16
+ text: [
17
+ `wrote ${built.written.length} glue files, ${built.unchanged.length} unchanged`,
18
+ ...built.written.map((path) => ` ${path}`),
19
+ '',
20
+ ].join('\n'),
21
+ }))
22
+ .with({ kind: 'compiled' }, (compiled) => ({
23
+ exitCode: 0 as const,
24
+ text: [
25
+ `wrote ${compiled.written.length} glue files, ${compiled.unchanged.length} unchanged`,
26
+ ...compiled.written.map((path) => ` ${path}`),
27
+ `compiled ${compiled.modules.length} consumers`,
28
+ ...compiled.modules.map((built) => ` ${built.name}: ${built.path}`),
29
+ `wrote ${compiled.manifest}`,
30
+ '',
31
+ ].join('\n'),
32
+ }))
33
+ .with({ kind: 'missing-tool' }, (missing) => ({
34
+ exitCode: 1 as const,
35
+ text: `the glue is written; compiling needs ${missing.tool}, from ${toolVariables[missing.tool]} or PATH\n`,
36
+ }))
37
+ .with({ kind: 'compile-failed' }, (failed) => ({
38
+ exitCode: 1 as const,
39
+ text: `${failed.consumer} did not compile:\n${failed.reason}\n`,
40
+ }))
41
+ .with({ kind: 'refused-manifest' }, (refused) => ({
42
+ exitCode: 1 as const,
43
+ text: [
44
+ 'the host would refuse this consumers.json, so the build wrote none:',
45
+ ...refused.issues.map((issue) => ` ${issue}`),
46
+ '',
47
+ ].join('\n'),
48
+ }))
49
+ .with({ kind: 'fresh' }, (fresh) => ({
50
+ exitCode: 0 as const,
51
+ text: `${fresh.count} glue files are up to date\n`,
52
+ }))
53
+ .with({ kind: 'stale' }, (stale) => ({
54
+ exitCode: 1 as const,
55
+ text: [
56
+ 'glue files differ from the declarations; run `pepys build`:',
57
+ ...stale.stale.map((path) => ` ${path}`),
58
+ '',
59
+ ].join('\n'),
60
+ }))
61
+ .with({ kind: 'no-target' }, (missing) => ({
62
+ exitCode: 1 as const,
63
+ text:
64
+ missing.reason === 'no-target'
65
+ ? 'the file exports no Deployment and no Plugin\n'
66
+ : 'the file exports more than one Deployment or Plugin\n',
67
+ }))
68
+ .with({ kind: 'invalid' }, (invalid) => ({
69
+ exitCode: 1 as const,
70
+ text: [
71
+ 'the declarations are invalid:',
72
+ ...invalid.failures.map((failure) => ` ${formatFailure(failure)}`),
73
+ '',
74
+ ].join('\n'),
75
+ }))
76
+ .exhaustive();
77
+ }
78
+
79
+ function formatFailure(failure: DeploymentFailure): string {
80
+ return JSON.stringify(failure);
81
+ }
@@ -0,0 +1,45 @@
1
+ import { parseArgs } from 'node:util';
2
+ import type { Result } from '@pepys/kit';
3
+
4
+ export interface BuildArguments {
5
+ file: string;
6
+ isCheck: boolean;
7
+ out: string | undefined;
8
+ }
9
+
10
+ export const usage = 'usage: pepys build <file> [--out <directory>] [--check]';
11
+
12
+ export function parseBuildArguments(args: readonly string[]): Result<BuildArguments, string> {
13
+ const [command, ...rest] = args;
14
+
15
+ if (command !== 'build') {
16
+ return { ok: false, failure: usage };
17
+ }
18
+
19
+ try {
20
+ const parsed = parseArgs({
21
+ args: rest,
22
+ allowPositionals: true,
23
+ options: { check: { type: 'boolean' }, out: { type: 'string' } },
24
+ });
25
+
26
+ const [file, ...extra] = parsed.positionals;
27
+
28
+ if (file === undefined || extra.length > 0) {
29
+ return { ok: false, failure: usage };
30
+ }
31
+
32
+ const isCheck = parsed.values.check === true;
33
+
34
+ // --check writes nothing, so it takes no folder to write to
35
+ if (isCheck && parsed.values.out !== undefined) {
36
+ return { ok: false, failure: `--check takes no --out\n${usage}` };
37
+ }
38
+
39
+ return { ok: true, value: { file, isCheck, out: parsed.values.out } };
40
+ } catch (error) {
41
+ const reason = error instanceof Error ? error.message : String(error);
42
+
43
+ return { ok: false, failure: `${reason}\n${usage}` };
44
+ }
45
+ }
@@ -0,0 +1,55 @@
1
+ import type { Deployment, Plugin, Result } from '@pepys/kit';
2
+ import { isRecord } from '@zgeoff/std';
3
+
4
+ export type BuildTarget =
5
+ | { kind: 'deployment'; deployment: Deployment }
6
+ | { kind: 'plugin'; plugin: Plugin };
7
+
8
+ export type TargetFailure = 'no-target' | 'several-targets';
9
+
10
+ // The file exports one Deployment or Plugin, under any name or as the default export. A value
11
+ // exported twice counts once.
12
+ export function pickBuildTarget(
13
+ exports: Readonly<Record<string, unknown>>,
14
+ ): Result<BuildTarget, TargetFailure> {
15
+ const targets = [...new Set(Object.values(exports))]
16
+ .map((value) => toBuildTarget(value))
17
+ .filter((target) => target !== undefined);
18
+
19
+ const [first, ...others] = targets;
20
+
21
+ if (first === undefined) {
22
+ return { ok: false, failure: 'no-target' };
23
+ }
24
+
25
+ if (others.length > 0) {
26
+ return { ok: false, failure: 'several-targets' };
27
+ }
28
+
29
+ return { ok: true, value: first };
30
+ }
31
+
32
+ function toBuildTarget(value: unknown): BuildTarget | undefined {
33
+ if (isDeployment(value)) {
34
+ return { kind: 'deployment', deployment: value };
35
+ }
36
+
37
+ if (isPlugin(value)) {
38
+ return { kind: 'plugin', plugin: value };
39
+ }
40
+
41
+ return undefined;
42
+ }
43
+
44
+ function isDeployment(value: unknown): value is Deployment {
45
+ return isRecord(value) && typeof value['name'] === 'string' && Array.isArray(value['plugins']);
46
+ }
47
+
48
+ function isPlugin(value: unknown): value is Plugin {
49
+ return (
50
+ isRecord(value) &&
51
+ typeof value['name'] === 'string' &&
52
+ typeof value['kit'] === 'number' &&
53
+ Array.isArray(value['consumers'])
54
+ );
55
+ }
@@ -0,0 +1,18 @@
1
+ import { dirname, resolve } from 'node:path';
2
+
3
+ // The nearest folder at or above the start that holds a package.json, or undefined when none does.
4
+ export async function readPackageRoot(start: string): Promise<string | undefined> {
5
+ let folder = start;
6
+
7
+ while (!(await Bun.file(resolve(folder, 'package.json')).exists())) {
8
+ const parent = dirname(folder);
9
+
10
+ if (parent === folder) {
11
+ return undefined;
12
+ }
13
+
14
+ folder = parent;
15
+ }
16
+
17
+ return folder;
18
+ }
@@ -0,0 +1,199 @@
1
+ import { mkdir, rm } from 'node:fs/promises';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import type { DeploymentFailure } from '@pepys/kit';
5
+ import { isRecord, readOptionalText } from '@zgeoff/std';
6
+ import { buildConsumerBuildManifest } from './build-consumer-build-manifest.ts';
7
+ import type { BuiltModule } from './build-consumer-build-manifest.ts';
8
+ import type { GlueFile } from './build-consumer-glue.ts';
9
+ import { checkBuildTarget } from './check-build-target.ts';
10
+ import type { CheckedTarget } from './check-build-target.ts';
11
+ import { collectGlueFiles } from './collect-glue-files.ts';
12
+ import { compileConsumer } from './compile-consumer.ts';
13
+ import type { BuildTools } from './compile-consumer.ts';
14
+ import { pickBuildTarget } from './pick-build-target.ts';
15
+ import type { TargetFailure } from './pick-build-target.ts';
16
+ import { readPackageRoot } from './read-package-root.ts';
17
+ import { writeBuildManifest } from './write-build-manifest.ts';
18
+
19
+ // scriptc comes with the CLI; a tool that the machine lacks is undefined
20
+ export interface BuildToolPaths {
21
+ scriptc: string;
22
+ zig: string | undefined;
23
+ wasmTools: string | undefined;
24
+ }
25
+
26
+ export interface BuildOptions {
27
+ file: string;
28
+
29
+ // compare the glue with the files on disk and write nothing
30
+ isCheck: boolean;
31
+
32
+ // The folder for the modules and consumers.json. With none, the build stops after the glue, so a
33
+ // plug-in author writes and tests a consumer with no zig and no wasm-tools.
34
+ out: string | undefined;
35
+
36
+ tools: BuildToolPaths;
37
+ }
38
+
39
+ export type BuildOutcome =
40
+ | { kind: 'built'; written: readonly string[]; unchanged: readonly string[] }
41
+ | {
42
+ kind: 'compiled';
43
+ written: readonly string[];
44
+ unchanged: readonly string[];
45
+ modules: readonly BuiltModule[];
46
+ manifest: string;
47
+ }
48
+ | { kind: 'stale'; stale: readonly string[] }
49
+ | { kind: 'fresh'; count: number }
50
+ | { kind: 'no-target'; reason: TargetFailure }
51
+ | { kind: 'invalid'; failures: readonly DeploymentFailure[] }
52
+ | { kind: 'missing-tool'; tool: 'zig' | 'wasm-tools' }
53
+ | { kind: 'compile-failed'; consumer: string; reason: string }
54
+ | { kind: 'refused-manifest'; issues: readonly string[] };
55
+
56
+ // The four steps of the build: check the target, write the glue of each consumer, compile each
57
+ // entry with scriptc, and write each module and consumers.json.
58
+ export async function runBuild(options: BuildOptions): Promise<BuildOutcome> {
59
+ const file = resolve(options.file);
60
+
61
+ const loaded: unknown = await import(pathToFileURL(file).href);
62
+
63
+ const exports = isRecord(loaded) ? loaded : {};
64
+ const picked = pickBuildTarget(exports);
65
+
66
+ if (!picked.ok) {
67
+ return { kind: 'no-target', reason: picked.failure };
68
+ }
69
+
70
+ const checked = checkBuildTarget(picked.value);
71
+
72
+ if (!checked.ok) {
73
+ return { kind: 'invalid', failures: checked.failure };
74
+ }
75
+
76
+ const foundRoot = await readPackageRoot(dirname(file));
77
+
78
+ const glue = collectGlueFiles({
79
+ target: checked.value,
80
+ packageRoot: foundRoot ?? dirname(file),
81
+ });
82
+
83
+ const stale = await readStalePaths(glue);
84
+
85
+ if (options.isCheck) {
86
+ return buildCheckOutcome(stale, glue.length);
87
+ }
88
+
89
+ for (const entry of glue.filter((candidate) => stale.includes(candidate.path))) {
90
+ await mkdir(dirname(entry.path), { recursive: true });
91
+
92
+ await Bun.write(entry.path, entry.content);
93
+ }
94
+
95
+ const written = stale;
96
+ const unchanged = glue.map((entry) => entry.path).filter((path) => !stale.includes(path));
97
+
98
+ if (options.out === undefined) {
99
+ return { kind: 'built', written, unchanged };
100
+ }
101
+
102
+ const zig = options.tools.zig;
103
+ const wasmTools = options.tools.wasmTools;
104
+
105
+ if (zig === undefined) {
106
+ return { kind: 'missing-tool', tool: 'zig' };
107
+ }
108
+
109
+ if (wasmTools === undefined) {
110
+ return { kind: 'missing-tool', tool: 'wasm-tools' };
111
+ }
112
+
113
+ const out = resolve(options.out);
114
+
115
+ const compiled = await compileTarget({
116
+ target: checked.value,
117
+ out,
118
+ tools: { scriptc: options.tools.scriptc, zig, wasmTools },
119
+ });
120
+
121
+ if (compiled.kind !== 'compiled') {
122
+ return compiled;
123
+ }
124
+
125
+ const manifest = await writeBuildManifest(
126
+ out,
127
+ buildConsumerBuildManifest({ target: checked.value, modules: compiled.modules }),
128
+ );
129
+
130
+ if (!manifest.ok) {
131
+ return { kind: 'refused-manifest', issues: manifest.failure };
132
+ }
133
+
134
+ return {
135
+ kind: 'compiled',
136
+ written,
137
+ unchanged,
138
+ modules: compiled.modules,
139
+ manifest: manifest.value,
140
+ };
141
+ }
142
+
143
+ async function readStalePaths(glue: readonly GlueFile[]): Promise<string[]> {
144
+ const results = await Promise.all(
145
+ glue.map(async (entry) => ({
146
+ path: entry.path,
147
+ isStale: (await readOptionalText(entry.path)) !== entry.content,
148
+ })),
149
+ );
150
+
151
+ return results.filter((result) => result.isStale).map((result) => result.path);
152
+ }
153
+
154
+ function buildCheckOutcome(stale: readonly string[], count: number): BuildOutcome {
155
+ if (stale.length > 0) {
156
+ return { kind: 'stale', stale };
157
+ }
158
+
159
+ return { kind: 'fresh', count };
160
+ }
161
+
162
+ interface CompileTargetParameters {
163
+ target: CheckedTarget;
164
+ out: string;
165
+ tools: BuildTools;
166
+ }
167
+
168
+ type CompileTargetOutcome =
169
+ | { kind: 'compiled'; modules: readonly BuiltModule[] }
170
+ | { kind: 'compile-failed'; consumer: string; reason: string };
171
+
172
+ async function compileTarget(parameters: CompileTargetParameters): Promise<CompileTargetOutcome> {
173
+ const families = parameters.target.checked.sources.flatMap((source) => source.families);
174
+ const modules: BuiltModule[] = [];
175
+
176
+ try {
177
+ for (const plugin of parameters.target.plugins) {
178
+ for (const consumer of plugin.consumers) {
179
+ const built = await compileConsumer({
180
+ consumer,
181
+ families,
182
+ settings: parameters.target.checked.settings[plugin.name],
183
+ out: parameters.out,
184
+ tools: parameters.tools,
185
+ });
186
+
187
+ if (!built.ok) {
188
+ return { kind: 'compile-failed', consumer: consumer.name, reason: built.failure };
189
+ }
190
+
191
+ modules.push(built.value);
192
+ }
193
+ }
194
+ } finally {
195
+ await rm(join(parameters.out, '.work'), { recursive: true, force: true });
196
+ }
197
+
198
+ return { kind: 'compiled', modules };
199
+ }
@@ -0,0 +1,30 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import type { Result } from '@pepys/kit';
4
+ import type { ConsumerBuildManifest } from '@pepys/schema';
5
+ import { consumerBuildManifestSchema } from '@pepys/schema';
6
+
7
+ // Writes `<out>/consumers.json` and returns its path, or the schema's issues and writes nothing. The
8
+ // file passes the strict schema first, so the build never writes a file that the host refuses, such
9
+ // as one with a table named after a table of the host.
10
+ export async function writeBuildManifest(
11
+ out: string,
12
+ manifest: ConsumerBuildManifest,
13
+ ): Promise<Result<string, readonly string[]>> {
14
+ const parsed = consumerBuildManifestSchema.safeParse(manifest);
15
+
16
+ if (!parsed.success) {
17
+ return {
18
+ ok: false,
19
+ failure: parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`),
20
+ };
21
+ }
22
+
23
+ const path = join(out, 'consumers.json');
24
+
25
+ await mkdir(out, { recursive: true });
26
+
27
+ await Bun.write(path, `${JSON.stringify(manifest, null, 2)}\n`);
28
+
29
+ return { ok: true, value: path };
30
+ }
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export { buildConsumerBuildManifest } from './build/build-consumer-build-manifest.ts';
2
+ export type { BuiltModule } from './build/build-consumer-build-manifest.ts';
3
+ export type { GlueFile } from './build/build-consumer-glue.ts';
4
+ export { checkBuildTarget } from './build/check-build-target.ts';
5
+ export { collectGlueFiles } from './build/collect-glue-files.ts';
6
+ export { writeBuildManifest } from './build/write-build-manifest.ts';
package/src/run-cli.ts ADDED
@@ -0,0 +1,42 @@
1
+ import { dirname, join } from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { writeStdout } from '@zgeoff/std';
4
+ import { formatBuildOutcome } from './build/format-build-outcome.ts';
5
+ import { parseBuildArguments } from './build/parse-build-arguments.ts';
6
+ import { runBuild } from './build/run-build.ts';
7
+ import type { BuildToolPaths } from './build/run-build.ts';
8
+
9
+ // Returns the exit code.
10
+ export async function runCLI(args: readonly string[]): Promise<number> {
11
+ const parsed = parseBuildArguments(args);
12
+
13
+ if (!parsed.ok) {
14
+ process.stderr.write(`${parsed.failure}\n`);
15
+
16
+ return 1;
17
+ }
18
+
19
+ const outcome = await runBuild({ ...parsed.value, tools: readBuildTools() });
20
+
21
+ const formatted = formatBuildOutcome(outcome);
22
+
23
+ if (formatted.exitCode === 0) {
24
+ writeStdout(formatted.text);
25
+ } else {
26
+ process.stderr.write(formatted.text);
27
+ }
28
+
29
+ return formatted.exitCode;
30
+ }
31
+
32
+ // scriptc comes with the CLI. zig and wasm-tools come from the two variables that pin a version, or
33
+ // from PATH.
34
+ function readBuildTools(): BuildToolPaths {
35
+ const scriptcPackage = fileURLToPath(import.meta.resolve('scriptc/package.json'));
36
+
37
+ return {
38
+ scriptc: join(dirname(scriptcPackage), 'bin/scriptc.exe'),
39
+ zig: process.env['PEPYS_ZIG_BIN'] ?? Bun.which('zig') ?? undefined,
40
+ wasmTools: process.env['PEPYS_WASM_TOOLS_BIN'] ?? Bun.which('wasm-tools') ?? undefined,
41
+ };
42
+ }